Skip to content

Commit 311fd6d

Browse files
authored
Merge pull request #10 from i-VRESSE/time-checks
Add recorded_argparse decorator
2 parents b7edd19 + 1e57ff2 commit 311fd6d

File tree

11 files changed

+300
-56
lines changed

11 files changed

+300
-56
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ uvx ruff format # Format code
77
uvx ruff check # Check for issues
88
uv run pyright # Type check code
99
uv build # Build package
10-
uv run --group docs sphinx-build docs docs/_build # Build documentation
10+
uv run --group docs sphinx-build docs docs/_build # Build documentation in docs/_build
1111
```
1212

1313
## Style

README.md

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,44 +19,39 @@ pip install rocrate-action-recorder
1919

2020
## Usage
2121

22-
Shown is an example of recording a CLI command (`example-cli input.txt output.txt`) implemented with `argparse`.
22+
Example of recording a CLI command (`example-cli input.txt output.txt`) implemented with `argparse`.
2323

2424
```python
2525
import argparse
26-
from datetime import datetime
2726
from pathlib import Path
28-
from rocrate_action_recorder import record_with_argparse, IOArgumentNames
27+
import sys
28+
29+
from rocrate_action_recorder import recorded_argparse
2930

30-
# Create an argparse parser
3131
parser = argparse.ArgumentParser(prog="example-cli", description="Example CLI")
32-
parser.add_argument("--version", action="version", version="1.2.3")
32+
parser.add_argument("--version", action="version", version="%(prog)s 1.2.3")
33+
parser.add_argument("--no-record", action="store_false", help="Disable RO-Crate recording.")
3334
parser.add_argument("input", type=Path, help="Input file")
3435
parser.add_argument("output", type=Path, help="Output file")
3536

37+
@recorded_argparse(
38+
parser=parser,
39+
input_files=["input"],
40+
output_files=["output"],
41+
dataset_license="CC-BY-4.0",
42+
enabled_argument="no_record",
43+
)
44+
def handler(args: argparse.Namespace):
45+
# For demonstration, just upper case input to output, replace with real logic
46+
args.output.write_text(args.input.read_text().upper())
47+
3648
# Prepare input
3749
Path("input.txt").write_text("hello")
50+
# Simulate command-line arguments
51+
sys.argv.extend(["input.txt", "output.txt"])
3852

39-
# Parse arguments
40-
args = ['input.txt', 'output.txt']
41-
ns = parser.parse_args(args)
42-
43-
# Do handling of the CLI command here
44-
start_time = datetime.now()
45-
# For demonstration, just upper case input to output
46-
Path(ns.output).write_text(ns.input.read_text().upper())
47-
48-
record_with_argparse(
49-
parser,
50-
ns,
51-
# Tell recorder which arguments are for input and output files
52-
IOArgumentNames(input_files=["input"], output_files=["output"]),
53-
start_time,
54-
dataset_license="CC-BY-4.0",
55-
# argv argument is optional, in real usage you can omit it
56-
argv=['example-cli'] + args,
57-
# current_user argument is optional, in real usage you can omit it
58-
current_user="someuser"
59-
)
53+
args = parser.parse_args()
54+
handler(args)
6055
```
6156

6257
<details>
@@ -161,9 +156,9 @@ Will generate a `ro-crate-metadata.json` file in the current working directory d
161156
}
162157
```
163158

164-
</details>
165-
159+
For you the startTime, endTime, and Person name will differ.
166160

161+
</details>
167162

168163
<details>
169164
<summary>

docs/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
:end-before: <!-- SPHINX-END -->
1212
```
1313

14-
The main functions are:
15-
- [record_from_argparse](#rocrate_action_recorder.adapters.argparse.record_with_argparse) function.
14+
The main entry points are:
15+
- [recorded_argparse](#rocrate_action_recorder.adapters.argparse.recorded_argparse) decorator.
1616
- [record](#rocrate_action_recorder.core.record) function.
1717

1818
## Example

example/myscript.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
#!/usr/bin/env python3
22
import argparse
3-
from datetime import datetime
3+
from functools import lru_cache
44
from pathlib import Path
5-
from rocrate_action_recorder import record_with_argparse, IOs
5+
from rocrate_action_recorder import recorded_argparse
66

77

8+
@lru_cache(maxsize=1) # Cache the parser instance to avoid re-creating it for each handler
89
def make_parser():
910
parser = argparse.ArgumentParser(prog="myscript", description="Example CLI")
1011
parser.add_argument("--version", action="version", version="%(prog)s 1.0.0")
@@ -13,25 +14,21 @@ def make_parser():
1314
return parser
1415

1516

16-
def handler(args, parser):
17-
start_time = datetime.now()
17+
@recorded_argparse(
18+
parser=make_parser(),
19+
input_files=["input"],
20+
output_files=["output"],
21+
dataset_license="CC-BY-4.0",
22+
)
23+
def handler(args: argparse.Namespace) -> int:
1824
# do something simple
19-
args.output.write_text(args.input.read_text().upper())
20-
21-
ios = IOs(input_files=["input"], output_files=["output"])
22-
record_with_argparse(
23-
parser=parser,
24-
ns=args,
25-
ios=ios,
26-
start_time=start_time,
27-
dataset_license="CC-BY-4.0",
28-
)
25+
return args.output.write_text(args.input.read_text().upper())
2926

3027

3128
def main():
3229
parser = make_parser()
3330
args = parser.parse_args()
34-
handler(args, parser)
31+
handler(args)
3532

3633

3734
if __name__ == "__main__":
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.0"
1+
__version__ = "0.2.0"

src/rocrate_action_recorder/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
"""RO-Crate action recorder for CLI invocations."""
22

3-
from rocrate_action_recorder.adapters.argparse import record_with_argparse, IOArgumentNames
3+
from rocrate_action_recorder.adapters.argparse import (
4+
record_argparse,
5+
recorded_argparse,
6+
IOArgumentNames,
7+
)
48
from rocrate_action_recorder.core import (
59
IOArgumentPath,
610
IOArgumentPaths,
@@ -10,7 +14,8 @@
1014
)
1115

1216
__all__ = [
13-
"record_with_argparse",
17+
"record_argparse",
18+
"recorded_argparse",
1419
"record",
1520
"playback",
1621
"Program",

src/rocrate_action_recorder/adapters/argparse.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Adapter for argparse CLI framework."""
22

33
from argparse import _VersionAction, ArgumentParser, Namespace
4+
from collections.abc import Callable
45
from dataclasses import dataclass, field
5-
from datetime import datetime
6+
from datetime import UTC, datetime
7+
from functools import wraps
68
from pathlib import Path
79
from typing import Any
810
import logging
@@ -235,7 +237,7 @@ def collect_record_info_from_argparse(
235237
return program, ioargs
236238

237239

238-
def record_with_argparse(
240+
def record_argparse(
239241
parser: ArgumentParser,
240242
ns: Namespace,
241243
ios: IOArgumentNames,
@@ -288,3 +290,70 @@ def record_with_argparse(
288290
current_user=current_user,
289291
dataset_license=dataset_license,
290292
)
293+
294+
295+
def recorded_argparse[T](
296+
parser: ArgumentParser,
297+
input_dirs: list[str] | None = None,
298+
output_dirs: list[str] | None = None,
299+
input_files: list[str] | None = None,
300+
output_files: list[str] | None = None,
301+
dataset_license: str | None = None,
302+
enabled_argument: str | None = None,
303+
) -> Callable[[Callable[[Namespace], T]], Callable[[Namespace], T]]:
304+
"""Decorator to record a CLI invocation in an RO-Crate using argparse.
305+
306+
Args:
307+
parser: The argument parser used to parse the command-line arguments.
308+
This is needed to extract program information and help texts for the arguments.
309+
input_dirs: List of argument names representing input directories
310+
output_dirs: List of argument names representing output directories
311+
input_files: List of argument names representing input files
312+
output_files: List of argument names representing output files
313+
dataset_license: License string for the dataset (e.g., "CC BY 4.0").
314+
If None, no license is recorded.
315+
enabled_argument: Name of the attribute in args that indicates whether
316+
to record the invocation. Records if None.
317+
If provided, the invocation is only recorded if getattr(args, enabled_argument) is truthy.
318+
319+
Returns:
320+
Decorator function
321+
322+
Raises:
323+
ValueError:
324+
If the current user cannot be determined.
325+
If the specified paths are outside the crate root.
326+
If the software version cannot be determined based on the program name.
327+
MissingDestArgparseSubparserError:
328+
If parser has subparsers but dest is not set.
329+
"""
330+
331+
def decorator(func: Callable[[Namespace], T]) -> Callable[[Namespace], T]:
332+
@wraps(func)
333+
def wrapper(args: Namespace) -> T:
334+
start_datetime = datetime.now(tz=UTC)
335+
336+
result = func(args)
337+
338+
if enabled_argument is None or getattr(args, enabled_argument, False):
339+
end_time = datetime.now(tz=UTC)
340+
ios = IOArgumentNames(
341+
input_dirs=input_dirs or [],
342+
output_dirs=output_dirs or [],
343+
input_files=input_files or [],
344+
output_files=output_files or [],
345+
)
346+
record_argparse(
347+
parser=parser,
348+
ns=args,
349+
ios=ios,
350+
start_time=start_datetime,
351+
end_time=end_time,
352+
dataset_license=dataset_license,
353+
)
354+
355+
return result
356+
357+
return wrapper
358+
359+
return decorator

src/rocrate_action_recorder/core.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Core functionality for recording CLI invocations in RO-Crate format."""
22

33
from dataclasses import dataclass, field
4-
from datetime import datetime
4+
from datetime import UTC, datetime
55
import getpass
66
import importlib.metadata
77
import inspect
@@ -200,6 +200,8 @@ def record(
200200
If the current user cannot be determined.
201201
If the specified paths are outside the crate root.
202202
If the software version cannot be determined based on the program name.
203+
If start_time and end_time have different timezone information.
204+
If start_time is after end_time.
203205
"""
204206
crate_root = Path(crate_dir or Path.cwd()).resolve().expanduser()
205207
crate_root.mkdir(parents=True, exist_ok=True)
@@ -213,7 +215,16 @@ def record(
213215
current_user = getpass.getuser()
214216

215217
if end_time is None:
216-
end_time = datetime.now()
218+
end_time = datetime.now(tz=UTC)
219+
220+
if start_time.tzinfo != end_time.tzinfo:
221+
raise ValueError(
222+
f"start_time and end_time must have same timezone. Now start_time tzinfo is {start_time.tzinfo} and end_time tzinfo is {end_time.tzinfo}"
223+
)
224+
if start_time > end_time:
225+
raise ValueError(
226+
f"start_time must be before end_time. Now start_time is {start_time} and end_time is {end_time}"
227+
)
217228

218229
if not program.version:
219230
program.version = detect_software_version(program.name)

0 commit comments

Comments
 (0)