labnotes 0.0.33__tar.gz → 0.0.35__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.
Files changed (94) hide show
  1. {labnotes-0.0.33 → labnotes-0.0.35}/PKG-INFO +1 -2
  2. {labnotes-0.0.33 → labnotes-0.0.35}/pyproject.toml +0 -1
  3. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/context.md +5 -1
  4. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/models.py +1 -0
  5. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/execute_run.py +2 -0
  6. labnotes-0.0.35/src/labnotes/runner/hold_folder.py +15 -0
  7. labnotes-0.0.35/src/labnotes/runner/new_invocation_id.py +6 -0
  8. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/read_experiment_config.py +6 -2
  9. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/run_experiment.py +15 -3
  10. labnotes-0.0.35/src/labnotes/runner/runs_parent.py +7 -0
  11. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/start_experiment.py +7 -8
  12. labnotes-0.0.35/testing-conventions.toml +5 -0
  13. {labnotes-0.0.33 → labnotes-0.0.35}/uv.lock +0 -11
  14. labnotes-0.0.33/src/labnotes/runner/lock_path.py +0 -7
  15. {labnotes-0.0.33 → labnotes-0.0.35}/.gitignore +0 -0
  16. {labnotes-0.0.33 → labnotes-0.0.35}/CHANGELOG.md +0 -0
  17. {labnotes-0.0.33 → labnotes-0.0.35}/MIGRATIONS.md +0 -0
  18. {labnotes-0.0.33 → labnotes-0.0.35}/README.md +0 -0
  19. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/__init__.py +0 -0
  20. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/asset_url.py +0 -0
  21. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/cell_progress.py +0 -0
  22. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/check_counts.py +0 -0
  23. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/cli.py +0 -0
  24. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/context_guide.py +0 -0
  25. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/count_runs.py +0 -0
  26. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/create_app.py +0 -0
  27. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/create_experiment.py +0 -0
  28. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/data_html.py +0 -0
  29. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/default_config_text.py +0 -0
  30. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/default_counts.py +0 -0
  31. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/drain_watch.py +0 -0
  32. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/experiment_notes.py +0 -0
  33. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/experiment_progress.py +0 -0
  34. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/experiment_runs.py +0 -0
  35. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/experiments.py +0 -0
  36. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/format_value.py +0 -0
  37. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/init.py +0 -0
  38. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/list_experiments.py +0 -0
  39. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/list_runs.py +0 -0
  40. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/load_config.py +0 -0
  41. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/load_experiment.py +0 -0
  42. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/notebook_kernel.py +0 -0
  43. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/output_html.py +0 -0
  44. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/parse_run_view.py +0 -0
  45. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/pivot_cells.py +0 -0
  46. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/print_rows.py +0 -0
  47. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/python_blocks.py +0 -0
  48. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/read_config.py +0 -0
  49. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/read_path.py +0 -0
  50. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/render_markdown.py +0 -0
  51. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/__init__.py +0 -0
  52. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/describe_event.py +0 -0
  53. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/new_run_id.py +0 -0
  54. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/param_args.py +0 -0
  55. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/param_grid.py +0 -0
  56. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/plan_runs.py +0 -0
  57. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/run_folder.py +0 -0
  58. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/run_headless.py +0 -0
  59. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/shell_command.py +0 -0
  60. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/stop_then_kill.py +0 -0
  61. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/wait_for_group.py +0 -0
  62. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/runner/write_run_json.py +0 -0
  63. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/saved_outputs.py +0 -0
  64. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/serve_app.py +0 -0
  65. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/server.py +0 -0
  66. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/sort_href.py +0 -0
  67. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/sql_quote.py +0 -0
  68. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/static/labnotes.css +0 -0
  69. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/static/row-link.js +0 -0
  70. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/static/run-cell.js +0 -0
  71. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/static/view-toggle.js +0 -0
  72. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/stop_idle_kernels.py +0 -0
  73. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/summarize_config.py +0 -0
  74. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/__init__.py +0 -0
  75. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/experiment_rows.py +0 -0
  76. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/file_prefix.py +0 -0
  77. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/iso_time.py +0 -0
  78. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/list_experiment_runs.py +0 -0
  79. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/open_tables.py +0 -0
  80. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/path_glob.py +0 -0
  81. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/path_pattern.py +0 -0
  82. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/read_experiment.py +0 -0
  83. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/read_file.py +0 -0
  84. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/read_run.py +0 -0
  85. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tables/summarize_experiments.py +0 -0
  86. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/templates/base.html +0 -0
  87. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/templates/experiment.html +0 -0
  88. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/templates/index.html +0 -0
  89. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/templates/macros.html +0 -0
  90. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tui/__init__.py +0 -0
  91. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tui/dim.py +0 -0
  92. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tui/format_elapsed.py +0 -0
  93. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tui/run_app.py +0 -0
  94. {labnotes-0.0.33 → labnotes-0.0.35}/src/labnotes/tui/run_tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.33
3
+ Version: 0.0.35
4
4
  Summary: Browse experiment designs, notes, and run data from a folder.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -8,7 +8,6 @@ License-Expression: MIT
8
8
  Requires-Python: >=3.12
9
9
  Requires-Dist: click>=8.5.0
10
10
  Requires-Dist: dirsql>=0.4.110
11
- Requires-Dist: filelock>=4.2.0
12
11
  Requires-Dist: haikunator>=2.1.0
13
12
  Requires-Dist: ipykernel>=7.4.0
14
13
  Requires-Dist: jinja2>=3.1.6
@@ -13,7 +13,6 @@ authors = [{ name = "Kevin Scott", email = "me@thekevinscott.com" }]
13
13
  dependencies = [
14
14
  "click>=8.5.0",
15
15
  "dirsql>=0.4.110",
16
- "filelock>=4.2.0",
17
16
  "haikunator>=2.1.0",
18
17
  "ipykernel>=7.4.0",
19
18
  "jinja2>=3.1.6",
@@ -59,13 +59,17 @@ renaming the experiment means editing `name`.
59
59
  Each combination of `[params]` runs `repeats` times, round-robin. Only runs
60
60
  that count fill a combination, so running again fills in what failed or never
61
61
  ran.
62
+ `mode = "adhoc"` skips that: every `run` runs each combination `repeats` times
63
+ again, whatever is already on disk. The default is `mode = "fill"`.
62
64
  Each run gets a new run folder. `command` runs in a shell from the root, with
63
65
  `{run_dir}` replaced by the run folder's absolute path. Pass it to the
64
66
  script so it writes its outputs there.
65
67
  Each param is appended as `--name value`, with the name as written (use
66
68
  `source-language` for a `--source-language` flag), booleans as 0 or 1, and lists
67
69
  and tables as JSON. labnotes writes `run.json` (params, created, ended, exit
68
- code), `stdout.log` and `stderr.log` to the run folder.
70
+ code, and `invocation_id`, shared by every run one `run` command started;
71
+ query it as `run ->> 'invocation_id'`), `stdout.log` and `stderr.log` to the
72
+ run folder.
69
73
  A run counts when it exited 0 and its `run.json` doesn't hold
70
74
  `"excluded": true`, so excluding a run by hand means adding that key; the next
71
75
  `run` replaces it. Other keys you add to `run.json` are kept and show in `run`.
@@ -47,3 +47,4 @@ class ExperimentConfig:
47
47
  params: dict[str, list[object]]
48
48
  name: str | None = None
49
49
  counts: str = DEFAULT_COUNTS
50
+ mode: str = "fill"
@@ -15,9 +15,11 @@ async def execute_run(
15
15
  params: dict[str, object],
16
16
  running: set[asyncio.subprocess.Process],
17
17
  stopping: asyncio.Event,
18
+ invocation: str,
18
19
  ) -> dict[str, object]:
19
20
  run: dict[str, object] = {
20
21
  "params": params,
22
+ "invocation_id": invocation,
21
23
  "created": iso_time(time.time()),
22
24
  "ended": None,
23
25
  "exit_code": None,
@@ -0,0 +1,15 @@
1
+ import fcntl
2
+ import os
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from pathlib import Path
6
+
7
+
8
+ @contextmanager
9
+ def hold_folder(folder: Path) -> Iterator[None]:
10
+ descriptor = os.open(folder, os.O_RDONLY)
11
+ try:
12
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
13
+ yield
14
+ finally:
15
+ os.close(descriptor)
@@ -0,0 +1,6 @@
1
+ import hashlib
2
+ import time
3
+
4
+
5
+ def new_invocation_id() -> str:
6
+ return hashlib.sha256(str(time.time_ns()).encode()).hexdigest()[:8]
@@ -5,7 +5,8 @@ from pathlib import Path
5
5
  from labnotes.default_counts import DEFAULT_COUNTS
6
6
  from labnotes.models import ExperimentConfig
7
7
 
8
- SETTINGS = {"name", "command", "repeats", "params", "counts"}
8
+ SETTINGS = {"name", "command", "repeats", "params", "counts", "mode"}
9
+ MODES = ("fill", "adhoc")
9
10
 
10
11
 
11
12
  def read_experiment_config(path: Path) -> ExperimentConfig:
@@ -39,4 +40,7 @@ def read_experiment_config(path: Path) -> ExperimentConfig:
39
40
  counts = values.get("counts", DEFAULT_COUNTS)
40
41
  if not isinstance(counts, str) or not counts.strip():
41
42
  raise ValueError(f"{path}: counts must be a SQL condition")
42
- return ExperimentConfig(command, repeats, params, name, counts)
43
+ mode = values.get("mode", "fill")
44
+ if mode not in MODES:
45
+ raise ValueError(f"{path}: mode must be fill or adhoc")
46
+ return ExperimentConfig(command, repeats, params, name, counts, mode)
@@ -12,6 +12,7 @@ from labnotes.models import (
12
12
  Report,
13
13
  )
14
14
  from labnotes.runner.execute_run import execute_run
15
+ from labnotes.runner.new_invocation_id import new_invocation_id
15
16
  from labnotes.runner.new_run_id import new_run_id
16
17
  from labnotes.runner.param_grid import param_grid
17
18
  from labnotes.runner.plan_runs import plan_runs
@@ -30,8 +31,12 @@ async def run_experiment(
30
31
  report: Report,
31
32
  listen: Listen,
32
33
  ) -> list[dict[str, object]]:
33
- existing = await list_experiment_runs(open_tables(config), name, experiment.counts)
34
- finished = [json.loads(str(run["params"])) for run in existing if run["counted"]]
34
+ finished: list[dict[str, object]] = []
35
+ match experiment.mode:
36
+ case "fill":
37
+ tables = open_tables(config)
38
+ existing = await list_experiment_runs(tables, name, experiment.counts)
39
+ finished = [json.loads(str(r["params"])) for r in existing if r["counted"]]
35
40
  planned: set[str] = set()
36
41
 
37
42
  def taken(run: str) -> bool:
@@ -39,6 +44,7 @@ async def run_experiment(
39
44
  run in planned or run_folder(config.root, config.runs, name, run).exists()
40
45
  )
41
46
 
47
+ invocation = new_invocation_id()
42
48
  plan: list[Entry] = []
43
49
  for params in plan_runs(
44
50
  param_grid(experiment.params), experiment.repeats, finished
@@ -163,7 +169,13 @@ async def run_experiment(
163
169
  report({"event": "started", "run": run, "params": params})
164
170
  running[run] = set()
165
171
  result = await execute_run(
166
- config.root, folder, experiment.command, params, running[run], stopping
172
+ config.root,
173
+ folder,
174
+ experiment.command,
175
+ params,
176
+ running[run],
177
+ stopping,
178
+ invocation,
167
179
  )
168
180
  del running[run]
169
181
  report({"event": "ended", "run": run, **result})
@@ -0,0 +1,7 @@
1
+ import posixpath
2
+ from pathlib import Path
3
+
4
+
5
+ def runs_parent(root: Path, template: str, experiment: str) -> Path:
6
+ head = template.replace("{experiment}", experiment).split("{run}")[0]
7
+ return root / posixpath.dirname(head)
@@ -2,12 +2,11 @@ from collections.abc import Callable
2
2
  from functools import partial
3
3
  from pathlib import Path
4
4
 
5
- from filelock import FileLock, Timeout
6
-
7
5
  from labnotes.models import LabnotesConfig, Start
8
- from labnotes.runner.lock_path import lock_path
6
+ from labnotes.runner.hold_folder import hold_folder
9
7
  from labnotes.runner.read_experiment_config import read_experiment_config
10
8
  from labnotes.runner.run_experiment import run_experiment
9
+ from labnotes.runner.runs_parent import runs_parent
11
10
 
12
11
 
13
12
  def start_experiment(
@@ -23,11 +22,11 @@ def start_experiment(
23
22
  if not experiment_config.is_file():
24
23
  raise ValueError(f"No experiment.toml for {name} at {experiment_config}.")
25
24
  experiment = read_experiment_config(experiment_config)
26
- lock = lock_path(config.root, config.runs, name)
27
- lock.parent.mkdir(parents=True, exist_ok=True)
25
+ runs = runs_parent(config.root, config.runs, name)
26
+ runs.mkdir(parents=True, exist_ok=True)
28
27
  try:
29
- with FileLock(lock, blocking=False):
28
+ with hold_folder(runs):
30
29
  start = partial(run_experiment, config, name, experiment, concurrency)
31
30
  return drive(start, experiment.name or name)
32
- except Timeout as error:
33
- raise ValueError(f"{name} is already running ({lock} is held).") from error
31
+ except BlockingIOError as error:
32
+ raise ValueError(f"{name} is already running ({runs} is held).") from error
@@ -0,0 +1,5 @@
1
+ [[python.exempt]]
2
+ path = "src/labnotes/runner/hold_folder.py"
3
+ rules = ["mutation"]
4
+ lines = [12]
5
+ reason = "LOCK_EX and LOCK_NB are disjoint bits, so | equals + and ^"
@@ -273,15 +273,6 @@ wheels = [
273
273
  { url = "https://files.pythonhosted.org/packages/4d/dd/8bc67e7d5ffc1d88aa5af511189dfb76dc4e1e5b808fab186146ef2663e5/executing-2.3.0-py3-none-any.whl", hash = "sha256:736e859c9f8701f11fcf516856f26f562e04776387824b43a35a1dfe21c84122", size = 29393, upload-time = "2026-10-10T14:06:02.777Z" },
274
274
  ]
275
275
 
276
- [[package]]
277
- name = "filelock"
278
- version = "4.2.0"
279
- source = { registry = "https://pypi.org/simple" }
280
- sdist = { url = "https://files.pythonhosted.org/packages/f4/a9/1af41b37c3279712b22cdc63aac78a52432202b6fe1f9666a2a3d2831fb4/filelock-4.2.0.tar.gz", hash = "sha256:7a60906c75227cf04d0c273afadc8219400f11aeb13cc69591d4f6cdc6c8036e", size = 569667, upload-time = "2026-10-14T20:57:13.11Z" }
281
- wheels = [
282
- { url = "https://files.pythonhosted.org/packages/8e/a3/9bc26acff301fe1aaea1cc3d82a1d57e0a34df3e1cadbfa91ac2dbcdde5c/filelock-4.2.0-py3-none-any.whl", hash = "sha256:2ff5690882e8cdb00ef31fb3d01a3094c29f30985426c59495afb1733f3b7238", size = 135032, upload-time = "2026-10-14T20:57:11.349Z" },
283
- ]
284
-
285
276
  [[package]]
286
277
  name = "h11"
287
278
  version = "0.16.0"
@@ -473,7 +464,6 @@ source = { editable = "." }
473
464
  dependencies = [
474
465
  { name = "click" },
475
466
  { name = "dirsql" },
476
- { name = "filelock" },
477
467
  { name = "haikunator" },
478
468
  { name = "ipykernel" },
479
469
  { name = "jinja2" },
@@ -497,7 +487,6 @@ dev = [
497
487
  requires-dist = [
498
488
  { name = "click", specifier = ">=8.5.0" },
499
489
  { name = "dirsql", specifier = ">=0.4.110" },
500
- { name = "filelock", specifier = ">=4.2.0" },
501
490
  { name = "haikunator", specifier = ">=2.1.0" },
502
491
  { name = "ipykernel", specifier = ">=7.4.0" },
503
492
  { name = "jinja2", specifier = ">=3.1.6" },
@@ -1,7 +0,0 @@
1
- import posixpath
2
- from pathlib import Path
3
-
4
-
5
- def lock_path(root: Path, template: str, experiment: str) -> Path:
6
- head = template.replace("{experiment}", experiment).split("{run}")[0]
7
- return root / posixpath.dirname(head) / ".labnotes.lock"
File without changes
File without changes
File without changes
File without changes
File without changes