scriptoria 0.6.3__tar.gz → 0.7.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.
Files changed (50) hide show
  1. {scriptoria-0.6.3 → scriptoria-0.7.0}/PKG-INFO +1 -1
  2. {scriptoria-0.6.3 → scriptoria-0.7.0}/pyproject.toml +1 -1
  3. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/__init__.py +1 -1
  4. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/blocks.py +3 -1
  5. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/facts.py +5 -1
  6. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/lock.py +91 -38
  7. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/conftest.py +10 -0
  8. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_blocks.py +11 -0
  9. scriptoria-0.7.0/tests/test_facts.py +34 -0
  10. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_lock.py +87 -0
  11. {scriptoria-0.6.3 → scriptoria-0.7.0}/uv.lock +1 -1
  12. {scriptoria-0.6.3 → scriptoria-0.7.0}/.gitignore +0 -0
  13. {scriptoria-0.6.3 → scriptoria-0.7.0}/README.md +0 -0
  14. {scriptoria-0.6.3 → scriptoria-0.7.0}/pyrightconfig.json +0 -0
  15. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/anchors.py +0 -0
  16. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/cli.py +0 -0
  17. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/embeddings.py +0 -0
  18. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/errors.py +0 -0
  19. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/frontmatter.py +0 -0
  20. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/graph.py +0 -0
  21. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/hashing.py +0 -0
  22. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/ingest.py +0 -0
  23. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/manifest.py +0 -0
  24. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/pageindex_adapter.py +0 -0
  25. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/query.py +0 -0
  26. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/retrieval.py +0 -0
  27. {scriptoria-0.6.3 → scriptoria-0.7.0}/src/scrip/similar.py +0 -0
  28. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_anchor_cmd.py +0 -0
  29. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_anchors.py +0 -0
  30. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_embeddings.py +0 -0
  31. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_fact_cmd.py +0 -0
  32. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_frontmatter.py +0 -0
  33. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_graph_status.py +0 -0
  34. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_hashing.py +0 -0
  35. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_index_cmd.py +0 -0
  36. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_ingest.py +0 -0
  37. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_json_shapes.py +0 -0
  38. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_manifest.py +0 -0
  39. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_new_cmd.py +0 -0
  40. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_pageindex_cmd.py +0 -0
  41. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_query.py +0 -0
  42. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_retrieval.py +0 -0
  43. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_similar_cmd.py +0 -0
  44. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_span_cmd.py +0 -0
  45. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_stamp.py +0 -0
  46. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_status_cmd.py +0 -0
  47. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_unlock_cmd.py +0 -0
  48. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_verify.py +0 -0
  49. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_version.py +0 -0
  50. {scriptoria-0.6.3 → scriptoria-0.7.0}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptoria
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base
5
5
  Project-URL: Homepage, https://github.com/coredipper/scriptorium
6
6
  Project-URL: Changelog, https://github.com/coredipper/scriptorium/blob/main/CHANGELOG.md
@@ -2,7 +2,7 @@
2
2
  # Distribution name on PyPI is `scriptoria` (scrip/scriptorium were taken); the
3
3
  # CLI command and the import package both remain `scrip`.
4
4
  name = "scriptoria"
5
- version = "0.6.3"
5
+ version = "0.7.0"
6
6
  description = "Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base"
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.10"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from pathlib import Path
15
15
 
16
- __version__ = "0.6.3"
16
+ __version__ = "0.7.0"
17
17
 
18
18
  # --- canonical vault layout ------------------------------------------------
19
19
  # ``root`` is the repo/instance root: the directory containing ``vault/``.
@@ -35,7 +35,9 @@ def _is_blank(line: str) -> bool:
35
35
 
36
36
  def split_blocks(text: str) -> list[dict]:
37
37
  """Return a list of ``{"block_id", "span": [start, end], "hash"}``."""
38
- # ⚡ Bolt: Single pass block segmentation to avoid intermediate O(N) allocation
38
+ # Use splitlines(keepends=True): its boundary set (form feed, NEL, U+2028/9,
39
+ # …) is part of the deterministic segmentation contract — io.StringIO only
40
+ # splits on \r/\n and would re-hash existing blocks that contain those chars.
39
41
  ranges: list[list[int]] = [] # [start, end] per block
40
42
  cur: list[int] | None = None
41
43
  start = 0
@@ -77,7 +77,11 @@ def parse_ndjson(text: str) -> list[dict]:
77
77
  """Parse proposed records (one JSON object per line). Malformed input is a
78
78
  :class:`DataError` with its line number; an empty input is a usage error."""
79
79
  records: list[dict] = []
80
- for lineno, raw_line in enumerate(text.splitlines(), start=1):
80
+ # Split on "\n" only, not str.splitlines(): NDJSON records are newline-
81
+ # delimited and may legally contain U+2028/U+2029/NEL inside a JSON string,
82
+ # which splitlines() would wrongly treat as record breaks. trailing \r (from
83
+ # \r\n) and the trailing empty element from a final newline are dropped below.
84
+ for lineno, raw_line in enumerate(text.split("\n"), start=1):
81
85
  line = raw_line.strip()
82
86
  if not line:
83
87
  continue
@@ -8,14 +8,18 @@ writers don't interleave stamps and edits. Reads (``status``, ``verify``,
8
8
 
9
9
  The lock is a small JSON file created atomically with ``O_CREAT|O_EXCL`` holding
10
10
  ``{pid, host, acquired_at}``. A lock whose holder is a dead process *on this
11
- host* is **stale** and is reclaimed automatically on acquire; a lock that looks
12
- live fails fast (exit 2) and points the user at ``scrip unlock``. A lock from
13
- another host is treated as live (we can't prove the process dead).
11
+ host* is **stale** and is reclaimed automatically on acquire. A lock that looks
12
+ live is **waited on**: ``acquire`` polls (with backoff) up to a timeout —
13
+ ``SCRIP_LOCK_TIMEOUT`` seconds, default 10 — so two agents serialize cooperatively
14
+ rather than one failing immediately. If the lock is still held when the wait
15
+ elapses it fails (exit 2) and points the user at ``scrip unlock``; ``timeout=0``
16
+ keeps the old fail-fast behavior. A lock from another host is treated as live (we
17
+ can't prove the process dead), so it is waited on but never reclaimed.
14
18
 
15
19
  It is advisory, not a kernel mutex: a tiny TOCTOU window remains when reclaiming
16
- a stale lock. That is acceptable for the single-machine, single-agent workflow
17
- this guards; it is a guardrail against accidental concurrent writes, not a
18
- distributed lock manager.
20
+ a stale lock, and waiting is best-effort polling, not a fair queue. That is
21
+ acceptable for the single-machine workflow this guards; it is a guardrail against
22
+ accidental concurrent writes, not a distributed lock manager.
19
23
  """
20
24
 
21
25
  from __future__ import annotations
@@ -24,6 +28,7 @@ import json
24
28
  import os
25
29
  import socket
26
30
  import tempfile
31
+ import time
27
32
  from contextlib import contextmanager
28
33
  from datetime import datetime, timezone
29
34
  from pathlib import Path
@@ -31,6 +36,31 @@ from pathlib import Path
31
36
  from . import lock_path
32
37
  from .errors import LockError, UsageError
33
38
 
39
+ # How long ``acquire`` waits for a busy lock before giving up (seconds). The default
40
+ # is cooperative: concurrent writers serialize rather than failing fast. ``0`` keeps
41
+ # the old fail-fast behavior; ``SCRIP_LOCK_TIMEOUT`` overrides it. The poll interval
42
+ # backs off from _POLL_MIN to _POLL_MAX so a freed lock is taken promptly without
43
+ # busy-spinning.
44
+ _DEFAULT_LOCK_TIMEOUT = 10.0
45
+ _POLL_MIN = 0.025
46
+ _POLL_MAX = 0.25
47
+
48
+
49
+ def _lock_timeout(explicit: float | None) -> float:
50
+ """Seconds ``acquire`` should wait for a busy lock. An explicit argument wins;
51
+ otherwise read ``SCRIP_LOCK_TIMEOUT`` (default :data:`_DEFAULT_LOCK_TIMEOUT`). A
52
+ missing or unparseable env value falls back to the default; negatives clamp to 0
53
+ (fail fast)."""
54
+ if explicit is not None:
55
+ return max(0.0, explicit)
56
+ raw = os.environ.get("SCRIP_LOCK_TIMEOUT")
57
+ if raw is None or not raw.strip():
58
+ return _DEFAULT_LOCK_TIMEOUT
59
+ try:
60
+ return max(0.0, float(raw))
61
+ except ValueError:
62
+ return _DEFAULT_LOCK_TIMEOUT
63
+
34
64
 
35
65
  def _now() -> str:
36
66
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -129,45 +159,68 @@ def _create(path: Path, payload: bytes) -> None:
129
159
  pass
130
160
 
131
161
 
132
- def acquire(root: Path) -> dict:
162
+ def acquire(root: Path, *, timeout: float | None = None) -> dict:
133
163
  """Take the lock, returning the holder info. Reclaims a *provably-dead* lock
134
- once; raises :class:`LockError` (exit 2) if the lock looks live or is being
135
- created by another writer."""
164
+ immediately. If the lock looks live (or is mid-creation by another writer),
165
+ *waits* — polling with backoff — up to ``timeout`` seconds for it to free, then
166
+ raises :class:`LockError` (exit 2) if it is still held. ``timeout`` defaults to
167
+ ``SCRIP_LOCK_TIMEOUT`` (``10`` s); ``timeout=0`` fails fast (the old behavior).
168
+ A holder that dies *during* the wait is reclaimed on the next poll."""
136
169
  p = lock_path(root)
137
170
  p.parent.mkdir(parents=True, exist_ok=True)
138
171
  info = _holder_info()
139
172
  payload = (json.dumps(info, ensure_ascii=False) + "\n").encode("utf-8")
140
- try:
141
- _create(p, payload)
142
- return info
143
- except FileExistsError:
144
- pass
173
+ wait = _lock_timeout(timeout)
174
+ deadline = time.monotonic() + wait
175
+ poll = _POLL_MIN
145
176
 
146
- existing = _read(p)
147
- if not _reclaimable(existing):
148
- if existing is None:
149
- raise LockError(
150
- ".kb/lock is held but not yet readable (another writer may be "
151
- "acquiring it). Retry; run `scrip unlock --force` if it is stuck."
152
- ) from None
153
- raise LockError(
154
- f"vault is locked by another writer ({_describe(existing)}). Wait for "
155
- f"it to finish, or run `scrip unlock --force` if it is stuck."
156
- ) from None
177
+ def _try_take() -> bool:
178
+ """One acquisition attempt: True if we now hold the lock. A provably-dead
179
+ lock on this host is reclaimed and retried once; otherwise we don't hold it."""
180
+ try:
181
+ _create(p, payload)
182
+ return True
183
+ except FileExistsError:
184
+ pass
185
+ if _reclaimable(_read(p)):
186
+ try:
187
+ p.unlink()
188
+ except FileNotFoundError:
189
+ pass
190
+ try:
191
+ _create(p, payload)
192
+ return True
193
+ except FileExistsError:
194
+ pass # raced with another writer; fall through to wait/retry
195
+ return False
157
196
 
158
- # Reclaim a clearly-dead lock and retry once.
159
- try:
160
- p.unlink()
161
- except FileNotFoundError:
162
- pass
163
- try:
164
- _create(p, payload)
165
- except FileExistsError:
197
+ # Always attempt once (so a free lock is taken even at timeout=0), then poll —
198
+ # re-checking the deadline both before sleeping and again after, so we never
199
+ # retry (and grab) a lock that freed only once the budget was already spent.
200
+ if _try_take():
201
+ return info
202
+ while True:
203
+ remaining = deadline - time.monotonic()
204
+ if remaining <= 0:
205
+ break
206
+ time.sleep(min(poll, remaining)) # remaining > 0 here, so never negative
207
+ poll = min(poll * 2, _POLL_MAX)
208
+ if time.monotonic() >= deadline:
209
+ break
210
+ if _try_take():
211
+ return info
212
+
213
+ existing = _read(p) # who holds it now, for the diagnostic
214
+ waited = f" after waiting {wait:g}s" if wait else ""
215
+ if existing is None:
166
216
  raise LockError(
167
- "could not acquire .kb/lock (raced with another writer); retry, or "
168
- "`scrip unlock` if it is stuck."
217
+ ".kb/lock is held but not yet readable (another writer may be "
218
+ f"acquiring it){waited}. Retry; run `scrip unlock --force` if it is stuck."
169
219
  ) from None
170
- return info
220
+ raise LockError(
221
+ f"vault is locked by another writer ({_describe(existing)}){waited}. "
222
+ f"Wait for it to finish, or run `scrip unlock --force` if it is stuck."
223
+ ) from None
171
224
 
172
225
 
173
226
  def release(root: Path, info: dict | None = None) -> None:
@@ -187,8 +240,8 @@ def release(root: Path, info: dict | None = None) -> None:
187
240
 
188
241
 
189
242
  @contextmanager
190
- def write_lock(root: Path):
191
- info = acquire(root)
243
+ def write_lock(root: Path, *, timeout: float | None = None):
244
+ info = acquire(root, timeout=timeout)
192
245
  try:
193
246
  yield info
194
247
  finally:
@@ -16,6 +16,16 @@ import pytest
16
16
  from scrip import anchors, blocks, frontmatter, hashing
17
17
 
18
18
 
19
+ @pytest.fixture(autouse=True)
20
+ def _fail_fast_lock(monkeypatch):
21
+ """Keep the hermetic suite fast: the write lock now *waits* for a busy lock
22
+ (``SCRIP_LOCK_TIMEOUT``, default 10s). Pin it to 0 (fail-fast) for tests so the
23
+ lock-blocked cases return immediately instead of sitting through the wait. Tests
24
+ of the waiting path pass an explicit ``timeout=`` to :func:`scrip.lock.acquire`,
25
+ which overrides this env default."""
26
+ monkeypatch.setenv("SCRIP_LOCK_TIMEOUT", "0")
27
+
28
+
19
29
  class KB:
20
30
  def __init__(self, root: Path):
21
31
  self.root = root
@@ -151,3 +151,14 @@ def test_blank_runs_are_boundaries_not_blocks():
151
151
  assert len(bs) == 1
152
152
  s, e = bs[0]["span"]
153
153
  assert "only paragraph" in "\n\nonly paragraph\n\n"[s:e]
154
+
155
+
156
+ def test_form_feed_is_a_line_boundary():
157
+ """Segmentation uses str.splitlines()'s full boundary set (form feed here,
158
+ also NEL/U+2028/U+2029). Splitting only on \\r/\\n — e.g. via io.StringIO —
159
+ would merge these into one block and change every affected block's id/hash,
160
+ breaking the deterministic-segmentation contract (SPEC §7.2)."""
161
+ text = "# Heading\x0c## Sub\n"
162
+ spans = [tuple(b["span"]) for b in blocks.split_blocks(text)]
163
+ # two separate heading blocks, split at the form feed
164
+ assert spans == [(0, 10), (10, 17)]
@@ -0,0 +1,34 @@
1
+ import pytest
2
+ from scrip.errors import DataError, UsageError
3
+
4
+ from scrip import facts
5
+
6
+
7
+ def test_parse_ndjson_basic():
8
+ text = '{"a": 1}\n{"b": 2}\n'
9
+ assert facts.parse_ndjson(text) == [{"a": 1}, {"b": 2}]
10
+
11
+
12
+ def test_parse_ndjson_skips_blank_lines_and_strips_crlf():
13
+ text = '{"a": 1}\r\n\r\n{"b": 2}\r\n'
14
+ assert facts.parse_ndjson(text) == [{"a": 1}, {"b": 2}]
15
+
16
+
17
+ def test_parse_ndjson_keeps_u2028_inside_json_string():
18
+ # U+2028 / U+2029 are legal *inside* a JSON string value. str.splitlines()
19
+ # treats them as line breaks and would split this record into invalid
20
+ # fragments; splitting on "\n" only keeps the record intact.
21
+ text = '{"a": "x
y
z"}\n{"b": 2}\n'
22
+ assert facts.parse_ndjson(text) == [{"a": "x
y
z"}, {"b": 2}]
23
+
24
+
25
+ def test_parse_ndjson_reports_line_number_on_bad_json():
26
+ text = '{"a": 1}\nnot json\n'
27
+ with pytest.raises(DataError) as exc:
28
+ facts.parse_ndjson(text)
29
+ assert "line 2" in str(exc.value)
30
+
31
+
32
+ def test_parse_ndjson_empty_is_usage_error():
33
+ with pytest.raises(UsageError):
34
+ facts.parse_ndjson(" \n \n")
@@ -6,6 +6,8 @@ import os
6
6
  import socket
7
7
  import stat
8
8
  import subprocess
9
+ import threading
10
+ import time
9
11
 
10
12
  import pytest
11
13
 
@@ -85,6 +87,91 @@ def test_release_only_removes_our_own_lock(tmp_path):
85
87
  lock_path(tmp_path).unlink()
86
88
 
87
89
 
90
+ # --- waiting (cooperative blocking acquire) ---------------------------------
91
+ def test_acquire_timeout_zero_fails_fast_on_live_lock(tmp_path):
92
+ info = lock.acquire(tmp_path, timeout=0)
93
+ try:
94
+ start = time.monotonic()
95
+ with pytest.raises(errors.LockError) as ei:
96
+ lock.acquire(tmp_path, timeout=0)
97
+ assert ei.value.exit_code == 2
98
+ assert time.monotonic() - start < 0.2 # immediate — no wait at timeout=0
99
+ finally:
100
+ lock.release(tmp_path, info)
101
+
102
+
103
+ def test_acquire_waits_for_a_busy_lock_then_succeeds(tmp_path):
104
+ # a writer holds the lock briefly; a waiting acquire grabs it once it frees,
105
+ # rather than failing fast.
106
+ info = lock.acquire(tmp_path, timeout=0)
107
+
108
+ def _release_after():
109
+ time.sleep(0.2)
110
+ lock.release(tmp_path, info)
111
+
112
+ t = threading.Thread(target=_release_after)
113
+ t.start()
114
+ try:
115
+ start = time.monotonic()
116
+ info2 = lock.acquire(tmp_path, timeout=5) # blocks ~0.2s, then acquires
117
+ waited = time.monotonic() - start
118
+ assert waited >= 0.15 # it actually waited for the release
119
+ assert json.loads(lock_path(tmp_path).read_text())["pid"] == os.getpid()
120
+ lock.release(tmp_path, info2)
121
+ finally:
122
+ t.join()
123
+
124
+
125
+ def test_acquire_times_out_while_lock_stays_held(tmp_path):
126
+ info = lock.acquire(tmp_path, timeout=0)
127
+ try:
128
+ start = time.monotonic()
129
+ with pytest.raises(errors.LockError) as ei:
130
+ lock.acquire(tmp_path, timeout=0.3)
131
+ waited = time.monotonic() - start
132
+ assert ei.value.exit_code == 2
133
+ assert 0.25 <= waited < 3.0 # waited ~the timeout, then gave up (not forever)
134
+ finally:
135
+ lock.release(tmp_path, info)
136
+
137
+
138
+ def test_acquire_does_not_grab_a_lock_freed_after_the_deadline(tmp_path, monkeypatch):
139
+ # A writer releases only once the timeout has elapsed. acquire must time out
140
+ # (exit 2), not wake from its last sleep, retry, and grab the just-freed lock —
141
+ # which would silently extend the budget past SCRIP_LOCK_TIMEOUT. Driven by a
142
+ # mocked clock so the boundary is deterministic (real-time races are not).
143
+ info = lock.acquire(tmp_path, timeout=0) # our live pid holds it
144
+ clock = {"t": 100.0}
145
+ sleeps: list[float] = []
146
+ monkeypatch.setattr(lock.time, "monotonic", lambda: clock["t"])
147
+
148
+ def fake_sleep(d):
149
+ sleeps.append(d)
150
+ clock["t"] += d
151
+ if clock["t"] >= 100.3 and lock_path(tmp_path).exists():
152
+ lock.release(tmp_path, info) # freed, but only at/after the deadline
153
+
154
+ monkeypatch.setattr(lock.time, "sleep", fake_sleep)
155
+ try:
156
+ with pytest.raises(errors.LockError) as ei:
157
+ lock.acquire(tmp_path, timeout=0.3) # deadline = 100.0 + 0.3
158
+ assert ei.value.exit_code == 2
159
+ assert all(d >= 0 for d in sleeps) # never a negative sleep duration
160
+ finally:
161
+ if lock_path(tmp_path).exists():
162
+ lock.release(tmp_path, info)
163
+
164
+
165
+ def test_lock_timeout_resolution(monkeypatch):
166
+ monkeypatch.delenv("SCRIP_LOCK_TIMEOUT", raising=False)
167
+ assert lock._lock_timeout(None) == 10.0 # default when unset
168
+ monkeypatch.setenv("SCRIP_LOCK_TIMEOUT", "3.5")
169
+ assert lock._lock_timeout(None) == 3.5 # env configures the default
170
+ assert lock._lock_timeout(0) == 0.0 # an explicit timeout wins over the env
171
+ monkeypatch.setenv("SCRIP_LOCK_TIMEOUT", "nonsense")
172
+ assert lock._lock_timeout(None) == 10.0 # a bad env value falls back to default
173
+
174
+
88
175
  # --- staleness --------------------------------------------------------------
89
176
  def test_leftover_temp_from_recycled_pid_does_not_block_acquire(tmp_path, monkeypatch):
90
177
  """A temp file left by a crashed process must not be mistaken for a held lock
@@ -1141,7 +1141,7 @@ wheels = [
1141
1141
 
1142
1142
  [[package]]
1143
1143
  name = "scriptoria"
1144
- version = "0.6.2"
1144
+ version = "0.7.0"
1145
1145
  source = { editable = "." }
1146
1146
  dependencies = [
1147
1147
  { name = "duckdb" },
File without changes
File without changes
File without changes