freeagent-cli 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/PKG-INFO +3 -3
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/README.md +2 -2
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/pyproject.toml +6 -1
- freeagent_cli-0.3.0/src/freeagent_cli/__init__.py +1 -0
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/src/freeagent_cli/api.py +17 -0
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/src/freeagent_cli/cli.py +171 -5
- freeagent_cli-0.3.0/tests/__init__.py +0 -0
- freeagent_cli-0.3.0/tests/test_api.py +111 -0
- freeagent_cli-0.3.0/tests/test_cli.py +281 -0
- freeagent_cli-0.3.0/tests/test_helpers.py +83 -0
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/uv.lock +61 -1
- freeagent_cli-0.2.0/src/freeagent_cli/__init__.py +0 -1
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/.gitignore +0 -0
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/LICENSE +0 -0
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/src/freeagent_cli/auth.py +0 -0
- {freeagent_cli-0.2.0 → freeagent_cli-0.3.0}/src/freeagent_cli/config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: freeagent-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI for submitting FreeAgent timeslips
|
|
5
5
|
Project-URL: Homepage, https://github.com/tomdyson/freeagent-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/tomdyson/freeagent-cli
|
|
@@ -67,9 +67,9 @@ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.js
|
|
|
67
67
|
|
|
68
68
|
```
|
|
69
69
|
freeagent-cli --help # canonical flow
|
|
70
|
-
freeagent-cli
|
|
70
|
+
freeagent-cli recent # what you've already logged (run this first to avoid duplicates)
|
|
71
71
|
freeagent-cli log <project> <duration> [comment...] # submit a timeslip
|
|
72
|
-
freeagent-cli
|
|
72
|
+
freeagent-cli projects # first-time / discovery: projects + tasks in one call
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
Examples:
|
|
@@ -40,9 +40,9 @@ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.js
|
|
|
40
40
|
|
|
41
41
|
```
|
|
42
42
|
freeagent-cli --help # canonical flow
|
|
43
|
-
freeagent-cli
|
|
43
|
+
freeagent-cli recent # what you've already logged (run this first to avoid duplicates)
|
|
44
44
|
freeagent-cli log <project> <duration> [comment...] # submit a timeslip
|
|
45
|
-
freeagent-cli
|
|
45
|
+
freeagent-cli projects # first-time / discovery: projects + tasks in one call
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Examples:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "freeagent-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "CLI for submitting FreeAgent timeslips"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -40,3 +40,8 @@ build-backend = "hatchling.build"
|
|
|
40
40
|
|
|
41
41
|
[tool.hatch.build.targets.wheel]
|
|
42
42
|
packages = ["src/freeagent_cli"]
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=9.0.3",
|
|
47
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -71,3 +71,20 @@ class FreeAgent:
|
|
|
71
71
|
if comment:
|
|
72
72
|
body["timeslip"]["comment"] = comment
|
|
73
73
|
return self.post("/v2/timeslips", body)
|
|
74
|
+
|
|
75
|
+
def delete(self, path: str) -> dict:
|
|
76
|
+
with self._client() as c:
|
|
77
|
+
r = c.delete(path)
|
|
78
|
+
r.raise_for_status()
|
|
79
|
+
return r.json()
|
|
80
|
+
|
|
81
|
+
def delete_timeslip(self, timeslip_id: str) -> dict:
|
|
82
|
+
try:
|
|
83
|
+
return self.delete(f"/v2/timeslips/{timeslip_id}")
|
|
84
|
+
except httpx.HTTPStatusError as e:
|
|
85
|
+
if e.response.status_code == 404:
|
|
86
|
+
return {"deleted": True, "id": timeslip_id, "already_deleted": True}
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
def get_timeslip(self, timeslip_id: str) -> dict:
|
|
90
|
+
return self.get(f"/v2/timeslips/{timeslip_id}", nested="true")["timeslip"]
|
|
@@ -19,6 +19,10 @@ def _api() -> FreeAgent:
|
|
|
19
19
|
return FreeAgent(c)
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def _extract_id(query: str) -> str:
|
|
23
|
+
return query.rsplit("/", 1)[-1] if "/" in query else query
|
|
24
|
+
|
|
25
|
+
|
|
22
26
|
def parse_hours(s: str) -> float:
|
|
23
27
|
"""Parse a duration: 1.5, 90m, 1h, 1h30m, 1:30."""
|
|
24
28
|
s = s.strip().lower()
|
|
@@ -37,6 +41,22 @@ def parse_hours(s: str) -> float:
|
|
|
37
41
|
raise ValueError(f"Cannot parse duration {s!r}; try 1.5, 90m, 1h30m, or 1:30")
|
|
38
42
|
|
|
39
43
|
|
|
44
|
+
def format_hours(value) -> str:
|
|
45
|
+
"""Format a decimal-hours value as 40m / 1h / 1h30m, rounding to the nearest minute."""
|
|
46
|
+
try:
|
|
47
|
+
total_min = round(float(value) * 60)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return str(value)
|
|
50
|
+
if total_min == 0:
|
|
51
|
+
return "0m"
|
|
52
|
+
h, m = divmod(total_min, 60)
|
|
53
|
+
if h == 0:
|
|
54
|
+
return f"{m}m"
|
|
55
|
+
if m == 0:
|
|
56
|
+
return f"{h}h"
|
|
57
|
+
return f"{h}h{m}m"
|
|
58
|
+
|
|
59
|
+
|
|
40
60
|
def _resolve(items: list[dict], query: str, label: str) -> dict:
|
|
41
61
|
if query.startswith("http"):
|
|
42
62
|
match = [i for i in items if i["url"] == query]
|
|
@@ -73,9 +93,9 @@ def _pick_task(tasks: list[dict], task_q: str | None, project_name: str) -> dict
|
|
|
73
93
|
@click.group(epilog="""
|
|
74
94
|
\b
|
|
75
95
|
Typical flow:
|
|
76
|
-
freeagent-cli
|
|
96
|
+
freeagent-cli recent # check what you've already logged (avoids duplicates)
|
|
77
97
|
freeagent-cli log <project> <duration> [comment] [--task <name>] [--dry-run]
|
|
78
|
-
freeagent-cli
|
|
98
|
+
freeagent-cli projects # first-time / discovery: projects + tasks
|
|
79
99
|
|
|
80
100
|
\b
|
|
81
101
|
Examples:
|
|
@@ -209,6 +229,8 @@ def log(project_q, duration, comment_parts, task_q, date_, dry_run):
|
|
|
209
229
|
freeagent-cli log Acme 1h30m "fixed the thing"
|
|
210
230
|
freeagent-cli log Acme 90m fixed the thing # comment without quotes
|
|
211
231
|
freeagent-cli log "Big Co" 1.5 --task Coding --date 2026-05-01
|
|
232
|
+
|
|
233
|
+
Tip: run `freeagent-cli recent` first to avoid duplicate entries.
|
|
212
234
|
"""
|
|
213
235
|
try:
|
|
214
236
|
hours = parse_hours(duration)
|
|
@@ -222,7 +244,7 @@ def log(project_q, duration, comment_parts, task_q, date_, dry_run):
|
|
|
222
244
|
dated_on = date_ or _dt.date.today().isoformat()
|
|
223
245
|
|
|
224
246
|
if dry_run:
|
|
225
|
-
click.echo(f"DRY RUN — would submit {hours}
|
|
247
|
+
click.echo(f"DRY RUN — would submit {format_hours(hours)} on {dated_on} → {project['name']} / {task['name']}")
|
|
226
248
|
click.echo(f" project: {project['url']}")
|
|
227
249
|
click.echo(f" task: {task['url']}")
|
|
228
250
|
if comment:
|
|
@@ -235,7 +257,7 @@ def log(project_q, duration, comment_parts, task_q, date_, dry_run):
|
|
|
235
257
|
dated_on=dated_on, hours=hours, comment=comment,
|
|
236
258
|
)
|
|
237
259
|
ts = result["timeslips"][0] if "timeslips" in result else result.get("timeslip", result)
|
|
238
|
-
click.echo(f"Submitted {hours}
|
|
260
|
+
click.echo(f"Submitted {format_hours(hours)} on {dated_on} → {project['name']} / {task['name']}")
|
|
239
261
|
if isinstance(ts, dict) and "url" in ts:
|
|
240
262
|
click.echo(ts["url"])
|
|
241
263
|
|
|
@@ -265,7 +287,151 @@ def recent(limit, days, all_users):
|
|
|
265
287
|
pname = proj["name"] if isinstance(proj, dict) else "?"
|
|
266
288
|
tname = task_["name"] if isinstance(task_, dict) else "?"
|
|
267
289
|
comment = (s.get("comment") or "").replace("\n", " ")
|
|
268
|
-
click.echo(f"{s.get('dated_on','?')}\t{s.get('hours'
|
|
290
|
+
click.echo(f"{s.get('dated_on','?')}\t{format_hours(s.get('hours'))}\t{pname}\t{tname}\t{comment}\t{s.get('url','')}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# -- delete --------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
@main.command()
|
|
296
|
+
@click.argument("timeslip_q", metavar="TIMESLIP_ID_OR_URL")
|
|
297
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
|
|
298
|
+
def delete(timeslip_q, yes):
|
|
299
|
+
"""Delete a timeslip by numeric ID or full URL."""
|
|
300
|
+
api = _api()
|
|
301
|
+
tid = _extract_id(timeslip_q)
|
|
302
|
+
try:
|
|
303
|
+
ts = api.get_timeslip(tid)
|
|
304
|
+
except Exception:
|
|
305
|
+
raise click.UsageError(f"Timeslip {tid!r} not found.")
|
|
306
|
+
proj = ts.get("project")
|
|
307
|
+
task_ = ts.get("task")
|
|
308
|
+
pname = proj["name"] if isinstance(proj, dict) else "?"
|
|
309
|
+
tname = task_["name"] if isinstance(task_, dict) else "?"
|
|
310
|
+
comment = (ts.get("comment") or "")[:80]
|
|
311
|
+
click.echo(f"{ts.get('dated_on','?')} {format_hours(ts.get('hours'))} {pname} / {tname}")
|
|
312
|
+
if comment:
|
|
313
|
+
click.echo(f" {comment}")
|
|
314
|
+
if not yes:
|
|
315
|
+
click.confirm("Delete this timeslip?", abort=True)
|
|
316
|
+
result = api.delete_timeslip(tid)
|
|
317
|
+
if result.get("already_deleted"):
|
|
318
|
+
click.echo("Timeslip already deleted.")
|
|
319
|
+
else:
|
|
320
|
+
click.echo("Deleted.")
|
|
321
|
+
click.echo(ts.get("url", ""))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# -- edit ----------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
@main.command()
|
|
327
|
+
@click.argument("timeslip_q", metavar="TIMESLIP_ID_OR_URL")
|
|
328
|
+
@click.option("--project", "project_q", default=None,
|
|
329
|
+
help="New project (name substring, id, or URL).")
|
|
330
|
+
@click.option("--task", "task_q", default=None,
|
|
331
|
+
help="New task (name substring, id, or URL).")
|
|
332
|
+
@click.option("--duration", "duration_str", default=None,
|
|
333
|
+
help="New duration (1.5, 90m, 1h30m, 1:30).")
|
|
334
|
+
@click.option("--date", "date_", default=None, help="New date YYYY-MM-DD.")
|
|
335
|
+
@click.option("--comment", "comment", default=None, help="New comment.")
|
|
336
|
+
@click.option("--dry-run", is_flag=True, help="Resolve and preview, but don't submit.")
|
|
337
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
|
|
338
|
+
def edit(timeslip_q, project_q, task_q, duration_str, date_, comment, dry_run, yes):
|
|
339
|
+
"""Edit a timeslip: fetch, apply changes, delete old, create new.
|
|
340
|
+
|
|
341
|
+
\b
|
|
342
|
+
Only the options you pass are changed; everything else stays the same.
|
|
343
|
+
If you change --project, --task defaults to a matching task name in the new
|
|
344
|
+
project if one exists, otherwise you must pass --task explicitly.
|
|
345
|
+
|
|
346
|
+
\b
|
|
347
|
+
Examples:
|
|
348
|
+
freeagent-cli edit 123456 --duration 2h
|
|
349
|
+
freeagent-cli edit https://api.freeagent.com/v2/timeslips/123456 --comment "done"
|
|
350
|
+
freeagent-cli edit 123456 --project "Big Co" --task Coding --dry-run
|
|
351
|
+
"""
|
|
352
|
+
api = _api()
|
|
353
|
+
tid = _extract_id(timeslip_q)
|
|
354
|
+
try:
|
|
355
|
+
old = api.get_timeslip(tid)
|
|
356
|
+
except Exception:
|
|
357
|
+
raise click.UsageError(f"Timeslip {tid!r} not found.")
|
|
358
|
+
|
|
359
|
+
if not any([project_q, task_q, duration_str, date_, comment is not None]):
|
|
360
|
+
raise click.UsageError("Nothing to change. Use --help to see options.")
|
|
361
|
+
|
|
362
|
+
old_proj = old.get("project")
|
|
363
|
+
old_task = old.get("task")
|
|
364
|
+
|
|
365
|
+
new_project_url = old_proj["url"] if isinstance(old_proj, dict) else ""
|
|
366
|
+
new_task_url = old_task["url"] if isinstance(old_task, dict) else ""
|
|
367
|
+
new_hours = float(old.get("hours", 0))
|
|
368
|
+
new_date = old.get("dated_on", "")
|
|
369
|
+
new_comment = old.get("comment") or ""
|
|
370
|
+
|
|
371
|
+
old_pname = old_proj["name"] if isinstance(old_proj, dict) else "?"
|
|
372
|
+
old_tname = old_task["name"] if isinstance(old_task, dict) else "?"
|
|
373
|
+
new_pname = old_pname
|
|
374
|
+
new_tname = old_tname
|
|
375
|
+
|
|
376
|
+
if project_q:
|
|
377
|
+
new_proj = _resolve(api.projects(view="active"), project_q, "project")
|
|
378
|
+
new_project_url = new_proj["url"]
|
|
379
|
+
new_pname = new_proj["name"]
|
|
380
|
+
if not task_q:
|
|
381
|
+
new_tasks = api.tasks(new_project_url)
|
|
382
|
+
matches = [t for t in new_tasks if t["name"].lower() == old_tname.lower()]
|
|
383
|
+
if len(matches) == 1:
|
|
384
|
+
new_task_url = matches[0]["url"]
|
|
385
|
+
new_tname = matches[0]["name"]
|
|
386
|
+
else:
|
|
387
|
+
names = ", ".join(t["name"] for t in new_tasks) or "(none)"
|
|
388
|
+
raise click.UsageError(
|
|
389
|
+
f"Project changed; --task required. Available in {new_proj['name']!r}: {names}"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if task_q:
|
|
393
|
+
proj_tasks = api.tasks(new_project_url)
|
|
394
|
+
new_task = _resolve(proj_tasks, task_q, "task")
|
|
395
|
+
new_task_url = new_task["url"]
|
|
396
|
+
new_tname = new_task["name"]
|
|
397
|
+
|
|
398
|
+
if duration_str:
|
|
399
|
+
try:
|
|
400
|
+
new_hours = parse_hours(duration_str)
|
|
401
|
+
except ValueError as e:
|
|
402
|
+
raise click.UsageError(str(e))
|
|
403
|
+
|
|
404
|
+
if date_:
|
|
405
|
+
new_date = date_
|
|
406
|
+
|
|
407
|
+
if comment is not None:
|
|
408
|
+
new_comment = comment
|
|
409
|
+
|
|
410
|
+
lines = []
|
|
411
|
+
lines.append("Before → After")
|
|
412
|
+
lines.append(f" Project: {old_pname} → {new_pname}")
|
|
413
|
+
lines.append(f" Task: {old_tname} → {new_tname}")
|
|
414
|
+
lines.append(f" Date: {old.get('dated_on','?')} → {new_date}")
|
|
415
|
+
lines.append(f" Duration: {format_hours(old.get('hours'))} → {format_hours(new_hours)}")
|
|
416
|
+
lines.append(f" Comment: {repr(old.get('comment') or '')} → {repr(new_comment)}")
|
|
417
|
+
click.echo("\n".join(lines))
|
|
418
|
+
|
|
419
|
+
if dry_run:
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
if not yes:
|
|
423
|
+
click.confirm("Proceed with edit?", abort=True)
|
|
424
|
+
|
|
425
|
+
user_url = api.me()["url"]
|
|
426
|
+
result = api.create_timeslip(
|
|
427
|
+
user=user_url, project=new_project_url, task=new_task_url,
|
|
428
|
+
dated_on=new_date, hours=new_hours, comment=new_comment or None,
|
|
429
|
+
)
|
|
430
|
+
ts = result["timeslips"][0] if "timeslips" in result else result.get("timeslip", result)
|
|
431
|
+
api.delete_timeslip(tid)
|
|
432
|
+
click.echo(f"Edited → {new_pname} / {new_tname}{' --dry-run' if dry_run else ''}")
|
|
433
|
+
if isinstance(ts, dict) and "url" in ts:
|
|
434
|
+
click.echo(ts["url"])
|
|
269
435
|
|
|
270
436
|
|
|
271
437
|
if __name__ == "__main__":
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from freeagent_cli.api import FreeAgent
|
|
9
|
+
from freeagent_cli.config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def config():
|
|
14
|
+
c = Config()
|
|
15
|
+
c.client_id = "test-id"
|
|
16
|
+
c.client_secret = "test-secret"
|
|
17
|
+
c.refresh_token = "test-refresh"
|
|
18
|
+
c.access_token = "test-access"
|
|
19
|
+
c.access_token_expires_at = 9999999999.0
|
|
20
|
+
return c
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def api(config):
|
|
25
|
+
return FreeAgent(config)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestDeleteTimeslip:
|
|
29
|
+
def test_delete_success(self, api):
|
|
30
|
+
mock_client = MagicMock()
|
|
31
|
+
mock_response = MagicMock()
|
|
32
|
+
mock_response.status_code = 200
|
|
33
|
+
mock_response.raise_for_status.return_value = None
|
|
34
|
+
mock_response.json.return_value = {"timeslip": {"url": "/v2/timeslips/42"}}
|
|
35
|
+
mock_client.__enter__.return_value = mock_client
|
|
36
|
+
mock_client.delete.return_value = mock_response
|
|
37
|
+
|
|
38
|
+
with patch.object(api, "_client", return_value=mock_client):
|
|
39
|
+
result = api.delete_timeslip("42")
|
|
40
|
+
assert result == {"timeslip": {"url": "/v2/timeslips/42"}}
|
|
41
|
+
mock_client.delete.assert_called_once_with("/v2/timeslips/42")
|
|
42
|
+
|
|
43
|
+
def test_delete_404_returns_already_deleted(self, api):
|
|
44
|
+
mock_client = MagicMock()
|
|
45
|
+
mock_response = MagicMock()
|
|
46
|
+
mock_response.status_code = 404
|
|
47
|
+
mock_client.__enter__.return_value = mock_client
|
|
48
|
+
|
|
49
|
+
error_response = MagicMock()
|
|
50
|
+
error_response.status_code = 404
|
|
51
|
+
error_response.text = "Not found"
|
|
52
|
+
error = httpx.HTTPStatusError("404 Not Found", request=MagicMock(), response=error_response)
|
|
53
|
+
mock_client.delete.side_effect = error
|
|
54
|
+
|
|
55
|
+
with patch.object(api, "_client", return_value=mock_client):
|
|
56
|
+
result = api.delete_timeslip("42")
|
|
57
|
+
assert result == {"deleted": True, "id": "42", "already_deleted": True}
|
|
58
|
+
|
|
59
|
+
def test_delete_500_reraises(self, api):
|
|
60
|
+
mock_client = MagicMock()
|
|
61
|
+
mock_client.__enter__.return_value = mock_client
|
|
62
|
+
|
|
63
|
+
error_response = MagicMock()
|
|
64
|
+
error_response.status_code = 500
|
|
65
|
+
error = httpx.HTTPStatusError("500 Server Error", request=MagicMock(), response=error_response)
|
|
66
|
+
mock_client.delete.side_effect = error
|
|
67
|
+
|
|
68
|
+
with patch.object(api, "_client", return_value=mock_client):
|
|
69
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
70
|
+
api.delete_timeslip("42")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestGetTimeslip:
|
|
74
|
+
def test_get_timeslip(self, api):
|
|
75
|
+
mock_client = MagicMock()
|
|
76
|
+
mock_response = MagicMock()
|
|
77
|
+
mock_response.raise_for_status.return_value = None
|
|
78
|
+
mock_response.json.return_value = {
|
|
79
|
+
"timeslip": {
|
|
80
|
+
"url": "/v2/timeslips/42",
|
|
81
|
+
"hours": "2.5",
|
|
82
|
+
"dated_on": "2026-05-15",
|
|
83
|
+
"comment": "test work",
|
|
84
|
+
"project": {"name": "Acme", "url": "/v2/projects/1"},
|
|
85
|
+
"task": {"name": "Coding", "url": "/v2/tasks/1"},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
mock_client.__enter__.return_value = mock_client
|
|
89
|
+
mock_client.get.return_value = mock_response
|
|
90
|
+
|
|
91
|
+
with patch.object(api, "_client", return_value=mock_client):
|
|
92
|
+
result = api.get_timeslip("42")
|
|
93
|
+
assert result["url"] == "/v2/timeslips/42"
|
|
94
|
+
assert result["hours"] == "2.5"
|
|
95
|
+
mock_client.get.assert_called_once_with("/v2/timeslips/42", params={"nested": "true"})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestDelete:
|
|
99
|
+
def test_delete_generic(self, api):
|
|
100
|
+
mock_client = MagicMock()
|
|
101
|
+
mock_response = MagicMock()
|
|
102
|
+
mock_response.status_code = 200
|
|
103
|
+
mock_response.raise_for_status.return_value = None
|
|
104
|
+
mock_response.json.return_value = {"status": "ok"}
|
|
105
|
+
mock_client.__enter__.return_value = mock_client
|
|
106
|
+
mock_client.delete.return_value = mock_response
|
|
107
|
+
|
|
108
|
+
with patch.object(api, "_client", return_value=mock_client):
|
|
109
|
+
result = api.delete("/v2/some/resource")
|
|
110
|
+
assert result == {"status": "ok"}
|
|
111
|
+
mock_client.delete.assert_called_once_with("/v2/some/resource")
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from click.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from freeagent_cli.cli import main
|
|
9
|
+
from freeagent_cli.config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def runner():
|
|
14
|
+
return CliRunner()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def mock_config():
|
|
19
|
+
c = Config()
|
|
20
|
+
c.client_id = "test-id"
|
|
21
|
+
c.client_secret = "test-secret"
|
|
22
|
+
c.refresh_token = "test-refresh"
|
|
23
|
+
c.access_token = "test-access"
|
|
24
|
+
c.access_token_expires_at = 9999999999.0
|
|
25
|
+
return c
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def mock_api():
|
|
30
|
+
return MagicMock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _mock_api_and_config(api_mock, config):
|
|
34
|
+
api_mock.me.return_value = {"url": "/v2/users/me"}
|
|
35
|
+
api_mock.projects.return_value = [
|
|
36
|
+
{"name": "Acme", "url": "/v2/projects/1"},
|
|
37
|
+
{"name": "Big Co", "url": "/v2/projects/2"},
|
|
38
|
+
]
|
|
39
|
+
api_mock.tasks.return_value = [
|
|
40
|
+
{"name": "Coding", "url": "/v2/tasks/1"},
|
|
41
|
+
{"name": "Design", "url": "/v2/tasks/2"},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestDelete:
|
|
46
|
+
def test_delete_confirmed(self, runner, mock_config, mock_api):
|
|
47
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
48
|
+
mock_api.get_timeslip.return_value = {
|
|
49
|
+
"url": "/v2/timeslips/42",
|
|
50
|
+
"hours": "2.5",
|
|
51
|
+
"dated_on": "2026-05-15",
|
|
52
|
+
"comment": "test work",
|
|
53
|
+
"project": {"name": "Acme", "url": "/v2/projects/1"},
|
|
54
|
+
"task": {"name": "Coding", "url": "/v2/tasks/1"},
|
|
55
|
+
}
|
|
56
|
+
mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
|
|
57
|
+
|
|
58
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
59
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
60
|
+
result = runner.invoke(main, ["delete", "42"], input="y\n")
|
|
61
|
+
assert result.exit_code == 0
|
|
62
|
+
assert "Acme / Coding" in result.output
|
|
63
|
+
assert "2026-05-15" in result.output
|
|
64
|
+
assert "Deleted" in result.output
|
|
65
|
+
|
|
66
|
+
def test_delete_aborted(self, runner, mock_config, mock_api):
|
|
67
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
68
|
+
mock_api.get_timeslip.return_value = {
|
|
69
|
+
"url": "/v2/timeslips/42",
|
|
70
|
+
"hours": "2.5",
|
|
71
|
+
"dated_on": "2026-05-15",
|
|
72
|
+
"comment": "test work",
|
|
73
|
+
"project": {"name": "Acme", "url": "/v2/projects/1"},
|
|
74
|
+
"task": {"name": "Coding", "url": "/v2/tasks/1"},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
78
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
79
|
+
result = runner.invoke(main, ["delete", "42"], input="n\n")
|
|
80
|
+
assert result.exit_code == 1
|
|
81
|
+
|
|
82
|
+
def test_delete_yes_flag_skips_confirm(self, runner, mock_config, mock_api):
|
|
83
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
84
|
+
mock_api.get_timeslip.return_value = {
|
|
85
|
+
"url": "/v2/timeslips/42",
|
|
86
|
+
"hours": "2.5",
|
|
87
|
+
"dated_on": "2026-05-15",
|
|
88
|
+
"comment": "test work",
|
|
89
|
+
"project": {"name": "Acme", "url": "/v2/projects/1"},
|
|
90
|
+
"task": {"name": "Coding", "url": "/v2/tasks/1"},
|
|
91
|
+
}
|
|
92
|
+
mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
|
|
93
|
+
|
|
94
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
95
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
96
|
+
result = runner.invoke(main, ["delete", "42", "--yes"])
|
|
97
|
+
assert result.exit_code == 0
|
|
98
|
+
assert "Deleted" in result.output
|
|
99
|
+
|
|
100
|
+
def test_delete_by_url(self, runner, mock_config, mock_api):
|
|
101
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
102
|
+
mock_api.get_timeslip.return_value = {
|
|
103
|
+
"url": "/v2/timeslips/42",
|
|
104
|
+
"hours": "1.0",
|
|
105
|
+
"dated_on": "2026-05-14",
|
|
106
|
+
"comment": "",
|
|
107
|
+
"project": {"name": "Big Co", "url": "/v2/projects/2"},
|
|
108
|
+
"task": {"name": "Design", "url": "/v2/tasks/2"},
|
|
109
|
+
}
|
|
110
|
+
mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
|
|
111
|
+
|
|
112
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
113
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
114
|
+
result = runner.invoke(main, ["delete", "https://api.freeagent.com/v2/timeslips/42", "--yes"])
|
|
115
|
+
assert result.exit_code == 0
|
|
116
|
+
mock_api.get_timeslip.assert_called_once_with("42")
|
|
117
|
+
mock_api.delete_timeslip.assert_called_once_with("42")
|
|
118
|
+
|
|
119
|
+
def test_delete_already_deleted(self, runner, mock_config, mock_api):
|
|
120
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
121
|
+
mock_api.get_timeslip.side_effect = Exception("not found")
|
|
122
|
+
|
|
123
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
124
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
125
|
+
result = runner.invoke(main, ["delete", "999", "--yes"])
|
|
126
|
+
assert result.exit_code == 2
|
|
127
|
+
assert "not found" in result.output
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestEdit:
|
|
131
|
+
def _old_timeslip(self):
|
|
132
|
+
return {
|
|
133
|
+
"url": "/v2/timeslips/42",
|
|
134
|
+
"hours": "2.5",
|
|
135
|
+
"dated_on": "2026-05-15",
|
|
136
|
+
"comment": "test work",
|
|
137
|
+
"project": {"name": "Acme", "url": "/v2/projects/1"},
|
|
138
|
+
"task": {"name": "Coding", "url": "/v2/tasks/1"},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def test_edit_duration(self, runner, mock_config, mock_api):
|
|
142
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
143
|
+
mock_api.get_timeslip.return_value = self._old_timeslip()
|
|
144
|
+
mock_api.create_timeslip.return_value = {
|
|
145
|
+
"timeslip": {"url": "/v2/timeslips/43", "hours": "1.0", "dated_on": "2026-05-15"}
|
|
146
|
+
}
|
|
147
|
+
mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
|
|
148
|
+
|
|
149
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
150
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
151
|
+
result = runner.invoke(main, ["edit", "42", "--duration", "1h", "--yes"])
|
|
152
|
+
assert result.exit_code == 0
|
|
153
|
+
assert "2h30m → 1h" in result.output
|
|
154
|
+
mock_api.create_timeslip.assert_called_once()
|
|
155
|
+
mock_api.delete_timeslip.assert_called_once_with("42")
|
|
156
|
+
|
|
157
|
+
def test_edit_dry_run(self, runner, mock_config, mock_api):
|
|
158
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
159
|
+
mock_api.get_timeslip.return_value = self._old_timeslip()
|
|
160
|
+
|
|
161
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
162
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
163
|
+
result = runner.invoke(main, ["edit", "42", "--duration", "1h", "--dry-run"])
|
|
164
|
+
assert result.exit_code == 0
|
|
165
|
+
assert "2h30m → 1h" in result.output
|
|
166
|
+
mock_api.create_timeslip.assert_not_called()
|
|
167
|
+
mock_api.delete_timeslip.assert_not_called()
|
|
168
|
+
|
|
169
|
+
def test_edit_no_changes(self, runner, mock_config, mock_api):
|
|
170
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
171
|
+
mock_api.get_timeslip.return_value = self._old_timeslip()
|
|
172
|
+
|
|
173
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
174
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
175
|
+
result = runner.invoke(main, ["edit", "42"])
|
|
176
|
+
assert result.exit_code == 2
|
|
177
|
+
assert "Nothing to change" in result.output
|
|
178
|
+
|
|
179
|
+
def test_edit_project_with_matching_task(self, runner, mock_config, mock_api):
|
|
180
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
181
|
+
mock_api.get_timeslip.return_value = self._old_timeslip()
|
|
182
|
+
|
|
183
|
+
def _tasks(project_url):
|
|
184
|
+
if "/projects/2" in project_url:
|
|
185
|
+
return [
|
|
186
|
+
{"name": "Coding", "url": "/v2/tasks/3"},
|
|
187
|
+
{"name": "Testing", "url": "/v2/tasks/4"},
|
|
188
|
+
]
|
|
189
|
+
return [
|
|
190
|
+
{"name": "Coding", "url": "/v2/tasks/1"},
|
|
191
|
+
{"name": "Design", "url": "/v2/tasks/2"},
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
mock_api.tasks.side_effect = _tasks
|
|
195
|
+
mock_api.create_timeslip.return_value = {
|
|
196
|
+
"timeslip": {"url": "/v2/timeslips/43", "hours": "2.5", "dated_on": "2026-05-15"}
|
|
197
|
+
}
|
|
198
|
+
mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
|
|
199
|
+
|
|
200
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
201
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
202
|
+
result = runner.invoke(main, ["edit", "42", "--project", "Big Co", "--yes"])
|
|
203
|
+
assert result.exit_code == 0
|
|
204
|
+
assert "Acme → Big Co" in result.output
|
|
205
|
+
|
|
206
|
+
def test_edit_project_task_not_found_requires_flag(self, runner, mock_config, mock_api):
|
|
207
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
208
|
+
mock_api.get_timeslip.return_value = self._old_timeslip()
|
|
209
|
+
|
|
210
|
+
def _tasks(project_url):
|
|
211
|
+
if "/projects/2" in project_url:
|
|
212
|
+
return [
|
|
213
|
+
{"name": "Testing", "url": "/v2/tasks/4"},
|
|
214
|
+
]
|
|
215
|
+
return [
|
|
216
|
+
{"name": "Coding", "url": "/v2/tasks/1"},
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
mock_api.tasks.side_effect = _tasks
|
|
220
|
+
|
|
221
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
222
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
223
|
+
result = runner.invoke(main, ["edit", "42", "--project", "Big Co", "--yes"])
|
|
224
|
+
assert result.exit_code == 2
|
|
225
|
+
assert "--task required" in result.output
|
|
226
|
+
|
|
227
|
+
def test_edit_aborted(self, runner, mock_config, mock_api):
|
|
228
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
229
|
+
mock_api.get_timeslip.return_value = self._old_timeslip()
|
|
230
|
+
|
|
231
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
232
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
233
|
+
result = runner.invoke(main, ["edit", "42", "--duration", "3h"], input="n\n")
|
|
234
|
+
assert result.exit_code == 1
|
|
235
|
+
mock_api.create_timeslip.assert_not_called()
|
|
236
|
+
|
|
237
|
+
def test_edit_timeslip_not_found(self, runner, mock_config, mock_api):
|
|
238
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
239
|
+
mock_api.get_timeslip.side_effect = Exception("not found")
|
|
240
|
+
|
|
241
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
242
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
243
|
+
result = runner.invoke(main, ["edit", "999", "--duration", "2h"])
|
|
244
|
+
assert result.exit_code == 2
|
|
245
|
+
assert "not found" in result.output
|
|
246
|
+
|
|
247
|
+
def test_edit_comment_blank(self, runner, mock_config, mock_api):
|
|
248
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
249
|
+
mock_api.get_timeslip.return_value = self._old_timeslip()
|
|
250
|
+
mock_api.create_timeslip.return_value = {
|
|
251
|
+
"timeslip": {"url": "/v2/timeslips/43"}
|
|
252
|
+
}
|
|
253
|
+
mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
|
|
254
|
+
|
|
255
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
256
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
257
|
+
result = runner.invoke(main, ["edit", "42", "--comment", "", "--yes"])
|
|
258
|
+
assert result.exit_code == 0
|
|
259
|
+
assert "'test work' → ''" in result.output
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class TestRecent:
|
|
263
|
+
def test_recent_includes_url(self, runner, mock_config, mock_api):
|
|
264
|
+
_mock_api_and_config(mock_api, mock_config)
|
|
265
|
+
mock_api.list_timeslips.return_value = [
|
|
266
|
+
{
|
|
267
|
+
"url": "/v2/timeslips/42",
|
|
268
|
+
"hours": "2.5",
|
|
269
|
+
"dated_on": "2026-05-15",
|
|
270
|
+
"created_at": "2026-05-15T10:00:00Z",
|
|
271
|
+
"comment": "test work",
|
|
272
|
+
"project": {"name": "Acme"},
|
|
273
|
+
"task": {"name": "Coding"},
|
|
274
|
+
}
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
|
|
278
|
+
patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
|
|
279
|
+
result = runner.invoke(main, ["recent"])
|
|
280
|
+
assert result.exit_code == 0
|
|
281
|
+
assert "/v2/timeslips/42" in result.output
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from freeagent_cli.cli import _extract_id, format_hours, parse_hours
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestParseHours:
|
|
9
|
+
def test_decimal(self):
|
|
10
|
+
assert parse_hours("1.5") == 1.5
|
|
11
|
+
assert parse_hours("0.25") == 0.25
|
|
12
|
+
|
|
13
|
+
def test_minutes_only(self):
|
|
14
|
+
assert parse_hours("90m") == 1.5
|
|
15
|
+
assert parse_hours("30m") == 0.5
|
|
16
|
+
assert parse_hours("0m") == 0.0
|
|
17
|
+
|
|
18
|
+
def test_hours_only(self):
|
|
19
|
+
assert parse_hours("1h") == 1.0
|
|
20
|
+
assert parse_hours("2h") == 2.0
|
|
21
|
+
|
|
22
|
+
def test_hours_and_minutes(self):
|
|
23
|
+
assert parse_hours("1h30m") == 1.5
|
|
24
|
+
assert parse_hours("2h15m") == 2.25
|
|
25
|
+
assert parse_hours("0h45m") == 0.75
|
|
26
|
+
|
|
27
|
+
def test_colon_format(self):
|
|
28
|
+
assert parse_hours("1:30") == 1.5
|
|
29
|
+
assert parse_hours("0:45") == 0.75
|
|
30
|
+
assert parse_hours("2:00") == 2.0
|
|
31
|
+
|
|
32
|
+
def test_case_insensitive(self):
|
|
33
|
+
assert parse_hours("1H30M") == 1.5
|
|
34
|
+
assert parse_hours("90M") == 1.5
|
|
35
|
+
|
|
36
|
+
def test_whitespace(self):
|
|
37
|
+
assert parse_hours(" 1.5 ") == 1.5
|
|
38
|
+
|
|
39
|
+
def test_invalid(self):
|
|
40
|
+
with pytest.raises(ValueError, match="Cannot parse duration"):
|
|
41
|
+
parse_hours("abc")
|
|
42
|
+
with pytest.raises(ValueError, match="Cannot parse duration"):
|
|
43
|
+
parse_hours("")
|
|
44
|
+
|
|
45
|
+
def test_fractional_hours_with_minutes(self):
|
|
46
|
+
assert parse_hours("1.5h30m") == 2.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestFormatHours:
|
|
50
|
+
def test_minutes_only(self):
|
|
51
|
+
assert format_hours(0.5) == "30m"
|
|
52
|
+
assert format_hours(0.25) == "15m"
|
|
53
|
+
assert format_hours(0) == "0m"
|
|
54
|
+
|
|
55
|
+
def test_hours_only(self):
|
|
56
|
+
assert format_hours(1.0) == "1h"
|
|
57
|
+
assert format_hours(2.0) == "2h"
|
|
58
|
+
|
|
59
|
+
def test_hours_and_minutes(self):
|
|
60
|
+
assert format_hours(1.5) == "1h30m"
|
|
61
|
+
assert format_hours(2.25) == "2h15m"
|
|
62
|
+
assert format_hours(0.75) == "45m"
|
|
63
|
+
|
|
64
|
+
def test_rounding(self):
|
|
65
|
+
assert format_hours(0.505) == "30m"
|
|
66
|
+
assert format_hours(0.495) == "30m"
|
|
67
|
+
|
|
68
|
+
def test_bad_input(self):
|
|
69
|
+
assert format_hours(None) == "None"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TestExtractId:
|
|
73
|
+
def test_numeric_id(self):
|
|
74
|
+
assert _extract_id("123456") == "123456"
|
|
75
|
+
|
|
76
|
+
def test_full_url(self):
|
|
77
|
+
assert _extract_id("https://api.freeagent.com/v2/timeslips/123456") == "123456"
|
|
78
|
+
|
|
79
|
+
def test_sandbox_url(self):
|
|
80
|
+
assert _extract_id("https://api.sandbox.freeagent.com/v2/timeslips/789") == "789"
|
|
81
|
+
|
|
82
|
+
def test_url_with_query(self):
|
|
83
|
+
assert _extract_id("https://api.freeagent.com/v2/timeslips/42?nested=true") == "42?nested=true"
|
|
@@ -47,7 +47,7 @@ wheels = [
|
|
|
47
47
|
|
|
48
48
|
[[package]]
|
|
49
49
|
name = "freeagent-cli"
|
|
50
|
-
version = "0.
|
|
50
|
+
version = "0.3.0"
|
|
51
51
|
source = { editable = "." }
|
|
52
52
|
dependencies = [
|
|
53
53
|
{ name = "click" },
|
|
@@ -55,6 +55,11 @@ dependencies = [
|
|
|
55
55
|
{ name = "platformdirs" },
|
|
56
56
|
]
|
|
57
57
|
|
|
58
|
+
[package.dev-dependencies]
|
|
59
|
+
dev = [
|
|
60
|
+
{ name = "pytest" },
|
|
61
|
+
]
|
|
62
|
+
|
|
58
63
|
[package.metadata]
|
|
59
64
|
requires-dist = [
|
|
60
65
|
{ name = "click", specifier = ">=8.1" },
|
|
@@ -62,6 +67,9 @@ requires-dist = [
|
|
|
62
67
|
{ name = "platformdirs", specifier = ">=4.0" },
|
|
63
68
|
]
|
|
64
69
|
|
|
70
|
+
[package.metadata.requires-dev]
|
|
71
|
+
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
|
|
72
|
+
|
|
65
73
|
[[package]]
|
|
66
74
|
name = "h11"
|
|
67
75
|
version = "0.16.0"
|
|
@@ -108,6 +116,24 @@ wheels = [
|
|
|
108
116
|
{ url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" },
|
|
109
117
|
]
|
|
110
118
|
|
|
119
|
+
[[package]]
|
|
120
|
+
name = "iniconfig"
|
|
121
|
+
version = "2.3.0"
|
|
122
|
+
source = { registry = "https://pypi.org/simple" }
|
|
123
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
124
|
+
wheels = [
|
|
125
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
[[package]]
|
|
129
|
+
name = "packaging"
|
|
130
|
+
version = "26.2"
|
|
131
|
+
source = { registry = "https://pypi.org/simple" }
|
|
132
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
|
133
|
+
wheels = [
|
|
134
|
+
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
|
135
|
+
]
|
|
136
|
+
|
|
111
137
|
[[package]]
|
|
112
138
|
name = "platformdirs"
|
|
113
139
|
version = "4.9.6"
|
|
@@ -117,6 +143,40 @@ wheels = [
|
|
|
117
143
|
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
|
118
144
|
]
|
|
119
145
|
|
|
146
|
+
[[package]]
|
|
147
|
+
name = "pluggy"
|
|
148
|
+
version = "1.6.0"
|
|
149
|
+
source = { registry = "https://pypi.org/simple" }
|
|
150
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
151
|
+
wheels = [
|
|
152
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
[[package]]
|
|
156
|
+
name = "pygments"
|
|
157
|
+
version = "2.20.0"
|
|
158
|
+
source = { registry = "https://pypi.org/simple" }
|
|
159
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
|
160
|
+
wheels = [
|
|
161
|
+
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
[[package]]
|
|
165
|
+
name = "pytest"
|
|
166
|
+
version = "9.0.3"
|
|
167
|
+
source = { registry = "https://pypi.org/simple" }
|
|
168
|
+
dependencies = [
|
|
169
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
170
|
+
{ name = "iniconfig" },
|
|
171
|
+
{ name = "packaging" },
|
|
172
|
+
{ name = "pluggy" },
|
|
173
|
+
{ name = "pygments" },
|
|
174
|
+
]
|
|
175
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
|
176
|
+
wheels = [
|
|
177
|
+
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
|
178
|
+
]
|
|
179
|
+
|
|
120
180
|
[[package]]
|
|
121
181
|
name = "typing-extensions"
|
|
122
182
|
version = "4.15.0"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|