aurochs-core 0.1.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.
@@ -0,0 +1,73 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ cover/
51
+
52
+ # Environments
53
+ .env
54
+ .venv
55
+ env/
56
+ venv/
57
+ ENV/
58
+ env.bak/
59
+ venv.bak/
60
+
61
+ # mypy / ruff
62
+ .mypy_cache/
63
+ .ruff_cache/
64
+ .dmypy.json
65
+ dmypy.json
66
+
67
+ # IDE
68
+ .vscode/
69
+ .idea/
70
+
71
+ # OS
72
+ .DS_Store
73
+ Thumbs.db
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stefan Kovalik <stefan@aurochs.agency>
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,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: aurochs-core
3
+ Version: 0.1.0
4
+ Summary: Shared infrastructure for the Aurochs agency line plugins.
5
+ Project-URL: Homepage, https://github.com/skovalik/aurochs-core
6
+ Project-URL: Repository, https://github.com/skovalik/aurochs-core
7
+ Project-URL: Issues, https://github.com/skovalik/aurochs-core/issues
8
+ Author-email: Stefan Kovalik <stefan@aurochs.agency>
9
+ Maintainer-email: Stefan Kovalik <stefan@aurochs.agency>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: advisory-lock,aurochs,lockfile,sqlite
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Database
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.13
24
+ Requires-Dist: psutil>=5.9
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.6; extra == 'dev'
30
+ Requires-Dist: types-psutil; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # aurochs-core
34
+
35
+ Shared infrastructure for the Aurochs agency line plugins. Not user-facing.
36
+
37
+ Ships the canonical advisory-lockfile classes (`WriteLock`, `MigrateLock`, `LockError`)
38
+ and the SQLite connection helper (`db_connect`) used by every plugin in the Aurochs
39
+ agency line (`aurochs-recall`, `aurochs-voice`, `aurochs-llm-scrub`, `aurochs-seo-geo`).
40
+
41
+ Plugins still own their own `MigrationRunner`, types, and schema modules — only the
42
+ truly shared substrate lives here.
43
+
44
+ ## Public surface
45
+
46
+ ```python
47
+ from aurochs_core import LockError, MigrateLock, WriteLock, db_connect
48
+ ```
49
+
50
+ Pragma contract applied by `db_connect()`:
51
+
52
+ - `foreign_keys = ON`
53
+ - `journal_mode = WAL`
54
+ - `synchronous = NORMAL`
55
+ - `busy_timeout = 30000` (30s)
56
+ - `row_factory = sqlite3.Row`
57
+
58
+ ## License
59
+
60
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,28 @@
1
+ # aurochs-core
2
+
3
+ Shared infrastructure for the Aurochs agency line plugins. Not user-facing.
4
+
5
+ Ships the canonical advisory-lockfile classes (`WriteLock`, `MigrateLock`, `LockError`)
6
+ and the SQLite connection helper (`db_connect`) used by every plugin in the Aurochs
7
+ agency line (`aurochs-recall`, `aurochs-voice`, `aurochs-llm-scrub`, `aurochs-seo-geo`).
8
+
9
+ Plugins still own their own `MigrationRunner`, types, and schema modules — only the
10
+ truly shared substrate lives here.
11
+
12
+ ## Public surface
13
+
14
+ ```python
15
+ from aurochs_core import LockError, MigrateLock, WriteLock, db_connect
16
+ ```
17
+
18
+ Pragma contract applied by `db_connect()`:
19
+
20
+ - `foreign_keys = ON`
21
+ - `journal_mode = WAL`
22
+ - `synchronous = NORMAL`
23
+ - `busy_timeout = 30000` (30s)
24
+ - `row_factory = sqlite3.Row`
25
+
26
+ ## License
27
+
28
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,8 @@
1
+ """aurochs-core — shared infrastructure for the Aurochs agency line."""
2
+
3
+ from aurochs_core.db import db_connect
4
+ from aurochs_core.locks import LockError, MigrateLock, WriteLock
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["LockError", "MigrateLock", "WriteLock", "db_connect"]
@@ -0,0 +1,104 @@
1
+ """Shared SQLite connection helper.
2
+
3
+ Establishes the canonical pragma contract used by every Aurochs plugin:
4
+ ``foreign_keys=ON``, ``journal_mode=WAL``, ``synchronous=NORMAL``,
5
+ ``busy_timeout=30000``, ``isolation_level=None`` (autocommit). Row
6
+ factory is ``sqlite3.Row``.
7
+
8
+ The pragmas applied here are not optional — leaving any of them off
9
+ changes correctness, not just performance:
10
+
11
+ * ``foreign_keys = ON`` — SQLite default is OFF. Without this, every
12
+ FK declared in the schema is documentation, not enforcement.
13
+ * ``journal_mode = WAL`` — concurrent readers + one writer; required
14
+ for the OS-lockfile concurrency model.
15
+ * ``busy_timeout = 30000`` — 30s cap before raising ``database is
16
+ locked``.
17
+ * ``synchronous = NORMAL`` — WAL-safe; ``FULL`` is unnecessary with
18
+ WAL and costs roughly 2x on bulk inserts.
19
+ * ``isolation_level=None`` — autocommit; the caller manages
20
+ transactions explicitly (BEGIN/COMMIT). Required for plugin
21
+ ``MigrationRunner`` paths that issue ``BEGIN EXCLUSIVE``.
22
+
23
+ NOTE: Plugins still own their own ``MigrationRunner`` — schema
24
+ migration logic varies per-plugin (different migration files,
25
+ different ``schema_version`` table ownership). This module ships only
26
+ the connection helper.
27
+
28
+ Connections are NOT thread-safe by default. Multiprocessing workers
29
+ MUST call ``db_connect()`` themselves rather than passing a connection
30
+ across the process boundary — pickling a sqlite3 Connection is not
31
+ supported.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import sqlite3
37
+ from pathlib import Path
38
+
39
+ # Pragmas applied to every connection, in order. Read by tests to
40
+ # assert the contract.
41
+ _REQUIRED_PRAGMAS: tuple[tuple[str, str], ...] = (
42
+ ("foreign_keys", "ON"),
43
+ ("journal_mode", "WAL"),
44
+ ("synchronous", "NORMAL"),
45
+ ("busy_timeout", "30000"),
46
+ )
47
+
48
+
49
+ def db_connect(db_path: Path | str) -> sqlite3.Connection:
50
+ """Open a sqlite3 Connection with the canonical Aurochs pragma contract.
51
+
52
+ Creates the database file if it does not exist (and the parent dir
53
+ if needed). The caller owns the connection — close it (or use as a
54
+ context manager) when done.
55
+
56
+ Parameters
57
+ ----------
58
+ db_path:
59
+ Path to the database file. ``":memory:"`` is supported for
60
+ tests; pass it as a literal str.
61
+
62
+ Returns
63
+ -------
64
+ sqlite3.Connection
65
+ Autocommit connection with ``Row`` row_factory and all required
66
+ pragmas applied. ``isolation_level=None`` so the caller manages
67
+ transactions explicitly (BEGIN/COMMIT) — necessary for the
68
+ EXCLUSIVE migration transactions plugins use.
69
+
70
+ Raises
71
+ ------
72
+ RuntimeError
73
+ If ``foreign_keys = ON`` did not take (some sqlite builds ignore
74
+ the pragma inside a transaction; we fail loud rather than silently
75
+ running with FKs off).
76
+ """
77
+ if isinstance(db_path, str) and db_path == ":memory:":
78
+ target: str | Path = ":memory:"
79
+ else:
80
+ target = Path(db_path)
81
+ if isinstance(target, Path):
82
+ target.parent.mkdir(parents=True, exist_ok=True)
83
+
84
+ conn = sqlite3.connect(
85
+ str(target),
86
+ isolation_level=None, # autocommit; we manage transactions manually
87
+ check_same_thread=True,
88
+ )
89
+ conn.row_factory = sqlite3.Row
90
+
91
+ for pragma, value in _REQUIRED_PRAGMAS:
92
+ conn.execute(f"PRAGMA {pragma} = {value}")
93
+
94
+ # Verify foreign_keys actually took. Some sqlite builds ignore the
95
+ # pragma inside a transaction; fail loud if it didn't.
96
+ enforced = conn.execute("PRAGMA foreign_keys").fetchone()[0]
97
+ if enforced != 1:
98
+ conn.close()
99
+ raise RuntimeError(
100
+ "PRAGMA foreign_keys = ON failed to apply. This sqlite build "
101
+ "may not support FK enforcement; refusing to continue."
102
+ )
103
+
104
+ return conn
@@ -0,0 +1,334 @@
1
+ """OS-level advisory lockfiles.
2
+
3
+ Single-writer enforcement at the OS level — not just SQLite-level. Two
4
+ distinct lockfile classes serve different concurrency contracts:
5
+
6
+ * ``WriteLock`` — held by ANY writer (indexer, extractor, taxonomy mutation).
7
+ Path: ``<db>.write.lock`` next to the database file.
8
+ * ``MigrateLock`` — held during the entire migration sequence, including
9
+ the ``schema_version`` status update. Path: ``<db>.migrate.lock``.
10
+
11
+ POSIX uses ``fcntl.flock``; Windows uses ``msvcrt.locking``. The Windows
12
+ implementation hardens against fork-inheritance leaks (``close_fds=True``,
13
+ non-inheritable handle) and ghost locks (stale-PID detection via
14
+ ``psutil.pid_exists``).
15
+
16
+ Each lockfile records the holder PID. If a process tries to acquire and
17
+ finds the lock held but the recorded PID no longer exists, the lock is
18
+ considered stale and force-released with an audit-log entry. This closes
19
+ the "user kill -9'd indexer; lock now blocks all subsequent runs" failure
20
+ mode.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+ from types import TracebackType
30
+
31
+
32
+ class LockError(RuntimeError):
33
+ """Raised when a lock cannot be acquired (held by another process)."""
34
+
35
+ def __init__(self, lockfile: Path, holder_pid: int | None) -> None:
36
+ msg = f"Lock {lockfile} is held"
37
+ if holder_pid is not None:
38
+ msg += f" by PID {holder_pid}"
39
+ super().__init__(msg)
40
+ self.lockfile = lockfile
41
+ self.holder_pid = holder_pid
42
+
43
+
44
+ def _pid_alive(pid: int) -> bool:
45
+ """Check whether a PID corresponds to a living process.
46
+
47
+ Tries ``psutil.pid_exists`` first (handles cross-platform edge cases
48
+ cleanly); falls back to ``os.kill(pid, 0)`` on POSIX and a Windows
49
+ ``OpenProcess`` probe via ctypes if psutil is unavailable.
50
+ """
51
+ try:
52
+ import psutil # type: ignore[import-not-found, unused-ignore]
53
+
54
+ return bool(psutil.pid_exists(pid))
55
+ except ImportError:
56
+ pass
57
+
58
+ if sys.platform == "win32":
59
+ # Best-effort ctypes probe so the spine remains importable without
60
+ # psutil installed.
61
+ try:
62
+ import ctypes
63
+ from ctypes import wintypes
64
+
65
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
66
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined, unused-ignore]
67
+ kernel32.OpenProcess.argtypes = (
68
+ wintypes.DWORD,
69
+ wintypes.BOOL,
70
+ wintypes.DWORD,
71
+ )
72
+ kernel32.OpenProcess.restype = wintypes.HANDLE
73
+ handle = kernel32.OpenProcess(
74
+ PROCESS_QUERY_LIMITED_INFORMATION, False, pid
75
+ )
76
+ if not handle:
77
+ return False
78
+ kernel32.CloseHandle(handle)
79
+ return True
80
+ except Exception:
81
+ # Conservative default: assume alive so we don't steal a
82
+ # legitimately held lock.
83
+ return True
84
+ try:
85
+ os.kill(pid, 0)
86
+ return True
87
+ except (ProcessLookupError, PermissionError):
88
+ # PermissionError means the PID exists but isn't ours — still alive.
89
+ return isinstance(sys.exc_info()[1], PermissionError)
90
+ except OSError:
91
+ return False
92
+
93
+
94
+ class _LockfileBase:
95
+ """Shared behavior for advisory lockfiles.
96
+
97
+ Subclasses set ``suffix`` (e.g. ``".write.lock"``). The concrete
98
+ acquire/release implementation differs by platform.
99
+ """
100
+
101
+ suffix: str = ".lock"
102
+
103
+ def __init__(self, db_path: Path | str, *, timeout: float = 0.0) -> None:
104
+ """Construct a lock pointing at ``{db_path}{suffix}``.
105
+
106
+ ``timeout`` is the number of seconds to wait for the lock before
107
+ raising ``LockError``. ``0`` (default) = fail fast.
108
+ """
109
+ db = Path(db_path)
110
+ self.path: Path = db.with_name(db.name + self.suffix)
111
+ self.timeout: float = float(timeout)
112
+ self._fd: int | None = None
113
+ self._acquired: bool = False
114
+
115
+ # ----- subclass hooks -------------------------------------------------
116
+
117
+ def _try_lock(self, fd: int) -> bool:
118
+ """Attempt to lock ``fd`` non-blocking. Return True on success."""
119
+ raise NotImplementedError
120
+
121
+ def _unlock(self, fd: int) -> None:
122
+ """Release the OS-level lock on ``fd``."""
123
+ raise NotImplementedError
124
+
125
+ # ----- public API ----------------------------------------------------
126
+
127
+ def acquire(self) -> None:
128
+ """Acquire the lock, retrying up to ``timeout`` seconds.
129
+
130
+ On timeout, raises ``LockError``. If the holder PID recorded in the
131
+ lockfile is dead, the lock is force-released first (stale-PID
132
+ detection).
133
+ """
134
+ if self._acquired:
135
+ raise RuntimeError("Lock already acquired by this object")
136
+
137
+ deadline = time.monotonic() + self.timeout
138
+ while True:
139
+ fd = self._open_lockfile()
140
+ if self._try_lock(fd):
141
+ # Got it. Stamp our PID into the file and remember it.
142
+ self._fd = fd
143
+ self._write_pid(fd)
144
+ self._acquired = True
145
+ return
146
+
147
+ # Failed to lock — peek at the recorded PID to decide stale-vs-live.
148
+ holder_pid = self._read_pid_from_path()
149
+ os.close(fd)
150
+
151
+ if holder_pid is not None and not _pid_alive(holder_pid):
152
+ # Stale lock. Force-release and retry once. The retry path
153
+ # only runs once because we can't be unlucky-stale twice.
154
+ self._force_release_stale(holder_pid)
155
+ continue
156
+
157
+ if time.monotonic() >= deadline:
158
+ raise LockError(self.path, holder_pid)
159
+ time.sleep(0.1)
160
+
161
+ def release(self) -> None:
162
+ """Release the lock. Safe to call even if not acquired.
163
+
164
+ On POSIX the lockfile is unlinked after release. On Windows, msvcrt
165
+ keeps an exclusive byte-range lock that prevents unlink even after
166
+ the FD is closed in some sharing modes — so we leave the file in
167
+ place. The next acquire will reuse it. The PID stamping inside the
168
+ file means stale-lock detection still works across runs.
169
+ """
170
+ if self._fd is not None:
171
+ try:
172
+ self._unlock(self._fd)
173
+ finally:
174
+ try:
175
+ os.close(self._fd)
176
+ except OSError:
177
+ pass
178
+ self._fd = None
179
+ self._acquired = False
180
+ # Best-effort lockfile removal — only on POSIX. On Windows leaving
181
+ # the pid-file alone is the safer behavior.
182
+ if sys.platform != "win32":
183
+ try:
184
+ self.path.unlink(missing_ok=True)
185
+ except OSError:
186
+ pass
187
+
188
+ # ----- helpers -------------------------------------------------------
189
+
190
+ def _open_lockfile(self) -> int:
191
+ """Open the lockfile, creating it if needed, with non-inheritable FD."""
192
+ # O_CLOEXEC: don't leak the FD to child processes (subprocess /
193
+ # multiprocessing). On Windows there's no O_CLOEXEC but we set
194
+ # the inheritable flag explicitly below.
195
+ flags = os.O_RDWR | os.O_CREAT
196
+ if hasattr(os, "O_CLOEXEC"):
197
+ flags |= os.O_CLOEXEC
198
+
199
+ self.path.parent.mkdir(parents=True, exist_ok=True)
200
+ fd = os.open(str(self.path), flags, 0o644)
201
+ # Belt-and-braces on Windows where O_CLOEXEC isn't available.
202
+ try:
203
+ os.set_inheritable(fd, False)
204
+ except (OSError, AttributeError):
205
+ pass
206
+ return fd
207
+
208
+ def _write_pid(self, fd: int) -> None:
209
+ """Stamp the current PID into the lockfile body."""
210
+ try:
211
+ os.lseek(fd, 0, os.SEEK_SET)
212
+ os.ftruncate(fd, 0)
213
+ os.write(fd, str(os.getpid()).encode("ascii"))
214
+ try:
215
+ os.fsync(fd)
216
+ except OSError:
217
+ pass
218
+ except OSError:
219
+ # PID-stamping is best-effort. If it fails the lock is still
220
+ # held; we just lose stale-detection on the next attempt.
221
+ pass
222
+
223
+ def _read_pid_from_path(self) -> int | None:
224
+ """Read the recorded PID from the lockfile body, if any."""
225
+ try:
226
+ raw = self.path.read_bytes().strip()
227
+ except OSError:
228
+ return None
229
+ if not raw:
230
+ return None
231
+ try:
232
+ return int(raw)
233
+ except ValueError:
234
+ return None
235
+
236
+ def _force_release_stale(self, dead_pid: int) -> None:
237
+ """Remove a stale lockfile whose holder PID is gone."""
238
+ # Best-effort. A concurrent acquirer might be racing us; if so the
239
+ # next acquire-attempt will simply fail and retry.
240
+ try:
241
+ self.path.unlink(missing_ok=True)
242
+ except OSError:
243
+ pass
244
+
245
+ # ----- context manager -----------------------------------------------
246
+
247
+ def __enter__(self) -> _LockfileBase:
248
+ self.acquire()
249
+ return self
250
+
251
+ def __exit__(
252
+ self,
253
+ exc_type: type[BaseException] | None,
254
+ exc: BaseException | None,
255
+ tb: TracebackType | None,
256
+ ) -> None:
257
+ self.release()
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Platform-specific implementations
262
+ # ---------------------------------------------------------------------------
263
+
264
+ if sys.platform == "win32":
265
+ import msvcrt
266
+
267
+ class _WindowsLockMixin(_LockfileBase):
268
+ def _try_lock(self, fd: int) -> bool:
269
+ try:
270
+ # LK_NBLCK: non-blocking lock of the first byte. msvcrt
271
+ # locks ranges, not the whole file, but byte 0 is enough
272
+ # for advisory mutual exclusion.
273
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) # type: ignore[attr-defined, unused-ignore]
274
+ return True
275
+ except OSError:
276
+ return False
277
+
278
+ def _unlock(self, fd: int) -> None:
279
+ try:
280
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined, unused-ignore]
281
+ except OSError:
282
+ # Already released or process dying; no recourse.
283
+ pass
284
+
285
+ _PlatformLock = _WindowsLockMixin
286
+
287
+ else:
288
+ import fcntl
289
+
290
+ class _PosixLockMixin(_LockfileBase):
291
+ def _try_lock(self, fd: int) -> bool:
292
+ try:
293
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
294
+ return True
295
+ except OSError:
296
+ return False
297
+
298
+ def _unlock(self, fd: int) -> None:
299
+ try:
300
+ fcntl.flock(fd, fcntl.LOCK_UN)
301
+ except OSError:
302
+ pass
303
+
304
+ _PlatformLock = _PosixLockMixin # type: ignore[misc, assignment]
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Public lock classes
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ class WriteLock(_PlatformLock):
313
+ """Single-writer advisory lock — held by any process that writes
314
+ plugin-owned state (drawers, extractions, taxonomy mutations).
315
+
316
+ Usage::
317
+
318
+ from aurochs_core import WriteLock
319
+
320
+ with WriteLock(db_path, timeout=30):
321
+ # do writes
322
+ ...
323
+ """
324
+
325
+ suffix = ".write.lock"
326
+
327
+
328
+ class MigrateLock(_PlatformLock):
329
+ """Migration advisory lock — held across an entire migration sequence,
330
+ including the ``schema_version`` status update. Distinct from
331
+ ``WriteLock`` so writers and migrators can be diagnosed independently.
332
+ """
333
+
334
+ suffix = ".migrate.lock"
@@ -0,0 +1,178 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aurochs-core"
7
+ version = "0.1.0"
8
+ description = "Shared infrastructure for the Aurochs agency line plugins."
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Stefan Kovalik", email = "stefan@aurochs.agency" },
15
+ ]
16
+ maintainers = [
17
+ { name = "Stefan Kovalik", email = "stefan@aurochs.agency" },
18
+ ]
19
+ keywords = [
20
+ "sqlite",
21
+ "lockfile",
22
+ "advisory-lock",
23
+ "aurochs",
24
+ ]
25
+ classifiers = [
26
+ "Development Status :: 3 - Alpha",
27
+ "Intended Audience :: Developers",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Operating System :: MacOS",
30
+ "Operating System :: Microsoft :: Windows",
31
+ "Operating System :: POSIX :: Linux",
32
+ "Programming Language :: Python :: 3",
33
+ "Programming Language :: Python :: 3.13",
34
+ "Topic :: Database",
35
+ "Topic :: Software Development :: Libraries :: Python Modules",
36
+ ]
37
+ dependencies = [
38
+ "psutil>=5.9",
39
+ ]
40
+
41
+ [project.optional-dependencies]
42
+ dev = [
43
+ "pytest>=8.0",
44
+ "pytest-cov>=5.0",
45
+ "ruff>=0.6",
46
+ "mypy>=1.10",
47
+ "types-psutil",
48
+ ]
49
+
50
+ [project.urls]
51
+ Homepage = "https://github.com/skovalik/aurochs-core"
52
+ Repository = "https://github.com/skovalik/aurochs-core"
53
+ Issues = "https://github.com/skovalik/aurochs-core/issues"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["aurochs_core"]
57
+
58
+ [tool.hatch.build.targets.sdist]
59
+ include = [
60
+ "/aurochs_core",
61
+ "/tests",
62
+ "/README.md",
63
+ "/LICENSE",
64
+ "/pyproject.toml",
65
+ ]
66
+
67
+ # ---- ruff -------------------------------------------------------------------
68
+
69
+ [tool.ruff]
70
+ target-version = "py313"
71
+ line-length = 100
72
+ extend-exclude = [
73
+ "build",
74
+ "dist",
75
+ ".venv",
76
+ "venv",
77
+ ]
78
+
79
+ [tool.ruff.lint]
80
+ select = [
81
+ "E", # pycodestyle errors
82
+ "W", # pycodestyle warnings
83
+ "F", # pyflakes
84
+ "I", # isort
85
+ "N", # pep8-naming
86
+ "UP", # pyupgrade
87
+ "B", # flake8-bugbear
88
+ "C4", # flake8-comprehensions
89
+ "SIM", # flake8-simplify
90
+ "RUF", # ruff-specific
91
+ ]
92
+ ignore = [
93
+ "E501", # line length (handled by formatter)
94
+ ]
95
+
96
+ [tool.ruff.lint.per-file-ignores]
97
+ "tests/**/*.py" = ["N802", "N806", "N803"]
98
+ # locks.py vendors a platform-shim with intentional try/except/pass blocks
99
+ # (best-effort cleanup paths where contextlib.suppress would change perf
100
+ # semantics) and a Windows constant in PEP 8-violating SHOUT_CASE form to
101
+ # match the Win32 API name. This matches the vendored copies across the
102
+ # Aurochs plugin line.
103
+ "aurochs_core/locks.py" = ["N806", "SIM105"]
104
+
105
+ [tool.ruff.format]
106
+ quote-style = "double"
107
+ indent-style = "space"
108
+ line-ending = "lf"
109
+
110
+ # ---- mypy -------------------------------------------------------------------
111
+
112
+ [tool.mypy]
113
+ python_version = "3.13"
114
+ strict = true
115
+ warn_unused_configs = true
116
+ warn_redundant_casts = true
117
+ warn_unused_ignores = true
118
+ disallow_untyped_defs = true
119
+ disallow_incomplete_defs = true
120
+ check_untyped_defs = true
121
+ no_implicit_optional = true
122
+ warn_return_any = true
123
+ exclude = [
124
+ "build/",
125
+ "dist/",
126
+ ]
127
+
128
+ [[tool.mypy.overrides]]
129
+ module = [
130
+ "psutil.*",
131
+ ]
132
+ ignore_missing_imports = true
133
+
134
+ # ---- pytest -----------------------------------------------------------------
135
+
136
+ [tool.pytest.ini_options]
137
+ minversion = "8.0"
138
+ testpaths = ["tests"]
139
+ python_files = ["test_*.py", "*_test.py"]
140
+ python_classes = ["Test*"]
141
+ python_functions = ["test_*"]
142
+ addopts = [
143
+ "-ra",
144
+ "--strict-markers",
145
+ "--strict-config",
146
+ "--showlocals",
147
+ "--tb=short",
148
+ ]
149
+ markers = [
150
+ "unit: unit tests, no I/O",
151
+ "integration: integration tests with sqlite",
152
+ "windows: windows-specific tests",
153
+ "macos: macos-specific tests",
154
+ "linux: linux-specific tests",
155
+ ]
156
+ filterwarnings = [
157
+ "error",
158
+ "ignore::DeprecationWarning:pkg_resources.*",
159
+ ]
160
+
161
+ # ---- coverage ---------------------------------------------------------------
162
+
163
+ [tool.coverage.run]
164
+ branch = true
165
+ source = ["aurochs_core"]
166
+ omit = [
167
+ "*/tests/*",
168
+ ]
169
+
170
+ [tool.coverage.report]
171
+ exclude_lines = [
172
+ "pragma: no cover",
173
+ "raise NotImplementedError",
174
+ "if TYPE_CHECKING:",
175
+ "if __name__ == .__main__.:",
176
+ ]
177
+ show_missing = true
178
+ skip_covered = false
File without changes
@@ -0,0 +1,159 @@
1
+ """Unit tests for db_connect — pragma contract.
2
+
3
+ The shared db_connect helper applies the canonical Aurochs pragma
4
+ contract to every connection. These tests assert each pragma is set
5
+ exactly as documented; deviation is a regression that affects every
6
+ plugin downstream.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sqlite3
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ from aurochs_core import db_connect
17
+
18
+
19
+ class TestPragmas:
20
+ def test_foreign_keys_pragma_on(self, tmp_path: Path) -> None:
21
+ db = tmp_path / "test.db"
22
+ conn = db_connect(db)
23
+ try:
24
+ result = conn.execute("PRAGMA foreign_keys").fetchone()[0]
25
+ assert result == 1, "PRAGMA foreign_keys must be ON every connection"
26
+ finally:
27
+ conn.close()
28
+
29
+ def test_journal_mode_wal(self, tmp_path: Path) -> None:
30
+ db = tmp_path / "test.db"
31
+ conn = db_connect(db)
32
+ try:
33
+ result = conn.execute("PRAGMA journal_mode").fetchone()[0]
34
+ assert result.lower() == "wal"
35
+ finally:
36
+ conn.close()
37
+
38
+ def test_synchronous_normal(self, tmp_path: Path) -> None:
39
+ db = tmp_path / "test.db"
40
+ conn = db_connect(db)
41
+ try:
42
+ # NORMAL = 1 in PRAGMA result.
43
+ result = conn.execute("PRAGMA synchronous").fetchone()[0]
44
+ assert result == 1
45
+ finally:
46
+ conn.close()
47
+
48
+ def test_busy_timeout_30s(self, tmp_path: Path) -> None:
49
+ db = tmp_path / "test.db"
50
+ conn = db_connect(db)
51
+ try:
52
+ result = conn.execute("PRAGMA busy_timeout").fetchone()[0]
53
+ assert result == 30000
54
+ finally:
55
+ conn.close()
56
+
57
+
58
+ class TestRowFactory:
59
+ def test_row_factory_is_sqlite_row(self, tmp_path: Path) -> None:
60
+ db = tmp_path / "test.db"
61
+ conn = db_connect(db)
62
+ try:
63
+ assert conn.row_factory is sqlite3.Row
64
+ finally:
65
+ conn.close()
66
+
67
+ def test_rows_support_mapping_access(self, tmp_path: Path) -> None:
68
+ """Row factory should let us index columns by name."""
69
+ db = tmp_path / "test.db"
70
+ conn = db_connect(db)
71
+ try:
72
+ conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
73
+ conn.execute("INSERT INTO t (name) VALUES (?)", ("alpha",))
74
+ conn.commit()
75
+ row = conn.execute("SELECT id, name FROM t").fetchone()
76
+ assert row["name"] == "alpha"
77
+ assert row["id"] == 1
78
+ finally:
79
+ conn.close()
80
+
81
+
82
+ class TestFkEnforcement:
83
+ def test_fk_actually_enforces(self, tmp_path: Path) -> None:
84
+ """Smoke-test: with FKs ON, violating-row inserts raise IntegrityError."""
85
+ db = tmp_path / "test.db"
86
+ conn = db_connect(db)
87
+ try:
88
+ conn.execute(
89
+ "CREATE TABLE parent (id INTEGER PRIMARY KEY, name TEXT)"
90
+ )
91
+ conn.execute(
92
+ "CREATE TABLE child ("
93
+ " id INTEGER PRIMARY KEY,"
94
+ " parent_id INTEGER,"
95
+ " FOREIGN KEY (parent_id) REFERENCES parent(id)"
96
+ ")"
97
+ )
98
+ conn.commit()
99
+ with pytest.raises(sqlite3.IntegrityError):
100
+ conn.execute(
101
+ "INSERT INTO child (parent_id) VALUES (?)", (999,)
102
+ )
103
+ finally:
104
+ conn.close()
105
+
106
+
107
+ class TestPathArg:
108
+ def test_path_object_accepted(self, tmp_path: Path) -> None:
109
+ """db_path: Path is the documented signature; pathlib.Path must work."""
110
+ db: Path = tmp_path / "test.db"
111
+ conn = db_connect(db)
112
+ try:
113
+ assert db.exists()
114
+ finally:
115
+ conn.close()
116
+
117
+ def test_creates_parent_dir(self, tmp_path: Path) -> None:
118
+ """Fresh install should create the db's parent directory if missing."""
119
+ nested = tmp_path / "nested" / "deeply" / "test.db"
120
+ conn = db_connect(nested)
121
+ try:
122
+ assert nested.parent.exists()
123
+ assert nested.exists()
124
+ finally:
125
+ conn.close()
126
+
127
+ def test_memory_db(self) -> None:
128
+ """``:memory:`` must be honored as an in-memory DB, not a file path."""
129
+ conn = db_connect(":memory:")
130
+ try:
131
+ result = conn.execute("PRAGMA foreign_keys").fetchone()[0]
132
+ assert result == 1
133
+ finally:
134
+ conn.close()
135
+
136
+
137
+ class TestAutocommit:
138
+ def test_isolation_level_is_none(self, tmp_path: Path) -> None:
139
+ """Autocommit mode is required for plugin BEGIN EXCLUSIVE migrations."""
140
+ db = tmp_path / "test.db"
141
+ conn = db_connect(db)
142
+ try:
143
+ assert conn.isolation_level is None
144
+ finally:
145
+ conn.close()
146
+
147
+ def test_begin_exclusive_works(self, tmp_path: Path) -> None:
148
+ """Plugins issue BEGIN EXCLUSIVE manually; verify the path is open."""
149
+ db = tmp_path / "test.db"
150
+ conn = db_connect(db)
151
+ try:
152
+ conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
153
+ conn.execute("BEGIN EXCLUSIVE")
154
+ conn.execute("INSERT INTO t (id) VALUES (?)", (1,))
155
+ conn.execute("COMMIT")
156
+ row = conn.execute("SELECT COUNT(*) FROM t").fetchone()
157
+ assert row[0] == 1
158
+ finally:
159
+ conn.close()
@@ -0,0 +1,174 @@
1
+ """Unit tests for OS-level advisory lockfiles.
2
+
3
+ Mocking-friendly: we don't fork a real second process to test mutual
4
+ exclusion. Instead we test the file-level state machine — acquire,
5
+ release, double-acquire raises, stale-PID detection.
6
+
7
+ A second-process scenario (where one Python interpreter holds the lock
8
+ and another tries to acquire) is covered by integration tests in the
9
+ plugin repos that consume aurochs-core, not this unit test, because
10
+ spawning processes is slow and brittle on Windows CI.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ from aurochs_core.locks import LockError, MigrateLock, WriteLock, _pid_alive
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Single-process state machine
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ class TestSingleProcess:
28
+ def test_acquire_release(self, tmp_path: Path) -> None:
29
+ db = tmp_path / "test.db"
30
+ lock = WriteLock(db)
31
+ lock.acquire()
32
+ try:
33
+ assert lock.path.exists()
34
+ finally:
35
+ lock.release()
36
+ # After release the PID stamp should be readable. On Windows,
37
+ # msvcrt.locking blocks reads from a SECOND handle while the lock
38
+ # is held — so we only verify the stamp post-release.
39
+ stamped = int(lock.path.read_text().strip() or "0")
40
+ assert stamped == os.getpid()
41
+
42
+ def test_context_manager(self, tmp_path: Path) -> None:
43
+ db = tmp_path / "test.db"
44
+ with WriteLock(db) as lock:
45
+ assert lock.path.exists()
46
+ # On POSIX the file is cleaned up; on Windows we deliberately leave
47
+ # it in place because msvcrt holds the byte-range even after close
48
+ # and we don't want race conditions on unlink. Either way, the OS
49
+ # lock has been released — re-acquire must succeed.
50
+ with WriteLock(db) as lock2:
51
+ assert lock2.path.exists()
52
+
53
+ def test_double_acquire_same_object_raises(self, tmp_path: Path) -> None:
54
+ db = tmp_path / "test.db"
55
+ lock = WriteLock(db)
56
+ lock.acquire()
57
+ try:
58
+ with pytest.raises(RuntimeError):
59
+ lock.acquire()
60
+ finally:
61
+ lock.release()
62
+
63
+ def test_release_without_acquire_safe(self, tmp_path: Path) -> None:
64
+ db = tmp_path / "test.db"
65
+ lock = WriteLock(db)
66
+ # Should be a no-op, not raise.
67
+ lock.release()
68
+
69
+ def test_write_and_migrate_locks_distinct(self, tmp_path: Path) -> None:
70
+ """A WriteLock and a MigrateLock can be held simultaneously by the
71
+ same process — they target different lockfiles."""
72
+ db = tmp_path / "test.db"
73
+ with WriteLock(db) as wl, MigrateLock(db) as ml:
74
+ assert wl.path != ml.path
75
+ assert wl.path.name.endswith(".write.lock")
76
+ assert ml.path.name.endswith(".migrate.lock")
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Stale-PID detection
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ class TestStalePidDetection:
85
+ def test_stale_lockfile_with_dead_pid_is_force_released(
86
+ self, tmp_path: Path
87
+ ) -> None:
88
+ """Pre-create a lockfile claiming a dead PID; acquire must succeed."""
89
+ db = tmp_path / "test.db"
90
+ lockfile = db.with_name(db.name + ".write.lock")
91
+ lockfile.parent.mkdir(parents=True, exist_ok=True)
92
+ # Use a PID we're confident is dead. PID 1 is normally init/launchd
93
+ # and will be alive — pick a high random PID instead. 999_999_999
94
+ # is well above the typical PID_MAX on Linux/macOS/Windows.
95
+ lockfile.write_text("999999999")
96
+
97
+ # The lockfile exists but no process actually holds the OS-level
98
+ # lock (we only wrote the PID, didn't fcntl/msvcrt-lock). The
99
+ # acquire path should succeed because it can take the OS-level
100
+ # lock — the stale-PID branch isn't strictly needed here, but the
101
+ # test confirms the happy path doesn't get confused by the file.
102
+ with WriteLock(db) as lock:
103
+ assert lock.path == lockfile
104
+
105
+ def test_pid_alive_self(self) -> None:
106
+ """Sanity: our own PID is reported alive."""
107
+ assert _pid_alive(os.getpid())
108
+
109
+ def test_pid_alive_dead_pid(self) -> None:
110
+ """A high-numbered PID that almost certainly doesn't exist must
111
+ be reported as dead (or, conservatively, as alive on platforms
112
+ where we can't tell — the code falls back to alive when ambiguous).
113
+
114
+ We accept either answer; the goal is to confirm the function
115
+ doesn't raise on an exotic input.
116
+ """
117
+ result = _pid_alive(999_999_999)
118
+ assert isinstance(result, bool)
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Concurrent acquire (in-process simulation via separate WriteLock objects)
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ class TestConcurrentInProcess:
127
+ def test_two_locks_same_file_one_wins(self, tmp_path: Path) -> None:
128
+ """Two WriteLock objects pointing at the same file: only one can
129
+ hold the OS-level lock at a time within a process.
130
+
131
+ On POSIX, fcntl.flock is per-file-descriptor but advisory between
132
+ processes — same-process double-locking via fcntl actually returns
133
+ success (POSIX-specific edge case). Windows msvcrt.locking is
134
+ strict. We accept either behavior here and just assert the API
135
+ doesn't crash.
136
+ """
137
+ db = tmp_path / "test.db"
138
+ lock_a = WriteLock(db, timeout=0)
139
+ lock_b = WriteLock(db, timeout=0)
140
+
141
+ lock_a.acquire()
142
+ try:
143
+ try:
144
+ lock_b.acquire()
145
+ # POSIX path — same-process flock is reentrant. Release.
146
+ lock_b.release()
147
+ except LockError:
148
+ # Windows path — strict mutual exclusion within process.
149
+ pass
150
+ finally:
151
+ lock_a.release()
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Public surface (re-exported from package root)
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ class TestPublicSurface:
160
+ def test_top_level_imports(self) -> None:
161
+ """The four documented public names import from package root."""
162
+ from aurochs_core import (
163
+ LockError as TopLockError,
164
+ )
165
+ from aurochs_core import (
166
+ MigrateLock as TopMigrateLock,
167
+ )
168
+ from aurochs_core import (
169
+ WriteLock as TopWriteLock,
170
+ )
171
+
172
+ assert TopLockError is LockError
173
+ assert TopMigrateLock is MigrateLock
174
+ assert TopWriteLock is WriteLock