Skip to content

Commit e1b87e0

Browse files
committed
refactor(time): describe as @click.group of commands
1 parent b15be12 commit e1b87e0

File tree

7 files changed

+261
-91
lines changed

7 files changed

+261
-91
lines changed
Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
from argparse import Namespace
1+
import click
22

3-
from compiler_admin.commands.time.convert import convert # noqa: F401
4-
from compiler_admin.commands.time.download import download # noqa: F401
3+
from compiler_admin.commands.time.convert import convert
4+
from compiler_admin.commands.time.download import download
55

66

7-
def time(args: Namespace, *extra):
8-
# try to call the subcommand function directly from global (module) symbols
9-
# if the subcommand function was imported above, it should exist in globals()
10-
global_env = globals()
7+
@click.group
8+
def time():
9+
"""
10+
Work with Compiler time entries.
11+
"""
12+
pass
1113

12-
if args.subcommand in global_env:
13-
cmd_func = global_env[args.subcommand]
14-
cmd_func(args, *extra)
15-
else:
16-
raise NotImplementedError(f"Unknown time subcommand: {args.subcommand}")
14+
15+
time.add_command(convert)
16+
time.add_command(download)

compiler_admin/commands/time/convert.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
from argparse import Namespace
1+
import os
2+
import sys
3+
from typing import TextIO
4+
5+
import click
26

3-
from compiler_admin import RESULT_SUCCESS
47
from compiler_admin.services.harvest import CONVERTERS as HARVEST_CONVERTERS
58
from compiler_admin.services.toggl import CONVERTERS as TOGGL_CONVERTERS
69

@@ -21,9 +24,44 @@ def _get_source_converter(from_fmt: str, to_fmt: str):
2124
)
2225

2326

24-
def convert(args: Namespace, *extras):
25-
converter = _get_source_converter(args.from_fmt, args.to_fmt)
26-
27-
converter(source_path=args.input, output_path=args.output, client_name=args.client)
27+
@click.command()
28+
@click.option(
29+
"--input",
30+
default=os.environ.get("TOGGL_DATA", sys.stdin),
31+
help="The path to the source data for conversion. Defaults to $TOGGL_DATA or stdin.",
32+
)
33+
@click.option(
34+
"--output",
35+
default=os.environ.get("HARVEST_DATA", sys.stdout),
36+
help="The path to the file where converted data should be written. Defaults to $HARVEST_DATA or stdout.",
37+
)
38+
@click.option(
39+
"--from",
40+
"from_fmt",
41+
default="toggl",
42+
help="The format of the source data.",
43+
show_default=True,
44+
type=click.Choice(sorted(CONVERTERS.keys()), case_sensitive=False),
45+
)
46+
@click.option(
47+
"--to",
48+
"to_fmt",
49+
default="harvest",
50+
help="The format of the converted data.",
51+
show_default=True,
52+
type=click.Choice(sorted([to_fmt for sub in CONVERTERS.values() for to_fmt in sub.keys()]), case_sensitive=False),
53+
)
54+
@click.option("--client", help="The name of the client to use in converted data.")
55+
def convert(
56+
input: str | TextIO = os.environ.get("TOGGL_DATA", sys.stdin),
57+
output: str | TextIO = os.environ.get("HARVEST_DATA", sys.stdout),
58+
from_fmt="toggl",
59+
to_fmt="harvest",
60+
client="",
61+
):
62+
"""
63+
Convert a time report from one format into another.
64+
"""
65+
converter = _get_source_converter(from_fmt, to_fmt)
2866

29-
return RESULT_SUCCESS
67+
converter(source_path=input, output_path=output, client_name=client)
Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,120 @@
1-
from argparse import Namespace
1+
from datetime import datetime, timedelta
2+
import os
3+
import sys
4+
from typing import List
5+
6+
import click
7+
from pytz import timezone
28

3-
from compiler_admin import RESULT_SUCCESS
49
from compiler_admin.services.toggl import TOGGL_COLUMNS, download_time_entries
510

611

7-
def download(args: Namespace, *extras):
8-
params = dict(
9-
start_date=args.start, end_date=args.end, output_path=args.output, output_cols=TOGGL_COLUMNS, billable=args.billable
10-
)
12+
TZINFO = timezone(os.environ.get("TZ_NAME", "America/Los_Angeles"))
1113

12-
if args.client_ids:
13-
params.update(dict(client_ids=args.client_ids))
14-
if args.project_ids:
15-
params.update(dict(project_ids=args.project_ids))
16-
if args.task_ids:
17-
params.update(dict(task_ids=args.task_ids))
18-
if args.user_ids:
19-
params.update(dict(user_ids=args.user_ids))
2014

21-
download_time_entries(**params)
15+
def local_now():
16+
return datetime.now(tz=TZINFO)
17+
18+
19+
def prior_month_end():
20+
now = local_now()
21+
first = now.replace(day=1)
22+
return first - timedelta(days=1)
23+
24+
25+
def prior_month_start():
26+
end = prior_month_end()
27+
return end.replace(day=1)
2228

23-
return RESULT_SUCCESS
29+
30+
@click.command()
31+
@click.option(
32+
"--start",
33+
metavar="YYYY-MM-DD",
34+
default=prior_month_start(),
35+
type=lambda s: TZINFO.localize(datetime.strptime(s, "%Y-%m-%d")),
36+
help="The start date of the reporting period. Defaults to the beginning of the prior month.",
37+
)
38+
@click.option(
39+
"--end",
40+
metavar="YYYY-MM-DD",
41+
default=prior_month_end(),
42+
type=lambda s: TZINFO.localize(datetime.strptime(s, "%Y-%m-%d")),
43+
help="The end date of the reporting period. Defaults to the end of the prior month.",
44+
)
45+
@click.option(
46+
"--output",
47+
default=os.environ.get("TOGGL_DATA", sys.stdout),
48+
help="The path to the file where downloaded data should be written. Defaults to a path calculated from the date range.",
49+
)
50+
@click.option(
51+
"--all",
52+
"billable",
53+
is_flag=True,
54+
default=True,
55+
help="Download all time entries. The default is to download only billable time entries.",
56+
)
57+
@click.option(
58+
"-c",
59+
"--client",
60+
"client_ids",
61+
multiple=True,
62+
help="An ID for a Toggl Client to filter for in reports. Can be supplied more than once.",
63+
metavar="CLIENT_ID",
64+
type=int,
65+
)
66+
@click.option(
67+
"-p",
68+
"--project",
69+
"project_ids",
70+
multiple=True,
71+
help="An ID for a Toggl Project to filter for in reports. Can be supplied more than once.",
72+
metavar="PROJECT_ID",
73+
type=int,
74+
)
75+
@click.option(
76+
"-t",
77+
"--task",
78+
"task_ids",
79+
multiple=True,
80+
metavar="TASK_ID",
81+
help="An ID for a Toggl Project Task to filter for in reports. Can be supplied more than once.",
82+
type=int,
83+
)
84+
@click.option(
85+
"-u",
86+
"--user",
87+
"user_ids",
88+
multiple=True,
89+
help="An ID for a Toggl User to filter for in reports. Can be supplied more than once.",
90+
metavar="USER_ID",
91+
type=int,
92+
)
93+
def download(
94+
start: datetime,
95+
end: datetime,
96+
output: str = "",
97+
billable: bool = True,
98+
client_ids: List[int] = [],
99+
project_ids: List[int] = [],
100+
task_ids: List[int] = [],
101+
user_ids: List[int] = [],
102+
):
103+
"""
104+
Download a Toggl time report in CSV format.
105+
"""
106+
if not output:
107+
output = f"Toggl_time_entries_{start.strftime('%Y-%m-%d')}_{end.strftime('%Y-%m-%d')}.csv"
108+
109+
params = dict(start_date=start, end_date=end, output_path=output, output_cols=TOGGL_COLUMNS, billable=billable)
110+
111+
if client_ids:
112+
params.update(dict(client_ids=client_ids))
113+
if project_ids:
114+
params.update(dict(project_ids=project_ids))
115+
if task_ids:
116+
params.update(dict(task_ids=task_ids))
117+
if user_ids:
118+
params.update(dict(user_ids=user_ids))
119+
120+
download_time_entries(**params)

tests/commands/time/test__init__.py

Lines changed: 0 additions & 25 deletions
This file was deleted.

tests/commands/time/test_convert.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from argparse import Namespace
21
import pytest
32

43
from compiler_admin import RESULT_SUCCESS
@@ -40,14 +39,15 @@ def test_get_source_converter_mismatch():
4039
_get_source_converter("toggl", "nope")
4140

4241

43-
def test_convert(mock_get_source_converter):
44-
args = Namespace(input="input", output="output", client="client", from_fmt="from", to_fmt="to")
45-
res = convert(args)
42+
def test_convert(cli_runner, mock_get_source_converter):
43+
result = cli_runner.invoke(
44+
convert, ["--input", "input", "--output", "output", "--client", "client", "--from", "harvest", "--to", "toggl"]
45+
)
4646

47-
assert res == RESULT_SUCCESS
48-
mock_get_source_converter.assert_called_once_with(args.from_fmt, args.to_fmt)
47+
assert result.exit_code == RESULT_SUCCESS
48+
mock_get_source_converter.assert_called_once_with("harvest", "toggl")
4949
mock_get_source_converter.return_value.assert_called_once_with(
50-
source_path=args.input, output_path=args.output, client_name=args.client
50+
source_path="input", output_path="output", client_name="client"
5151
)
5252

5353

Lines changed: 76 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,93 @@
1-
from argparse import Namespace
21
from datetime import datetime
32
import pytest
43

54
from compiler_admin import RESULT_SUCCESS
6-
from compiler_admin.commands.time.download import __name__ as MODULE, download, TOGGL_COLUMNS
5+
from compiler_admin.commands.time.download import (
6+
__name__ as MODULE,
7+
download,
8+
TOGGL_COLUMNS,
9+
TZINFO,
10+
prior_month_end,
11+
prior_month_start,
12+
)
13+
14+
15+
@pytest.fixture
16+
def mock_local_now(mocker):
17+
dt = datetime(2024, 9, 25, tzinfo=TZINFO)
18+
mocker.patch(f"{MODULE}.local_now", return_value=dt)
19+
return dt
20+
21+
22+
@pytest.fixture
23+
def mock_start(mock_local_now):
24+
return datetime(2024, 8, 1, tzinfo=TZINFO)
25+
26+
27+
@pytest.fixture
28+
def mock_end(mock_local_now):
29+
return datetime(2024, 8, 31, tzinfo=TZINFO)
730

831

932
@pytest.fixture
1033
def mock_download_time_entries(mocker):
1134
return mocker.patch(f"{MODULE}.download_time_entries")
1235

1336

37+
def test_prior_month_start(mock_start):
38+
start = prior_month_start()
39+
40+
assert start == mock_start
41+
42+
43+
def test_prior_month_end(mock_end):
44+
end = prior_month_end()
45+
46+
assert end == mock_end
47+
48+
1449
@pytest.mark.parametrize("billable", [True, False])
15-
def test_download(mock_download_time_entries, billable):
16-
date = datetime.now()
17-
args = Namespace(
18-
start=date,
19-
end=date,
20-
output="output",
21-
billable=billable,
22-
client_ids=["c1", "c2"],
23-
project_ids=["p1", "p2"],
24-
task_ids=["t1", "t2"],
25-
user_ids=["u1", "u2"],
26-
)
50+
def test_download(cli_runner, mock_download_time_entries, billable):
51+
date = datetime.now(tz=TZINFO).replace(hour=0, minute=0, second=0, microsecond=0)
52+
args = [
53+
"--start",
54+
date.strftime("%Y-%m-%d"),
55+
"--end",
56+
date.strftime("%Y-%m-%d"),
57+
"--output",
58+
"output",
59+
"-c",
60+
1,
61+
"-c",
62+
2,
63+
"-p",
64+
3,
65+
"-p",
66+
4,
67+
"-t",
68+
5,
69+
"-t",
70+
6,
71+
"-u",
72+
7,
73+
"-u",
74+
8,
75+
]
2776

28-
res = download(args)
77+
if not billable:
78+
args.append("--all")
2979

30-
assert res == RESULT_SUCCESS
80+
result = cli_runner.invoke(download, args)
81+
82+
assert result.exit_code == RESULT_SUCCESS
3183
mock_download_time_entries.assert_called_once_with(
32-
start_date=args.start,
33-
end_date=args.end,
34-
output_path=args.output,
84+
start_date=date,
85+
end_date=date,
86+
output_path="output",
3587
output_cols=TOGGL_COLUMNS,
36-
billable=args.billable,
37-
client_ids=args.client_ids,
38-
project_ids=args.project_ids,
39-
task_ids=args.task_ids,
40-
user_ids=args.user_ids,
88+
billable=billable,
89+
client_ids=(1, 2),
90+
project_ids=(3, 4),
91+
task_ids=(5, 6),
92+
user_ids=(7, 8),
4193
)

0 commit comments

Comments
 (0)