phistory 0.2.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.
phistory-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tmo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include phistory *.py
4
+ recursive-include tests *.py
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: phistory
3
+ Version: 0.2.0
4
+ Summary: Zero-config argparse history recorder and replayer
5
+ Author: amorriso
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/amorriso/phistory
8
+ Project-URL: Source, https://github.com/amorriso/phistory
9
+ Project-URL: Issues, https://github.com/amorriso/phistory/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: PyYAML>=6.0
24
+ Dynamic: author
25
+ Dynamic: classifier
26
+ Dynamic: description
27
+ Dynamic: description-content-type
28
+ Dynamic: license
29
+ Dynamic: license-file
30
+ Dynamic: project-url
31
+ Dynamic: requires-dist
32
+ Dynamic: requires-python
33
+ Dynamic: summary
34
+
35
+ # phistory
36
+
37
+ `phistory` makes Python scripts remember how they were run, and provides a
38
+ small YAML parameter loader for scripts that do not use `argparse`.
39
+
40
+ It supports Python 3.10+.
41
+
42
+ ---
43
+
44
+ ## Quickstart
45
+
46
+ ```python
47
+ # myscript.py
48
+ import phistory # must be first
49
+ import argparse
50
+
51
+ p = argparse.ArgumentParser()
52
+ p.add_argument("--foo")
53
+ p.add_argument("bar")
54
+ args = p.parse_args()
55
+ print(args)
56
+ ```
57
+
58
+ ### Run
59
+
60
+ ```bash
61
+ python myscript.py --foo 123 hello
62
+ python myscript.py --foo 999 world
63
+ python myscript.py --history
64
+ # outputs:
65
+ # myscript.py --foo 123 hello
66
+ # myscript.py --foo 999 world
67
+ ```
68
+
69
+ ---
70
+
71
+ ## What it does
72
+
73
+ - Saves each execution’s CLI (`script name + args`) to `~/.python-history/<script>.history`.
74
+ - When run with `--history`, prints previous runs (copy/paste friendly) and exits.
75
+ - Only writes history when your script calls `argparse.parse_args` or `parse_known_args`.
76
+ - The `--history` flag itself is never recorded.
77
+
78
+ **History directory:** `~/.python-history/`
79
+
80
+ Use `--history date` to include timestamps, `--history unique` to show only
81
+ the first occurrence of each command, or both options together.
82
+
83
+ > `phistory` intentionally monkey-patches `argparse.ArgumentParser` when it is
84
+ > imported. Import it before importing or configuring `argparse` in a script
85
+ > that should record history.
86
+
87
+ ---
88
+
89
+ ## YAML args (no argparse required)
90
+
91
+ Skip `argparse` entirely and just load parameters from YAML.
92
+
93
+ ```python
94
+ from phistory import yaml_args
95
+
96
+ args = yaml_args.load() # looks for "<script>.params.yaml" in the current directory
97
+ print(args.outpath) # access fields as attributes
98
+ ```
99
+
100
+ ### Behavior
101
+
102
+ - Default file: `<script_stem>.params.yaml` (e.g. `runner.params.yaml`)
103
+ - You can also provide the path explicitly or as a single argument:
104
+ ```bash
105
+ python runner.py configs/myexp.yaml
106
+ ```
107
+ - Validate required keys:
108
+ ```python
109
+ args = yaml_args.load(required=["experiment-name", "outpath", "description"])
110
+ ```
111
+
112
+ ### Helper functions
113
+
114
+ ```python
115
+ from phistory import derive_params_filename, load_yaml_params
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Installation
121
+
122
+ ```bash
123
+ pip install phistory
124
+ ```
125
+
126
+ ---
127
+
128
+ ## License
129
+
130
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,96 @@
1
+ # phistory
2
+
3
+ `phistory` makes Python scripts remember how they were run, and provides a
4
+ small YAML parameter loader for scripts that do not use `argparse`.
5
+
6
+ It supports Python 3.10+.
7
+
8
+ ---
9
+
10
+ ## Quickstart
11
+
12
+ ```python
13
+ # myscript.py
14
+ import phistory # must be first
15
+ import argparse
16
+
17
+ p = argparse.ArgumentParser()
18
+ p.add_argument("--foo")
19
+ p.add_argument("bar")
20
+ args = p.parse_args()
21
+ print(args)
22
+ ```
23
+
24
+ ### Run
25
+
26
+ ```bash
27
+ python myscript.py --foo 123 hello
28
+ python myscript.py --foo 999 world
29
+ python myscript.py --history
30
+ # outputs:
31
+ # myscript.py --foo 123 hello
32
+ # myscript.py --foo 999 world
33
+ ```
34
+
35
+ ---
36
+
37
+ ## What it does
38
+
39
+ - Saves each execution’s CLI (`script name + args`) to `~/.python-history/<script>.history`.
40
+ - When run with `--history`, prints previous runs (copy/paste friendly) and exits.
41
+ - Only writes history when your script calls `argparse.parse_args` or `parse_known_args`.
42
+ - The `--history` flag itself is never recorded.
43
+
44
+ **History directory:** `~/.python-history/`
45
+
46
+ Use `--history date` to include timestamps, `--history unique` to show only
47
+ the first occurrence of each command, or both options together.
48
+
49
+ > `phistory` intentionally monkey-patches `argparse.ArgumentParser` when it is
50
+ > imported. Import it before importing or configuring `argparse` in a script
51
+ > that should record history.
52
+
53
+ ---
54
+
55
+ ## YAML args (no argparse required)
56
+
57
+ Skip `argparse` entirely and just load parameters from YAML.
58
+
59
+ ```python
60
+ from phistory import yaml_args
61
+
62
+ args = yaml_args.load() # looks for "<script>.params.yaml" in the current directory
63
+ print(args.outpath) # access fields as attributes
64
+ ```
65
+
66
+ ### Behavior
67
+
68
+ - Default file: `<script_stem>.params.yaml` (e.g. `runner.params.yaml`)
69
+ - You can also provide the path explicitly or as a single argument:
70
+ ```bash
71
+ python runner.py configs/myexp.yaml
72
+ ```
73
+ - Validate required keys:
74
+ ```python
75
+ args = yaml_args.load(required=["experiment-name", "outpath", "description"])
76
+ ```
77
+
78
+ ### Helper functions
79
+
80
+ ```python
81
+ from phistory import derive_params_filename, load_yaml_params
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Installation
87
+
88
+ ```bash
89
+ pip install phistory
90
+ ```
91
+
92
+ ---
93
+
94
+ ## License
95
+
96
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,40 @@
1
+ """
2
+ phistory: seamless argparse execution history.
3
+
4
+ Import this at the very top of your script:
5
+
6
+ import phistory
7
+
8
+ Behaviour:
9
+ - On import, if '--history' is present in sys.argv, prints history for the current
10
+ script and exits.
11
+ - Patches argparse's parse_args/parse_known_args so that, on first call, the
12
+ invocation (script name + args) is appended to ~/.python-history/<script>.history
13
+ (excluding the --history flag itself).
14
+ """
15
+
16
+ from .core import install as _install
17
+ import os
18
+
19
+ # Allow disabling automatic install during import for tests or introspection.
20
+ # Set the environment variable PHISTORY_NO_AUTO_INSTALL=1 to suppress auto-hooks.
21
+ if os.environ.get("PHISTORY_NO_AUTO_INSTALL") != "1":
22
+ _install()
23
+
24
+ # phistory/__init__.py (append to the bottom)
25
+ from .yaml_args import (
26
+ load as yaml_args_load,
27
+ derive_params_filename,
28
+ load_yaml_params,
29
+ )
30
+
31
+ # nice namespace: phistory.yaml_args.load
32
+ from . import yaml_args # re-export module for ergonomic `from phistory import yaml_args`
33
+
34
+ __all__ = [
35
+ # existing…
36
+ "yaml_args",
37
+ "yaml_args_load",
38
+ "derive_params_filename",
39
+ "load_yaml_params",
40
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,225 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import shlex
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Iterable, Optional, Tuple
8
+ from datetime import datetime
9
+
10
+ # Guard to avoid writing multiple times per process execution.
11
+ _HISTORY_WRITTEN = False
12
+
13
+ # Valid suboptions that may follow --history
14
+ _HISTORY_SUBOPTS = {"date", "unique"}
15
+
16
+
17
+ def _history_dir() -> Path:
18
+ """
19
+ Compute the history directory dynamically each time so changes to HOME
20
+ are respected. On Windows, prefer LOCALAPPDATA (then APPDATA).
21
+ - Windows: %LOCALAPPDATA%/Python/phistory/history
22
+ - Others : ~/.python-history
23
+ """
24
+ if os.name == "nt":
25
+ base = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA")
26
+ if base:
27
+ return Path(base) / "Python" / "phistory" / "history"
28
+ # Fallback if env vars are missing
29
+ return Path.home() / "AppData" / "Local" / "Python" / "phistory" / "history"
30
+ # POSIX default
31
+ return Path.home() / ".python-history"
32
+
33
+
34
+ def _script_name() -> str:
35
+ # Fall back to "script" if argv[0] is empty.
36
+ return os.path.basename(sys.argv[0]) or "script"
37
+
38
+
39
+ def _history_file_for(script_name: Optional[str] = None) -> Path:
40
+ if script_name is None:
41
+ script_name = _script_name()
42
+ return _history_dir() / f"{script_name}.history"
43
+
44
+
45
+ def _ensure_dir():
46
+ _history_dir().mkdir(parents=True, exist_ok=True)
47
+
48
+
49
+ def _parse_history_options(argv: Iterable[str]) -> Tuple[bool, bool]:
50
+ """
51
+ Scan argv for --history and its optional suboptions.
52
+ Returns: (want_date, want_unique).
53
+ """
54
+ want_date = False
55
+ want_unique = False
56
+ tokens = list(argv)
57
+ for i, tok in enumerate(tokens):
58
+ if tok == "--history":
59
+ # Consume following suboptions while valid (order agnostic)
60
+ j = i + 1
61
+ while j < len(tokens) and tokens[j] in _HISTORY_SUBOPTS:
62
+ if tokens[j] == "date":
63
+ want_date = True
64
+ elif tokens[j] == "unique":
65
+ want_unique = True
66
+ j += 1
67
+ break
68
+ return want_date, want_unique
69
+
70
+
71
+ def _filter_args(argv: Iterable[str]) -> list[str]:
72
+ """
73
+ Remove --history and its recognized suboptions from the provided argv.
74
+ Leave everything else intact.
75
+ """
76
+ filtered = []
77
+ it = iter(list(argv))
78
+ for a in it:
79
+ if a == "--history":
80
+ # Skip any recognized suboptions that immediately follow (max two)
81
+ consumed = 0
82
+ while consumed < 2:
83
+ try:
84
+ nxt = next(it)
85
+ except StopIteration:
86
+ break
87
+ if nxt in _HISTORY_SUBOPTS:
88
+ consumed += 1
89
+ continue
90
+ # Not a recognized suboption: push back into filtered
91
+ filtered.append(nxt)
92
+ break
93
+ continue
94
+ filtered.append(a)
95
+ return filtered
96
+
97
+
98
+ def _join_args_for_shell(args: list[str]) -> str:
99
+ """
100
+ Platform-appropriate quoting for replayable commands.
101
+ - Windows: use subprocess.list2cmdline (CreateProcess/CommandLineToArgvW rules)
102
+ - POSIX : use shlex.quote
103
+ """
104
+ if os.name == "nt":
105
+ return subprocess.list2cmdline(args)
106
+ return " ".join(shlex.quote(a) for a in args)
107
+
108
+
109
+ def _record_invocation(argv: Optional[Iterable[str]] = None):
110
+ """
111
+ Append a single line to the history file.
112
+ Stored format: "<ISO_DATETIME>\t<commandline>"
113
+ ISO datetime uses seconds precision in local time.
114
+ """
115
+ global _HISTORY_WRITTEN
116
+ if _HISTORY_WRITTEN:
117
+ return
118
+ if argv is None:
119
+ argv = sys.argv[1:]
120
+
121
+ args_list = list(argv)
122
+ args_list = _filter_args(args_list)
123
+
124
+ # Compose a fully copy/paste-able command line: "<script> <args...>"
125
+ command = _script_name()
126
+ if args_list:
127
+ command += " " + _join_args_for_shell(args_list)
128
+
129
+ _ensure_dir()
130
+ hf = _history_file_for()
131
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
132
+ with hf.open("a", encoding="utf-8") as f:
133
+ f.write(f"{ts}\t{command}\n")
134
+
135
+ _HISTORY_WRITTEN = True
136
+
137
+
138
+ def _render_history_line(raw_line: str, want_date: bool) -> Optional[str]:
139
+ """
140
+ Render one history line for output depending on want_date.
141
+ History lines may be either:
142
+ 1) "<ts>\t<command>" (new format), or
143
+ 2) "<command>" (legacy format)
144
+ Returns the printable string, or None if the line is empty.
145
+ """
146
+ line = raw_line.rstrip("\n")
147
+ if not line:
148
+ return None
149
+ if "\t" in line:
150
+ ts, cmd = line.split("\t", 1)
151
+ return f"{ts} {cmd}" if want_date else cmd
152
+ # legacy format (no timestamp)
153
+ return line if not want_date else line # no ts to show; print command only
154
+
155
+
156
+ def _print_history_and_exit():
157
+ want_date, want_unique = _parse_history_options(sys.argv[1:])
158
+ hf = _history_file_for()
159
+ seen = set()
160
+ output = []
161
+
162
+ if hf.exists():
163
+ with hf.open("r", encoding="utf-8") as f:
164
+ for raw in f:
165
+ # For uniqueness, dedupe by the *command* part (strip timestamp if present)
166
+ dedupe_key = raw.split("\t", 1)[-1].rstrip("\n")
167
+ if want_unique:
168
+ if dedupe_key in seen:
169
+ continue
170
+ seen.add(dedupe_key)
171
+ rendered = _render_history_line(raw, want_date=want_date)
172
+ if rendered is not None:
173
+ output.append(rendered)
174
+
175
+ if output:
176
+ sys.stdout.write("\n".join(output) + "\n")
177
+ else:
178
+ # No history yet; keep output friendly but still copyable (empty)
179
+ sys.stdout.write("")
180
+ raise SystemExit(0)
181
+
182
+
183
+ def _check_and_handle_history_flag():
184
+ if "--history" in sys.argv[1:]:
185
+ _print_history_and_exit()
186
+
187
+
188
+ def _patch_argparse():
189
+ """Monkey-patch argparse to record once per process when parse_* is called."""
190
+ # Only patch once
191
+ if getattr(argparse.ArgumentParser, "_phistory_patched", False):
192
+ return
193
+
194
+ _orig_parse_args = argparse.ArgumentParser.parse_args
195
+ _orig_parse_known_args = argparse.ArgumentParser.parse_known_args
196
+
197
+ def _wrap_parse_args(self, args=None, namespace=None):
198
+ # Normalize incoming args (explicit or sys.argv) and strip --history so argparse won't error.
199
+ incoming = list(args) if args is not None else list(sys.argv[1:])
200
+ filtered = _filter_args(incoming)
201
+ # If args is None, pass None so argparse reads from sys.argv; we still record using filtered.
202
+ result = _orig_parse_args(self, args=filtered if args is not None else None, namespace=namespace)
203
+ _record_invocation(filtered)
204
+ return result
205
+
206
+ def _wrap_parse_known_args(self, args=None, namespace=None):
207
+ incoming = list(args) if args is not None else list(sys.argv[1:])
208
+ filtered = _filter_args(incoming)
209
+ result = _orig_parse_known_args(self, args=filtered if args is not None else None, namespace=namespace)
210
+ _record_invocation(filtered)
211
+ return result
212
+
213
+ argparse.ArgumentParser.parse_args = _wrap_parse_args
214
+ argparse.ArgumentParser.parse_known_args = _wrap_parse_known_args
215
+ argparse.ArgumentParser._phistory_patched = True # sentinel
216
+
217
+
218
+ def install():
219
+ """
220
+ Entry point called by package import.
221
+ - If '--history' present, print history (with options) and exit.
222
+ - Otherwise patch argparse to record on first parse.
223
+ """
224
+ _check_and_handle_history_flag()
225
+ _patch_argparse()
@@ -0,0 +1,156 @@
1
+ """
2
+ phistory.yaml_args
3
+ Ultra-minimal YAML-based argument loader to complement phistory's zero-config history.
4
+
5
+ Usage:
6
+ from phistory import yaml_args
7
+ args = yaml_args.load() # looks in PWD for "<script>.params.yaml"
8
+ # or: args = yaml_args.load("/path/to/params.yaml")
9
+
10
+ Conventions:
11
+ - Default params filename is derived from your script name and searched in the
12
+ current working directory (pwd): "<script_stem>.params.yaml".
13
+ - If the user supplies a single positional argument (that does not start with "-"),
14
+ it is treated as the params file path (absolute or relative).
15
+ - You can also pass params_file explicitly.
16
+
17
+ Returns:
18
+ - argparse.Namespace by default (attribute access), or a dict with as_namespace=False.
19
+ """
20
+
21
+ from __future__ import annotations
22
+ import os
23
+ import re
24
+ import sys
25
+ from argparse import Namespace
26
+ from pathlib import Path
27
+ from typing import Any, Dict, Iterable, Mapping, Optional
28
+
29
+ try:
30
+ import yaml # type: ignore
31
+ except Exception as e: # pragma: no cover
32
+ raise RuntimeError(
33
+ "PyYAML is required for phistory.yaml_args. "
34
+ "Install with: pip install PyYAML"
35
+ ) from e
36
+
37
+
38
+ def derive_params_filename(script_path: Optional[str] = None, suffix: str = ".params.yaml") -> str:
39
+ """
40
+ Derive "<script_stem>.params.yaml" from a script path.
41
+ Example: "/x/run_experiment.py" -> "run_experiment.params.yaml"
42
+ """
43
+ if not script_path:
44
+ script_path = sys.argv[0] or "script.py"
45
+ stem = re.sub(r"\.py$", "", os.path.basename(script_path))
46
+ return f"{stem}{suffix}"
47
+
48
+
49
+ def load_yaml_params(params_filename: str) -> Dict[str, Any]:
50
+ """
51
+ Load YAML from disk.
52
+ - Raises FileNotFoundError if the file does not exist.
53
+ - Returns {} if the YAML is empty.
54
+ """
55
+ path = Path(params_filename)
56
+ if not path.exists():
57
+ raise FileNotFoundError(f"Parameters file not found: {params_filename}")
58
+ with path.open("r", encoding="utf-8") as f:
59
+ return yaml.safe_load(f) or {}
60
+
61
+
62
+ def require_keys(params: Mapping[str, Any], required: Iterable[str]) -> None:
63
+ """Raise ValueError if any required key is missing."""
64
+ missing = [k for k in required if k not in params]
65
+ if missing:
66
+ raise ValueError(f"Missing required parameter(s) in YAML: {', '.join(missing)}")
67
+
68
+
69
+ def _positional_argv_candidate() -> Optional[str]:
70
+ """
71
+ Return the first positional (non-flag) argument from sys.argv (if any).
72
+ Flags start with '-'. Only checks argv[1], in keeping with the "single argument" UX.
73
+ """
74
+ if len(sys.argv) >= 2:
75
+ first = sys.argv[1]
76
+ if first and not first.startswith("-"):
77
+ return first
78
+ return None
79
+
80
+
81
+ def load(
82
+ params_file: Optional[str] = None,
83
+ required: Optional[Iterable[str]] = None,
84
+ *,
85
+ as_namespace: bool = True,
86
+ ) -> Namespace | Dict[str, Any]:
87
+ """
88
+ Load parameters from YAML with zero script-side config.
89
+
90
+ Resolution order:
91
+ 1) If params_file is provided, use it.
92
+ 2) Else if a single positional (non-flag) argv is present and points to an existing file, use it.
93
+ 3) Else look in the current working directory for "<script_stem>.params.yaml".
94
+
95
+ Args:
96
+ params_file: optional path to YAML.
97
+ required: optional iterable of required keys to enforce.
98
+ as_namespace: if True (default) return argparse.Namespace; else a dict.
99
+
100
+ Returns:
101
+ argparse.Namespace (default) or dict with the YAML keys as attributes/keys.
102
+ """
103
+ searched: list[str] = []
104
+
105
+ # 1) Explicit path wins
106
+ if params_file:
107
+ pf_path = Path(params_file)
108
+ searched.append(str(pf_path))
109
+ if not pf_path.exists():
110
+ raise FileNotFoundError(
111
+ f"Parameters file not found: {pf_path}"
112
+ )
113
+ params = load_yaml_params(str(pf_path))
114
+ else:
115
+ # 2) Single positional argv candidate
116
+ cand = _positional_argv_candidate()
117
+ if cand:
118
+ cand_path = Path(cand)
119
+ searched.append(str(cand_path))
120
+ if cand_path.exists():
121
+ params = load_yaml_params(str(cand_path))
122
+ else:
123
+ # 3) Fall back to default in PWD
124
+ default_name = derive_params_filename(sys.argv[0])
125
+ default_path = Path.cwd() / default_name
126
+ searched.append(str(default_path))
127
+ if not default_path.exists():
128
+ raise FileNotFoundError(
129
+ "Could not locate a parameters YAML.\n"
130
+ f"Tried:\n - {cand_path}\n - {default_path}\n\n"
131
+ "Tips:\n"
132
+ " • Put '<script_stem>.params.yaml' in the current working directory, or\n"
133
+ " • Provide a single positional argument with the path to the YAML, or\n"
134
+ " • Call yaml_args.load(params_file='/path/to/file.yaml')."
135
+ )
136
+ params = load_yaml_params(str(default_path))
137
+ else:
138
+ # 3) Default in PWD
139
+ default_name = derive_params_filename(sys.argv[0])
140
+ default_path = Path.cwd() / default_name
141
+ searched.append(str(default_path))
142
+ if not default_path.exists():
143
+ raise FileNotFoundError(
144
+ f"Parameters file not found in current directory: {default_path}\n"
145
+ "Tips:\n"
146
+ " • Put '<script_stem>.params.yaml' in the current working directory, or\n"
147
+ " • Provide a single positional argument with the path to the YAML, or\n"
148
+ " • Call yaml_args.load(params_file='/path/to/file.yaml')."
149
+ )
150
+ params = load_yaml_params(str(default_path))
151
+
152
+ if required:
153
+ require_keys(params, required)
154
+
155
+ return Namespace(**params) if as_namespace else params
156
+
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: phistory
3
+ Version: 0.2.0
4
+ Summary: Zero-config argparse history recorder and replayer
5
+ Author: amorriso
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/amorriso/phistory
8
+ Project-URL: Source, https://github.com/amorriso/phistory
9
+ Project-URL: Issues, https://github.com/amorriso/phistory/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: PyYAML>=6.0
24
+ Dynamic: author
25
+ Dynamic: classifier
26
+ Dynamic: description
27
+ Dynamic: description-content-type
28
+ Dynamic: license
29
+ Dynamic: license-file
30
+ Dynamic: project-url
31
+ Dynamic: requires-dist
32
+ Dynamic: requires-python
33
+ Dynamic: summary
34
+
35
+ # phistory
36
+
37
+ `phistory` makes Python scripts remember how they were run, and provides a
38
+ small YAML parameter loader for scripts that do not use `argparse`.
39
+
40
+ It supports Python 3.10+.
41
+
42
+ ---
43
+
44
+ ## Quickstart
45
+
46
+ ```python
47
+ # myscript.py
48
+ import phistory # must be first
49
+ import argparse
50
+
51
+ p = argparse.ArgumentParser()
52
+ p.add_argument("--foo")
53
+ p.add_argument("bar")
54
+ args = p.parse_args()
55
+ print(args)
56
+ ```
57
+
58
+ ### Run
59
+
60
+ ```bash
61
+ python myscript.py --foo 123 hello
62
+ python myscript.py --foo 999 world
63
+ python myscript.py --history
64
+ # outputs:
65
+ # myscript.py --foo 123 hello
66
+ # myscript.py --foo 999 world
67
+ ```
68
+
69
+ ---
70
+
71
+ ## What it does
72
+
73
+ - Saves each execution’s CLI (`script name + args`) to `~/.python-history/<script>.history`.
74
+ - When run with `--history`, prints previous runs (copy/paste friendly) and exits.
75
+ - Only writes history when your script calls `argparse.parse_args` or `parse_known_args`.
76
+ - The `--history` flag itself is never recorded.
77
+
78
+ **History directory:** `~/.python-history/`
79
+
80
+ Use `--history date` to include timestamps, `--history unique` to show only
81
+ the first occurrence of each command, or both options together.
82
+
83
+ > `phistory` intentionally monkey-patches `argparse.ArgumentParser` when it is
84
+ > imported. Import it before importing or configuring `argparse` in a script
85
+ > that should record history.
86
+
87
+ ---
88
+
89
+ ## YAML args (no argparse required)
90
+
91
+ Skip `argparse` entirely and just load parameters from YAML.
92
+
93
+ ```python
94
+ from phistory import yaml_args
95
+
96
+ args = yaml_args.load() # looks for "<script>.params.yaml" in the current directory
97
+ print(args.outpath) # access fields as attributes
98
+ ```
99
+
100
+ ### Behavior
101
+
102
+ - Default file: `<script_stem>.params.yaml` (e.g. `runner.params.yaml`)
103
+ - You can also provide the path explicitly or as a single argument:
104
+ ```bash
105
+ python runner.py configs/myexp.yaml
106
+ ```
107
+ - Validate required keys:
108
+ ```python
109
+ args = yaml_args.load(required=["experiment-name", "outpath", "description"])
110
+ ```
111
+
112
+ ### Helper functions
113
+
114
+ ```python
115
+ from phistory import derive_params_filename, load_yaml_params
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Installation
121
+
122
+ ```bash
123
+ pip install phistory
124
+ ```
125
+
126
+ ---
127
+
128
+ ## License
129
+
130
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ setup.py
6
+ phistory/__init__.py
7
+ phistory/__version__.py
8
+ phistory/core.py
9
+ phistory/yaml_args.py
10
+ phistory.egg-info/PKG-INFO
11
+ phistory.egg-info/SOURCES.txt
12
+ phistory.egg-info/dependency_links.txt
13
+ phistory.egg-info/requires.txt
14
+ phistory.egg-info/top_level.txt
15
+ tests/test_phistory.py
16
+ tests/test_yaml_args.py
@@ -0,0 +1 @@
1
+ PyYAML>=6.0
@@ -0,0 +1 @@
1
+ phistory
@@ -0,0 +1,7 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.pytest.ini_options]
6
+ addopts = "-q"
7
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ from pathlib import Path
2
+
3
+ from setuptools import find_packages, setup
4
+
5
+ ROOT = Path(__file__).parent
6
+ version_ns = {}
7
+ with open(ROOT / "phistory" / "__version__.py", "r", encoding="utf-8") as f:
8
+ exec(f.read(), version_ns)
9
+
10
+ setup(
11
+ name="phistory",
12
+ version=version_ns["__version__"],
13
+ description="Zero-config argparse history recorder and replayer",
14
+ long_description=(ROOT / "README.md").read_text(encoding="utf-8"),
15
+ long_description_content_type="text/markdown",
16
+ author="amorriso",
17
+ license="MIT",
18
+ packages=find_packages(exclude=("tests",)),
19
+ python_requires=">=3.10",
20
+ install_requires=["PyYAML>=6.0"],
21
+ include_package_data=True,
22
+ project_urls={
23
+ "Homepage": "https://github.com/amorriso/phistory",
24
+ "Source": "https://github.com/amorriso/phistory",
25
+ "Issues": "https://github.com/amorriso/phistory/issues",
26
+ },
27
+ classifiers=[
28
+ "Development Status :: 4 - Beta",
29
+ "Operating System :: OS Independent",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3 :: Only",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ "Intended Audience :: Developers",
37
+ "Topic :: Software Development :: Libraries",
38
+ ],
39
+ )
@@ -0,0 +1,191 @@
1
+
2
+ import importlib
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ import pytest
7
+
8
+
9
+ def _reload_phistory_core():
10
+ """Reload phistory.core cleanly, without triggering auto-install on import."""
11
+ if "phistory.core" in sys.modules:
12
+ del sys.modules["phistory.core"]
13
+ if "phistory" in sys.modules:
14
+ del sys.modules["phistory"]
15
+ os.environ["PHISTORY_NO_AUTO_INSTALL"] = "1"
16
+ import phistory.core as core # type: ignore
17
+ importlib.reload(core)
18
+ return core
19
+
20
+
21
+ def _strip_ts_if_present(line: str) -> str:
22
+ """Return the command part of a history line regardless of timestamp presence."""
23
+ line = line.strip()
24
+ if "\t" in line:
25
+ return line.split("\t", 1)[1]
26
+ return line
27
+
28
+
29
+ def test_records_invocation(tmp_path, monkeypatch):
30
+ # History should land under the tmp HOME
31
+ monkeypatch.setenv("HOME", str(tmp_path))
32
+ monkeypatch.setenv("PYTHONIOENCODING", "utf-8")
33
+ script_name = "myscript.py"
34
+ monkeypatch.setattr(sys, "argv", [script_name, "--foo", "bar", "baz"])
35
+
36
+ import argparse
37
+ parser = argparse.ArgumentParser()
38
+ parser.add_argument("--foo")
39
+ parser.add_argument("positional")
40
+
41
+ import phistory # noqa: F401 # installs hooks
42
+
43
+ ns = parser.parse_args()
44
+ assert ns.foo == "bar"
45
+ assert ns.positional == "baz"
46
+
47
+ hist_file = Path(tmp_path, ".python-history", f"{script_name}.history")
48
+ assert hist_file.exists()
49
+ content = hist_file.read_text(encoding="utf-8").strip().splitlines()
50
+ # The stored line is "<ts>\t<script> --foo bar baz". Compare command part only.
51
+ assert _strip_ts_if_present(content[-1]) == f"{script_name} --foo bar baz"
52
+
53
+
54
+ def test_history_flag_prints_and_exits(tmp_path, monkeypatch, capsys):
55
+ script_name = "runme.py"
56
+ hist_dir = Path(tmp_path, ".python-history")
57
+ hist_dir.mkdir(parents=True, exist_ok=True)
58
+ hist_file = hist_dir / f"{script_name}.history"
59
+ # Legacy format (no timestamps) still supported
60
+ hist_file.write_text(
61
+ f"{script_name} --alpha 1\n{script_name} --beta two three\n",
62
+ encoding="utf-8",
63
+ )
64
+
65
+ monkeypatch.setenv("HOME", str(tmp_path))
66
+ monkeypatch.setattr(sys, "argv", [script_name, "--history"])
67
+
68
+ core = _reload_phistory_core()
69
+
70
+ with pytest.raises(SystemExit) as exc:
71
+ core.install()
72
+
73
+ assert exc.value.code == 0
74
+ out = capsys.readouterr().out
75
+ assert out.strip().splitlines() == [
76
+ f"{script_name} --alpha 1",
77
+ f"{script_name} --beta two three",
78
+ ]
79
+
80
+
81
+ def test_ignores_history_flag_in_record(tmp_path, monkeypatch):
82
+ monkeypatch.setenv("HOME", str(tmp_path))
83
+ script_name = "cli.py"
84
+ monkeypatch.setattr(sys, "argv", [script_name])
85
+
86
+ # Reset modules so _HISTORY_WRITTEN is fresh
87
+ sys.modules.pop("phistory.core", None)
88
+ sys.modules.pop("phistory", None)
89
+ monkeypatch.delenv("PHISTORY_NO_AUTO_INSTALL", raising=False)
90
+
91
+ # 🔑 Also clear the argparse sentinel so a fresh patch is applied.
92
+ import argparse
93
+ if hasattr(argparse.ArgumentParser, "_phistory_patched"):
94
+ delattr(argparse.ArgumentParser, "_phistory_patched")
95
+
96
+ parser = argparse.ArgumentParser()
97
+ parser.add_argument("--x")
98
+
99
+ import phistory # noqa: F401 # installs hooks fresh
100
+
101
+ # Explicitly include --history in args; phistory must filter it out.
102
+ ns = parser.parse_args(["--history", "--x", "y"])
103
+ assert ns.x == "y"
104
+
105
+ hist_path = Path(tmp_path, ".python-history", f"{script_name}.history")
106
+ assert hist_path.exists()
107
+ lines = hist_path.read_text(encoding="utf-8").strip().splitlines()
108
+ assert _strip_ts_if_present(lines[-1]) == f"{script_name} --x y"
109
+
110
+
111
+ def test_history_date_option_prints_timestamps(tmp_path, monkeypatch, capsys):
112
+ script_name = "show.py"
113
+ hist_dir = Path(tmp_path, ".python-history")
114
+ hist_dir.mkdir(parents=True, exist_ok=True)
115
+ hist_file = hist_dir / f"{script_name}.history"
116
+ hist_file.write_text(
117
+ "2024-12-31 23:59:59\tshow.py --a 1\n"
118
+ "2025-01-01 00:00:00\tshow.py --b 2 3\n",
119
+ encoding="utf-8",
120
+ )
121
+
122
+ monkeypatch.setenv("HOME", str(tmp_path))
123
+ monkeypatch.setattr(sys, "argv", [script_name, "--history", "date"])
124
+
125
+ core = _reload_phistory_core()
126
+ with pytest.raises(SystemExit) as exc:
127
+ core.install()
128
+ assert exc.value.code == 0
129
+
130
+ out_lines = capsys.readouterr().out.strip().splitlines()
131
+ assert out_lines == [
132
+ "2024-12-31 23:59:59 show.py --a 1",
133
+ "2025-01-01 00:00:00 show.py --b 2 3",
134
+ ]
135
+
136
+
137
+ def test_history_unique_option_dedupes(tmp_path, monkeypatch, capsys):
138
+ script_name = "dedupe.py"
139
+ hist_dir = Path(tmp_path, ".python-history")
140
+ hist_dir.mkdir(parents=True, exist_ok=True)
141
+ hist_file = hist_dir / f"{script_name}.history"
142
+ # Two identical commands with different timestamps + one unique
143
+ hist_file.write_text(
144
+ "2025-01-01 10:00:00\tdedupe.py --x 1\n"
145
+ "2025-01-01 10:05:00\tdedupe.py --x 1\n"
146
+ "2025-01-01 10:10:00\tdedupe.py --y 2\n",
147
+ encoding="utf-8",
148
+ )
149
+
150
+ monkeypatch.setenv("HOME", str(tmp_path))
151
+ monkeypatch.setattr(sys, "argv", [script_name, "--history", "unique"])
152
+
153
+ core = _reload_phistory_core()
154
+ with pytest.raises(SystemExit) as exc:
155
+ core.install()
156
+ assert exc.value.code == 0
157
+
158
+ # With 'unique' only, dates are not shown; expect first occurrence only.
159
+ out_lines = capsys.readouterr().out.strip().splitlines()
160
+ assert out_lines == [
161
+ "dedupe.py --x 1",
162
+ "dedupe.py --y 2",
163
+ ]
164
+
165
+
166
+ def test_history_date_unique_together(tmp_path, monkeypatch, capsys):
167
+ script_name = "both.py"
168
+ hist_dir = Path(tmp_path, ".python-history")
169
+ hist_dir.mkdir(parents=True, exist_ok=True)
170
+ hist_file = hist_dir / f"{script_name}.history"
171
+ hist_file.write_text(
172
+ "2025-01-01 10:00:00\tboth.py --x 1\n"
173
+ "2025-01-01 10:05:00\tboth.py --x 1\n" # duplicate command
174
+ "2025-01-01 10:10:00\tboth.py --y 2\n",
175
+ encoding="utf-8",
176
+ )
177
+
178
+ monkeypatch.setenv("HOME", str(tmp_path))
179
+ monkeypatch.setattr(sys, "argv", [script_name, "--history", "date", "unique"])
180
+
181
+ core = _reload_phistory_core()
182
+ with pytest.raises(SystemExit) as exc:
183
+ core.install()
184
+ assert exc.value.code == 0
185
+
186
+ # With 'date unique', include the timestamp of the first occurrence only.
187
+ out_lines = capsys.readouterr().out.strip().splitlines()
188
+ assert out_lines == [
189
+ "2025-01-01 10:00:00 both.py --x 1",
190
+ "2025-01-01 10:10:00 both.py --y 2",
191
+ ]
@@ -0,0 +1,44 @@
1
+ # tests/test_yaml_args.py
2
+ from pathlib import Path
3
+ from phistory import yaml_args, derive_params_filename, load_yaml_params
4
+ import pytest
5
+
6
+ def test_derive_params_filename():
7
+ assert yaml_args.derive_params_filename("/x/run.py") == "run.params.yaml"
8
+ assert derive_params_filename("/x/runner.py") == "runner.params.yaml"
9
+
10
+ def test_load_yaml_and_required(tmp_path: Path, monkeypatch):
11
+ # Prepare a params file in a temp PWD
12
+ pf = tmp_path / "runner.params.yaml"
13
+ pf.write_text("outpath: out\ndescription: d\nexperiment-name: e\n", encoding="utf-8")
14
+
15
+ # Make PWD be tmp_path so default discovery works (searches PWD)
16
+ monkeypatch.chdir(tmp_path)
17
+
18
+ # default discovery uses sys.argv[0] to derive name, but file is in PWD
19
+ monkeypatch.setenv("PYTHONIOENCODING", "utf-8")
20
+ monkeypatch.setattr("sys.argv", ["runner.py"])
21
+
22
+ args = yaml_args.load()
23
+ assert args.outpath == "out"
24
+
25
+ # required OK
26
+ yaml_args.load(required=["experiment-name", "outpath", "description"])
27
+
28
+ # missing required key should raise
29
+ pf.write_text("outpath: out\n", encoding="utf-8")
30
+ with pytest.raises(ValueError):
31
+ yaml_args.load(required=["experiment-name"])
32
+
33
+ def test_single_positional_argument_path(tmp_path: Path, monkeypatch):
34
+ # Create a YAML in a non-PWD directory
35
+ custom = tmp_path / "custom.yaml"
36
+ custom.write_text("alpha: 42\nbeta: true\n", encoding="utf-8")
37
+
38
+ # Keep current PWD as-is; pass the path as the single positional arg
39
+ monkeypatch.setattr("sys.argv", ["runner.py", str(custom)])
40
+
41
+ args = yaml_args.load()
42
+ assert args.alpha == 42
43
+ assert args.beta is True
44
+