worklogs 0.2.0__tar.gz → 0.2.1__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.
- {worklogs-0.2.0 → worklogs-0.2.1}/PKG-INFO +47 -5
- {worklogs-0.2.0 → worklogs-0.2.1}/README.md +46 -4
- {worklogs-0.2.0 → worklogs-0.2.1}/docs/api.md +6 -0
- {worklogs-0.2.0 → worklogs-0.2.1}/pyproject.toml +1 -1
- {worklogs-0.2.0 → worklogs-0.2.1}/src/worklogs/__init__.py +1 -1
- {worklogs-0.2.0 → worklogs-0.2.1}/src/worklogs/cli.py +185 -2
- {worklogs-0.2.0 → worklogs-0.2.1}/tests/test_package.py +193 -7
- {worklogs-0.2.0 → worklogs-0.2.1}/uv.lock +1 -1
- {worklogs-0.2.0 → worklogs-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {worklogs-0.2.0 → worklogs-0.2.1}/.github/workflows/ci.yml +0 -0
- {worklogs-0.2.0 → worklogs-0.2.1}/.github/workflows/release.yml +0 -0
- {worklogs-0.2.0 → worklogs-0.2.1}/.gitignore +0 -0
- {worklogs-0.2.0 → worklogs-0.2.1}/.pre-commit-config.yaml +0 -0
- {worklogs-0.2.0 → worklogs-0.2.1}/.python-version +0 -0
- {worklogs-0.2.0 → worklogs-0.2.1}/AGENTS.md +0 -0
- {worklogs-0.2.0 → worklogs-0.2.1}/src/worklogs/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: worklogs
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Local markdown worklog helpers for developer workflows.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alik-git/worklogs
|
|
6
6
|
Project-URL: Repository, https://github.com/alik-git/worklogs
|
|
@@ -99,6 +99,41 @@ Print created paths for scripts:
|
|
|
99
99
|
worklogs new note--personal-site--theme-ideas --scope personal --print-path
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
Create a dated project workset directory:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
worklogs workset new backend-api-refactor --worksets-root ~/worksets
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Create a dated workset under organizer folders:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
worklogs workset new release-tools/python-packaging/worklogs-0.2.1 \
|
|
112
|
+
--worksets-root ~/worksets
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Worksets are created under:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
<worksets-root>/YYYY/MM/DD/<workset-path>/
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Preview a workset path without creating it:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
worklogs workset new release-tools/python-packaging/worklogs-0.2.1 \
|
|
125
|
+
--worksets-root ~/worksets \
|
|
126
|
+
--dry-run
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Print only the created path for scripts:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
worklogs workset new backend-api-refactor \
|
|
133
|
+
--worksets-root ~/worksets \
|
|
134
|
+
--print-path
|
|
135
|
+
```
|
|
136
|
+
|
|
102
137
|
Supported kinds:
|
|
103
138
|
|
|
104
139
|
```text
|
|
@@ -122,13 +157,14 @@ Example:
|
|
|
122
157
|
root = "~/worklog"
|
|
123
158
|
default_scope = "work"
|
|
124
159
|
timezone = "America/Toronto"
|
|
160
|
+
worksets_root = "~/worksets"
|
|
125
161
|
```
|
|
126
162
|
|
|
127
163
|
Resolution order:
|
|
128
164
|
|
|
129
165
|
1. CLI flags
|
|
130
166
|
2. environment variables: `WORKLOG_ROOT`, `WORKLOG_SCOPE`,
|
|
131
|
-
`WORKLOG_TIMEZONE`
|
|
167
|
+
`WORKLOG_TIMEZONE`, `WORKLOG_WORKSETS_ROOT`
|
|
132
168
|
3. config file
|
|
133
169
|
4. package defaults
|
|
134
170
|
|
|
@@ -138,6 +174,12 @@ With `default_scope` configured, the fast path can omit `--scope`:
|
|
|
138
174
|
worklogs new plan--backend-api--improve-deploy-notes
|
|
139
175
|
```
|
|
140
176
|
|
|
177
|
+
With `worksets_root` configured, the workset path can omit `--worksets-root`:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
worklogs workset new backend-api-refactor
|
|
181
|
+
```
|
|
182
|
+
|
|
141
183
|
For shorter personal shell usage, add a local alias:
|
|
142
184
|
|
|
143
185
|
```bash
|
|
@@ -196,9 +238,9 @@ Environment name: pypi
|
|
|
196
238
|
Create a GitHub Release for the version in [`pyproject.toml`](pyproject.toml):
|
|
197
239
|
|
|
198
240
|
```bash
|
|
199
|
-
gh release create v0.2.
|
|
200
|
-
--title "v0.2.
|
|
201
|
-
--notes "Add
|
|
241
|
+
gh release create v0.2.1 \
|
|
242
|
+
--title "v0.2.1" \
|
|
243
|
+
--notes "Add dated workset directory creation."
|
|
202
244
|
```
|
|
203
245
|
|
|
204
246
|
Publishing is release-driven on purpose: normal pushes and pull requests build
|
|
@@ -72,6 +72,41 @@ Print created paths for scripts:
|
|
|
72
72
|
worklogs new note--personal-site--theme-ideas --scope personal --print-path
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
Create a dated project workset directory:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
worklogs workset new backend-api-refactor --worksets-root ~/worksets
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Create a dated workset under organizer folders:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
worklogs workset new release-tools/python-packaging/worklogs-0.2.1 \
|
|
85
|
+
--worksets-root ~/worksets
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Worksets are created under:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
<worksets-root>/YYYY/MM/DD/<workset-path>/
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Preview a workset path without creating it:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
worklogs workset new release-tools/python-packaging/worklogs-0.2.1 \
|
|
98
|
+
--worksets-root ~/worksets \
|
|
99
|
+
--dry-run
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Print only the created path for scripts:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
worklogs workset new backend-api-refactor \
|
|
106
|
+
--worksets-root ~/worksets \
|
|
107
|
+
--print-path
|
|
108
|
+
```
|
|
109
|
+
|
|
75
110
|
Supported kinds:
|
|
76
111
|
|
|
77
112
|
```text
|
|
@@ -95,13 +130,14 @@ Example:
|
|
|
95
130
|
root = "~/worklog"
|
|
96
131
|
default_scope = "work"
|
|
97
132
|
timezone = "America/Toronto"
|
|
133
|
+
worksets_root = "~/worksets"
|
|
98
134
|
```
|
|
99
135
|
|
|
100
136
|
Resolution order:
|
|
101
137
|
|
|
102
138
|
1. CLI flags
|
|
103
139
|
2. environment variables: `WORKLOG_ROOT`, `WORKLOG_SCOPE`,
|
|
104
|
-
`WORKLOG_TIMEZONE`
|
|
140
|
+
`WORKLOG_TIMEZONE`, `WORKLOG_WORKSETS_ROOT`
|
|
105
141
|
3. config file
|
|
106
142
|
4. package defaults
|
|
107
143
|
|
|
@@ -111,6 +147,12 @@ With `default_scope` configured, the fast path can omit `--scope`:
|
|
|
111
147
|
worklogs new plan--backend-api--improve-deploy-notes
|
|
112
148
|
```
|
|
113
149
|
|
|
150
|
+
With `worksets_root` configured, the workset path can omit `--worksets-root`:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
worklogs workset new backend-api-refactor
|
|
154
|
+
```
|
|
155
|
+
|
|
114
156
|
For shorter personal shell usage, add a local alias:
|
|
115
157
|
|
|
116
158
|
```bash
|
|
@@ -169,9 +211,9 @@ Environment name: pypi
|
|
|
169
211
|
Create a GitHub Release for the version in [`pyproject.toml`](pyproject.toml):
|
|
170
212
|
|
|
171
213
|
```bash
|
|
172
|
-
gh release create v0.2.
|
|
173
|
-
--title "v0.2.
|
|
174
|
-
--notes "Add
|
|
214
|
+
gh release create v0.2.1 \
|
|
215
|
+
--title "v0.2.1" \
|
|
216
|
+
--notes "Add dated workset directory creation."
|
|
175
217
|
```
|
|
176
218
|
|
|
177
219
|
Publishing is release-driven on purpose: normal pushes and pull requests build
|
|
@@ -9,5 +9,11 @@ Use `worklogs new` to create dated markdown worklog files:
|
|
|
9
9
|
worklogs new plan--backend-api--improve-deploy-notes --scope work
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
Use `worklogs workset new` to create dated project workset directories:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
worklogs workset new backend-api-refactor --worksets-root ~/worksets
|
|
16
|
+
```
|
|
17
|
+
|
|
12
18
|
Public Python helpers will be documented here if the package grows a stable API
|
|
13
19
|
outside the CLI.
|
|
@@ -8,7 +8,7 @@ import re
|
|
|
8
8
|
import sys
|
|
9
9
|
import tomllib
|
|
10
10
|
from dataclasses import dataclass
|
|
11
|
-
from datetime import UTC, datetime, tzinfo
|
|
11
|
+
from datetime import UTC, date, datetime, tzinfo
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import TYPE_CHECKING
|
|
14
14
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
@@ -55,6 +55,14 @@ class WorklogConfig:
|
|
|
55
55
|
timezone: tzinfo
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class WorksetConfig:
|
|
60
|
+
"""Resolved user defaults for workset directory creation."""
|
|
61
|
+
|
|
62
|
+
root: Path
|
|
63
|
+
timezone: tzinfo
|
|
64
|
+
|
|
65
|
+
|
|
58
66
|
@dataclass(frozen=True)
|
|
59
67
|
class WorklogEntry:
|
|
60
68
|
"""A worklog file that should be created."""
|
|
@@ -122,6 +130,51 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
122
130
|
action="store_true",
|
|
123
131
|
help="print created path or paths",
|
|
124
132
|
)
|
|
133
|
+
|
|
134
|
+
workset_parser = subparsers.add_parser(
|
|
135
|
+
"workset",
|
|
136
|
+
help="create and maintain project workset directories",
|
|
137
|
+
description="Create and maintain project workset directories.",
|
|
138
|
+
)
|
|
139
|
+
workset_subparsers = workset_parser.add_subparsers(
|
|
140
|
+
dest="workset_command",
|
|
141
|
+
required=True,
|
|
142
|
+
)
|
|
143
|
+
workset_new_parser = workset_subparsers.add_parser(
|
|
144
|
+
"new",
|
|
145
|
+
help="create a dated project workset directory",
|
|
146
|
+
description="Create a dated project workset directory.",
|
|
147
|
+
)
|
|
148
|
+
workset_new_parser.add_argument(
|
|
149
|
+
"path",
|
|
150
|
+
metavar="PATH",
|
|
151
|
+
help="relative workset path under YYYY/MM/DD, for example app/refactor",
|
|
152
|
+
)
|
|
153
|
+
workset_new_parser.add_argument(
|
|
154
|
+
"--worksets-root",
|
|
155
|
+
metavar="PATH",
|
|
156
|
+
help="root directory for dated workset folders",
|
|
157
|
+
)
|
|
158
|
+
workset_new_parser.add_argument(
|
|
159
|
+
"--timezone",
|
|
160
|
+
metavar="ZONE",
|
|
161
|
+
help="IANA timezone name used when choosing today's date",
|
|
162
|
+
)
|
|
163
|
+
workset_new_parser.add_argument(
|
|
164
|
+
"--date",
|
|
165
|
+
metavar="YYYY-MM-DD",
|
|
166
|
+
help="explicit date for reproducible workset creation",
|
|
167
|
+
)
|
|
168
|
+
workset_new_parser.add_argument(
|
|
169
|
+
"--dry-run",
|
|
170
|
+
action="store_true",
|
|
171
|
+
help="print the intended path without creating it",
|
|
172
|
+
)
|
|
173
|
+
workset_new_parser.add_argument(
|
|
174
|
+
"--print-path",
|
|
175
|
+
action="store_true",
|
|
176
|
+
help="print only the created or intended path",
|
|
177
|
+
)
|
|
125
178
|
return parser
|
|
126
179
|
|
|
127
180
|
|
|
@@ -137,6 +190,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
137
190
|
try:
|
|
138
191
|
if args.command == "new":
|
|
139
192
|
return _run_new(args)
|
|
193
|
+
if args.command == "workset" and args.workset_command == "new":
|
|
194
|
+
return _run_workset_new(args)
|
|
140
195
|
except WorklogsError as error:
|
|
141
196
|
print(f"worklogs: error: {error}", file=sys.stderr)
|
|
142
197
|
return 2
|
|
@@ -176,6 +231,34 @@ def _run_new(args: argparse.Namespace) -> int:
|
|
|
176
231
|
return 0
|
|
177
232
|
|
|
178
233
|
|
|
234
|
+
def _run_workset_new(args: argparse.Namespace) -> int:
|
|
235
|
+
config = _resolve_workset_config(args, os.environ)
|
|
236
|
+
workset_date = _resolve_workset_date(args.date, config.timezone)
|
|
237
|
+
path_parts = _parse_workset_path(args.path)
|
|
238
|
+
workset_path = _build_workset_path(
|
|
239
|
+
config=config,
|
|
240
|
+
workset_date=workset_date,
|
|
241
|
+
path_parts=path_parts,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if args.dry_run:
|
|
245
|
+
if args.print_path:
|
|
246
|
+
print(workset_path)
|
|
247
|
+
else:
|
|
248
|
+
print("Would create workset:")
|
|
249
|
+
print(workset_path)
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
created = _create_workset_directory(workset_path)
|
|
253
|
+
if args.print_path:
|
|
254
|
+
print(workset_path)
|
|
255
|
+
elif created:
|
|
256
|
+
print(f"Created workset directory: {workset_path}")
|
|
257
|
+
else:
|
|
258
|
+
print(f"Workset directory already exists and is empty: {workset_path}")
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
179
262
|
def _resolve_identity(args: argparse.Namespace) -> WorklogIdentity:
|
|
180
263
|
explicit_fields = (args.kind, args.project, args.slug)
|
|
181
264
|
has_explicit_fields = any(value is not None for value in explicit_fields)
|
|
@@ -267,6 +350,35 @@ def _resolve_config(
|
|
|
267
350
|
)
|
|
268
351
|
|
|
269
352
|
|
|
353
|
+
def _resolve_workset_config(
|
|
354
|
+
args: argparse.Namespace,
|
|
355
|
+
environment: Mapping[str, str],
|
|
356
|
+
) -> WorksetConfig:
|
|
357
|
+
file_config = _load_config()
|
|
358
|
+
|
|
359
|
+
root_value = _first_string(
|
|
360
|
+
args.worksets_root,
|
|
361
|
+
environment.get("WORKLOG_WORKSETS_ROOT"),
|
|
362
|
+
file_config.get("worksets_root"),
|
|
363
|
+
)
|
|
364
|
+
if root_value is None:
|
|
365
|
+
msg = (
|
|
366
|
+
"worksets root is required; set --worksets-root, "
|
|
367
|
+
"WORKLOG_WORKSETS_ROOT, or worksets_root in config"
|
|
368
|
+
)
|
|
369
|
+
raise WorklogsError(msg)
|
|
370
|
+
|
|
371
|
+
timezone_value = _first_string(
|
|
372
|
+
args.timezone,
|
|
373
|
+
environment.get("WORKLOG_TIMEZONE"),
|
|
374
|
+
file_config.get("timezone"),
|
|
375
|
+
)
|
|
376
|
+
return WorksetConfig(
|
|
377
|
+
root=Path(root_value).expanduser(),
|
|
378
|
+
timezone=_resolve_timezone(timezone_value),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
270
382
|
def _load_config() -> dict[str, str]:
|
|
271
383
|
config_path = CONFIG_PATH.expanduser()
|
|
272
384
|
if not config_path.exists():
|
|
@@ -283,7 +395,7 @@ def _load_config() -> dict[str, str]:
|
|
|
283
395
|
raise WorklogsError(msg) from error
|
|
284
396
|
|
|
285
397
|
config: dict[str, str] = {}
|
|
286
|
-
for key in ("root", "default_scope", "timezone"):
|
|
398
|
+
for key in ("root", "default_scope", "timezone", "worksets_root"):
|
|
287
399
|
value = raw_config.get(key)
|
|
288
400
|
if value is None:
|
|
289
401
|
continue
|
|
@@ -311,6 +423,77 @@ def _resolve_timezone(timezone_name: str | None) -> tzinfo:
|
|
|
311
423
|
raise WorklogsError(msg) from error
|
|
312
424
|
|
|
313
425
|
|
|
426
|
+
def _resolve_workset_date(value: str | None, timezone: tzinfo) -> date:
|
|
427
|
+
if value is None:
|
|
428
|
+
return datetime.now(UTC).astimezone(timezone).date()
|
|
429
|
+
try:
|
|
430
|
+
return date.fromisoformat(value)
|
|
431
|
+
except ValueError as error:
|
|
432
|
+
msg = f"invalid date {value!r}; expected YYYY-MM-DD"
|
|
433
|
+
raise WorklogsError(msg) from error
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _parse_workset_path(value: str) -> tuple[str, ...]:
|
|
437
|
+
if not value:
|
|
438
|
+
msg = "workset path cannot be empty"
|
|
439
|
+
raise WorklogsError(msg)
|
|
440
|
+
if Path(value).is_absolute():
|
|
441
|
+
msg = "workset path must be relative"
|
|
442
|
+
raise WorklogsError(msg)
|
|
443
|
+
|
|
444
|
+
parts = tuple(value.split("/"))
|
|
445
|
+
for part in parts:
|
|
446
|
+
if part in {"", ".", ".."}:
|
|
447
|
+
msg = "workset path cannot contain empty, '.', or '..' components"
|
|
448
|
+
raise WorklogsError(msg)
|
|
449
|
+
if not SLUG_PATTERN.fullmatch(part):
|
|
450
|
+
msg = (
|
|
451
|
+
"workset path components must start with a lowercase letter or "
|
|
452
|
+
"digit and contain only lowercase letters, digits, dots, "
|
|
453
|
+
"underscores, or hyphens"
|
|
454
|
+
)
|
|
455
|
+
raise WorklogsError(msg)
|
|
456
|
+
return parts
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _build_workset_path(
|
|
460
|
+
*,
|
|
461
|
+
config: WorksetConfig,
|
|
462
|
+
workset_date: date,
|
|
463
|
+
path_parts: Sequence[str],
|
|
464
|
+
) -> Path:
|
|
465
|
+
return (
|
|
466
|
+
config.root
|
|
467
|
+
/ f"{workset_date:%Y}"
|
|
468
|
+
/ f"{workset_date:%m}"
|
|
469
|
+
/ f"{workset_date:%d}"
|
|
470
|
+
/ Path(*path_parts)
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _create_workset_directory(path: Path) -> bool:
|
|
475
|
+
if path.exists():
|
|
476
|
+
if not path.is_dir():
|
|
477
|
+
msg = f"workset path exists but is not a directory: {path}"
|
|
478
|
+
raise WorklogsError(msg)
|
|
479
|
+
try:
|
|
480
|
+
has_contents = next(path.iterdir(), None) is not None
|
|
481
|
+
except OSError as error:
|
|
482
|
+
msg = f"could not inspect existing workset directory {path}: {error}"
|
|
483
|
+
raise WorklogsError(msg) from error
|
|
484
|
+
if has_contents:
|
|
485
|
+
msg = f"refusing to use existing non-empty workset directory: {path}"
|
|
486
|
+
raise WorklogsError(msg)
|
|
487
|
+
return False
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
path.mkdir(parents=True)
|
|
491
|
+
except OSError as error:
|
|
492
|
+
msg = f"could not create workset directory {path}: {error}"
|
|
493
|
+
raise WorklogsError(msg) from error
|
|
494
|
+
return True
|
|
495
|
+
|
|
496
|
+
|
|
314
497
|
def _build_entries(
|
|
315
498
|
*,
|
|
316
499
|
identity: WorklogIdentity,
|
|
@@ -14,8 +14,11 @@ from worklogs.cli import (
|
|
|
14
14
|
WorklogEntry,
|
|
15
15
|
WorklogIdentity,
|
|
16
16
|
WorklogsError,
|
|
17
|
+
WorksetConfig,
|
|
17
18
|
_build_entries,
|
|
19
|
+
_build_workset_path,
|
|
18
20
|
_parse_identity_token,
|
|
21
|
+
_parse_workset_path,
|
|
19
22
|
_write_entries,
|
|
20
23
|
main,
|
|
21
24
|
)
|
|
@@ -26,7 +29,7 @@ if TYPE_CHECKING:
|
|
|
26
29
|
|
|
27
30
|
def test_version_is_exposed() -> None:
|
|
28
31
|
"""Verify the package exposes its current version."""
|
|
29
|
-
assert worklogs.__version__ == "0.2.
|
|
32
|
+
assert worklogs.__version__ == "0.2.1"
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
def test_cli_accepts_no_arguments() -> None:
|
|
@@ -210,6 +213,185 @@ def test_dry_run_writes_nothing(
|
|
|
210
213
|
assert not root.exists()
|
|
211
214
|
|
|
212
215
|
|
|
216
|
+
def test_workset_new_uses_config_root_and_explicit_date(
|
|
217
|
+
tmp_path: Path,
|
|
218
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
219
|
+
capsys: pytest.CaptureFixture[str],
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Verify workset creation uses the configured root and dated layout."""
|
|
222
|
+
worksets_root = tmp_path / "Projects" / "worksets"
|
|
223
|
+
_write_config(
|
|
224
|
+
tmp_path,
|
|
225
|
+
monkeypatch,
|
|
226
|
+
root=tmp_path / "worklog",
|
|
227
|
+
default_scope="work",
|
|
228
|
+
worksets_root=worksets_root,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
assert (
|
|
232
|
+
main(
|
|
233
|
+
[
|
|
234
|
+
"workset",
|
|
235
|
+
"new",
|
|
236
|
+
"release-tools/worklogs-0.2.1",
|
|
237
|
+
"--date",
|
|
238
|
+
"2026-05-29",
|
|
239
|
+
]
|
|
240
|
+
)
|
|
241
|
+
== 0
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
created_path = (
|
|
245
|
+
worksets_root / "2026" / "05" / "29" / "release-tools" / "worklogs-0.2.1"
|
|
246
|
+
)
|
|
247
|
+
assert created_path.is_dir()
|
|
248
|
+
assert f"Created workset directory: {created_path}" in capsys.readouterr().out
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_workset_new_dry_run_writes_nothing(
|
|
252
|
+
tmp_path: Path,
|
|
253
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
254
|
+
capsys: pytest.CaptureFixture[str],
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Verify workset dry-run prints the intended path without writing."""
|
|
257
|
+
_isolate_home(tmp_path, monkeypatch)
|
|
258
|
+
worksets_root = tmp_path / "worksets"
|
|
259
|
+
|
|
260
|
+
assert (
|
|
261
|
+
main(
|
|
262
|
+
[
|
|
263
|
+
"workset",
|
|
264
|
+
"new",
|
|
265
|
+
"release-tools/python-packaging/worklogs-0.2.1",
|
|
266
|
+
"--worksets-root",
|
|
267
|
+
str(worksets_root),
|
|
268
|
+
"--date",
|
|
269
|
+
"2026-05-29",
|
|
270
|
+
"--dry-run",
|
|
271
|
+
]
|
|
272
|
+
)
|
|
273
|
+
== 0
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
output = capsys.readouterr().out
|
|
277
|
+
assert "Would create workset:" in output
|
|
278
|
+
assert (
|
|
279
|
+
str(
|
|
280
|
+
worksets_root
|
|
281
|
+
/ "2026"
|
|
282
|
+
/ "05"
|
|
283
|
+
/ "29"
|
|
284
|
+
/ "release-tools"
|
|
285
|
+
/ "python-packaging"
|
|
286
|
+
/ "worklogs-0.2.1"
|
|
287
|
+
)
|
|
288
|
+
in output
|
|
289
|
+
)
|
|
290
|
+
assert not worksets_root.exists()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_workset_new_print_path_is_script_friendly(
|
|
294
|
+
tmp_path: Path,
|
|
295
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
296
|
+
capsys: pytest.CaptureFixture[str],
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Verify --print-path prints only the created workset path."""
|
|
299
|
+
_isolate_home(tmp_path, monkeypatch)
|
|
300
|
+
worksets_root = tmp_path / "worksets"
|
|
301
|
+
|
|
302
|
+
assert (
|
|
303
|
+
main(
|
|
304
|
+
[
|
|
305
|
+
"workset",
|
|
306
|
+
"new",
|
|
307
|
+
"worklogs-0.2.1",
|
|
308
|
+
"--worksets-root",
|
|
309
|
+
str(worksets_root),
|
|
310
|
+
"--date",
|
|
311
|
+
"2026-05-29",
|
|
312
|
+
"--print-path",
|
|
313
|
+
]
|
|
314
|
+
)
|
|
315
|
+
== 0
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
expected_path = worksets_root / "2026" / "05" / "29" / "worklogs-0.2.1"
|
|
319
|
+
assert capsys.readouterr().out == f"{expected_path}\n"
|
|
320
|
+
assert expected_path.is_dir()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_workset_new_refuses_existing_non_empty_directory(
|
|
324
|
+
tmp_path: Path,
|
|
325
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
326
|
+
capsys: pytest.CaptureFixture[str],
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Verify workset creation refuses to reuse non-empty directories."""
|
|
329
|
+
_isolate_home(tmp_path, monkeypatch)
|
|
330
|
+
worksets_root = tmp_path / "worksets"
|
|
331
|
+
existing_path = worksets_root / "2026" / "05" / "29" / "worklogs-0.2.1"
|
|
332
|
+
existing_path.mkdir(parents=True)
|
|
333
|
+
(existing_path / "README.md").write_text("existing\n", encoding="utf-8")
|
|
334
|
+
|
|
335
|
+
assert (
|
|
336
|
+
main(
|
|
337
|
+
[
|
|
338
|
+
"workset",
|
|
339
|
+
"new",
|
|
340
|
+
"worklogs-0.2.1",
|
|
341
|
+
"--worksets-root",
|
|
342
|
+
str(worksets_root),
|
|
343
|
+
"--date",
|
|
344
|
+
"2026-05-29",
|
|
345
|
+
]
|
|
346
|
+
)
|
|
347
|
+
== 2
|
|
348
|
+
)
|
|
349
|
+
assert "existing non-empty workset directory" in capsys.readouterr().err
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_workset_new_requires_configured_root(
|
|
353
|
+
tmp_path: Path,
|
|
354
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
355
|
+
capsys: pytest.CaptureFixture[str],
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Verify workset creation does not guess a local machine default."""
|
|
358
|
+
_isolate_home(tmp_path, monkeypatch)
|
|
359
|
+
|
|
360
|
+
assert main(["workset", "new", "worklogs-0.2.1", "--date", "2026-05-29"]) == 2
|
|
361
|
+
assert "worksets root is required" in capsys.readouterr().err
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_parse_workset_path_rejects_unsafe_components() -> None:
|
|
365
|
+
"""Verify workset path parsing rejects absolute or escaping paths."""
|
|
366
|
+
for value in (
|
|
367
|
+
"/absolute/workset",
|
|
368
|
+
"release-tools/../workset",
|
|
369
|
+
"release-tools//workset",
|
|
370
|
+
):
|
|
371
|
+
with pytest.raises(WorklogsError):
|
|
372
|
+
_parse_workset_path(value)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_build_workset_path_renders_date_folders(tmp_path: Path) -> None:
|
|
376
|
+
"""Verify workset paths include YYYY/MM/DD before organizer folders."""
|
|
377
|
+
workset_path = _build_workset_path(
|
|
378
|
+
config=WorksetConfig(root=tmp_path / "worksets", timezone=ZoneInfo("UTC")),
|
|
379
|
+
workset_date=datetime(2026, 5, 29, tzinfo=ZoneInfo("UTC")).date(),
|
|
380
|
+
path_parts=("release-tools", "worklogs-0.2.1"),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
assert (
|
|
384
|
+
workset_path
|
|
385
|
+
== tmp_path
|
|
386
|
+
/ "worksets"
|
|
387
|
+
/ "2026"
|
|
388
|
+
/ "05"
|
|
389
|
+
/ "29"
|
|
390
|
+
/ "release-tools"
|
|
391
|
+
/ "worklogs-0.2.1"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
213
395
|
def test_scope_is_required_without_config_or_flag(
|
|
214
396
|
tmp_path: Path,
|
|
215
397
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -249,16 +431,19 @@ def _write_config(
|
|
|
249
431
|
*,
|
|
250
432
|
root: Path,
|
|
251
433
|
default_scope: str,
|
|
434
|
+
worksets_root: Path | None = None,
|
|
252
435
|
) -> None:
|
|
253
436
|
home = _isolate_home(tmp_path, monkeypatch)
|
|
254
437
|
config_path = home / ".config" / "worklogs" / "config.toml"
|
|
255
438
|
config_path.parent.mkdir(parents=True)
|
|
256
|
-
|
|
257
|
-
f'root = "{root}"
|
|
258
|
-
f'default_scope = "{default_scope}"
|
|
259
|
-
'timezone = "America/Toronto"
|
|
260
|
-
|
|
261
|
-
|
|
439
|
+
lines = [
|
|
440
|
+
f'root = "{root}"',
|
|
441
|
+
f'default_scope = "{default_scope}"',
|
|
442
|
+
'timezone = "America/Toronto"',
|
|
443
|
+
]
|
|
444
|
+
if worksets_root is not None:
|
|
445
|
+
lines.append(f'worksets_root = "{worksets_root}"')
|
|
446
|
+
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
262
447
|
|
|
263
448
|
|
|
264
449
|
def _isolate_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
@@ -268,4 +453,5 @@ def _isolate_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
|
268
453
|
monkeypatch.delenv("WORKLOG_ROOT", raising=False)
|
|
269
454
|
monkeypatch.delenv("WORKLOG_SCOPE", raising=False)
|
|
270
455
|
monkeypatch.delenv("WORKLOG_TIMEZONE", raising=False)
|
|
456
|
+
monkeypatch.delenv("WORKLOG_WORKSETS_ROOT", raising=False)
|
|
271
457
|
return home
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|