Skip to content

Commit 8ecead5

Browse files
committed
fix TOCTOU races, FIPS compat, type shadows, and other code review fixes
1 parent 3baac7a commit 8ecead5

17 files changed

+630
-249
lines changed

benchmarks/benchmark_lockers.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
"""Benchmark: OneLocker vs ThreeLocker.
3+
4+
Run: python benchmark_lockers.py
5+
"""
6+
7+
import multiprocessing
8+
import os
9+
import sys
10+
import tempfile
11+
import time
12+
13+
multiprocessing.set_start_method("spawn", force=True)
14+
15+
from ubiquerg import OneLocker, ThreeLocker
16+
17+
18+
def benchmark_exclusive_access(filepath, iterations=100):
19+
"""Measure lock/unlock cycle speed for exclusive access."""
20+
one = OneLocker(filepath)
21+
start = time.perf_counter()
22+
for _ in range(iterations):
23+
one.write_lock()
24+
one.write_unlock()
25+
one_time = time.perf_counter() - start
26+
27+
three = ThreeLocker(filepath)
28+
start = time.perf_counter()
29+
for _ in range(iterations):
30+
three.write_lock()
31+
three.write_unlock()
32+
three_time = time.perf_counter() - start
33+
34+
return one_time, three_time
35+
36+
37+
def reader_process(filepath, locker_type, hold_time, barrier, timestamps, idx):
38+
"""Subprocess: wait for barrier, acquire read lock, hold it, record times."""
39+
if locker_type == "three":
40+
locker = ThreeLocker(filepath)
41+
else:
42+
locker = OneLocker(filepath)
43+
44+
barrier.wait()
45+
t_start = time.perf_counter()
46+
locker.read_lock()
47+
t_acquired = time.perf_counter()
48+
time.sleep(hold_time)
49+
locker.read_unlock()
50+
t_done = time.perf_counter()
51+
52+
timestamps[idx] = (t_start, t_acquired, t_done)
53+
54+
55+
def benchmark_concurrent_reads(filepath, n_readers=4, hold_time=0.5):
56+
"""Spawn n_readers behind a barrier, measure overlap behavior."""
57+
results = {}
58+
59+
for locker_type in ["one", "three"]:
60+
barrier = multiprocessing.Barrier(n_readers)
61+
manager = multiprocessing.Manager()
62+
timestamps = manager.dict()
63+
64+
processes = []
65+
for i in range(n_readers):
66+
p = multiprocessing.Process(
67+
target=reader_process,
68+
args=(filepath, locker_type, hold_time, barrier, timestamps, i),
69+
)
70+
processes.append(p)
71+
72+
for p in processes:
73+
p.start()
74+
for p in processes:
75+
p.join(timeout=60)
76+
77+
starts = [timestamps[i][0] for i in range(n_readers)]
78+
acquireds = [timestamps[i][1] for i in range(n_readers)]
79+
dones = [timestamps[i][2] for i in range(n_readers)]
80+
81+
wall = max(dones) - min(starts)
82+
avg_wait = sum(a - s for s, a in zip(starts, acquireds)) / n_readers
83+
84+
results[locker_type] = {"wall": wall, "avg_wait": avg_wait}
85+
86+
return results
87+
88+
89+
def main():
90+
tmpdir = tempfile.mkdtemp()
91+
filepath = os.path.join(tmpdir, "benchmark.txt")
92+
with open(filepath, "w") as f:
93+
f.write("benchmark data")
94+
95+
iterations = 100
96+
n_readers = 4
97+
hold_times = [0.5, 3.0]
98+
99+
print("ubiquerg locker benchmark")
100+
print("=" * 50)
101+
102+
# Benchmark 1: Exclusive access speed
103+
print(f"\n1. Exclusive lock/unlock ({iterations} iterations)")
104+
print("-" * 50)
105+
one_time, three_time = benchmark_exclusive_access(filepath, iterations)
106+
speedup = three_time / one_time
107+
print(f" OneLocker: {one_time:.4f}s ({one_time / iterations * 1000:.2f}ms/cycle)")
108+
print(f" ThreeLocker: {three_time:.4f}s ({three_time / iterations * 1000:.2f}ms/cycle)")
109+
print(f" OneLocker {speedup:.1f}x faster")
110+
111+
# Benchmark 2: Concurrent readers at different hold times
112+
for hold_time in hold_times:
113+
print(f"\n2. Concurrent readers ({n_readers} readers, {hold_time}s hold each)")
114+
print("-" * 50)
115+
# Suppress wait_for_lock stdout dots (fd-level for child processes)
116+
sys.stdout.flush()
117+
devnull = os.open(os.devnull, os.O_WRONLY)
118+
old_fd = os.dup(1)
119+
os.dup2(devnull, 1)
120+
os.close(devnull)
121+
results = benchmark_concurrent_reads(filepath, n_readers, hold_time)
122+
os.dup2(old_fd, 1)
123+
os.close(old_fd)
124+
125+
one = results["one"]
126+
three = results["three"]
127+
print(f" OneLocker: {one['wall']:.2f}s wall (avg {one['avg_wait']:.2f}s wait)")
128+
print(f" ThreeLocker: {three['wall']:.2f}s wall (avg {three['avg_wait']:.2f}s wait)")
129+
130+
os.unlink(filepath)
131+
os.rmdir(tmpdir)
132+
133+
134+
if __name__ == "__main__":
135+
main()

changelog.md

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,38 @@
22

33
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format.
44

5-
## [0.9.0] -- 2026-02-10
5+
## [0.9.0] -- 2026-02-11
6+
7+
### Added
8+
- `OneLocker` class with context manager support (`with OneLocker(path) as locker:`)
69

710
### Changed
8-
- Migrated packaging from setup.py/setup.cfg to pyproject.toml with hatchling backend
9-
- Replaced black with ruff for linting and formatting
10-
- Updated GitHub Actions workflows to latest versions (checkout@v6, setup-python@v6)
11-
- Updated publish workflow to use `python -m build` instead of `setup.py sdist`
12-
- Modernized type hints: replaced `Optional`/`Union` with PEP 604 `X | None` / `X | Y` syntax
13-
- Converted remaining Sphinx-style docstrings to Google style
14-
- Dropped Python 3.9 support (minimum is now 3.10)
15-
- Added Python 3.14 support
11+
- Migrated to pyproject.toml/hatchling, ruff, latest GitHub Actions
12+
- Modernized type hints (PEP 604) and docstrings (Google style)
13+
- Dropped Python 3.9; added Python 3.14
14+
- `is_command_callable` uses `shutil.which()` instead of `os.system()`
15+
- `_create_lock` is now iterative with retry limit (was unbounded recursion)
16+
- `wait_for_lock` progress dots go to stderr instead of stdout
17+
- `convert_value` passes through `None`/`bool`/`int`/`float` directly
18+
- `is_url` regex compiled once at module level
1619

1720
### Fixed
18-
- Deduplicated `mkabs` in `file_locking.py``ThreeLocker` now uses the canonical version from `paths.py` which handles None, URLs, and file-as-reldir
19-
- Deduplicated `_create_lock` in `file_locking.py` — consolidated to single implementation in `files.py`
20-
- Fixed `tarfile.extractall()` deprecation warning for Python 3.12+ (added `filter="data"`)
21-
- Fixed typo "assiated" → "associated" in `cli_tools.py`
22-
- Re-exported `uniqify` function that was accidentally dropped during explicit-exports migration
21+
- Shell injection in `is_command_callable`
22+
- Signal handler leak in `read_lock`/`write_lock` context managers
23+
- ThreeLocker SIGTERM handler crash
24+
- `read_lock()`/`_lock()` returning True when locking actually fails
25+
- `read_unlock()` while write lock held now raises `RuntimeError`
26+
- `deep_update` crash replacing scalar with nested dict
27+
- `TmpEnv` destroying pre-existing env vars on exit
28+
- `arg_defaults(unique=True)` only returning first subcommand's defaults
29+
- `wait_max=0` treated as falsy in `create_read_lock`/`create_write_lock`
30+
- `is_writable` infinite recursion and failure on nested paths
31+
- Mutable default argument in `parse_registry_path`
32+
- `tarfile.extractall()` deprecation warning on Python 3.12+
2333

2434
### Removed
25-
- Deprecated `asciify_dict()` function (was a Python 2 no-op)
26-
- Legacy packaging files: setup.py, setup.cfg, MANIFEST.in, requirements/
35+
- `build_cli_extra()` and `asciify_dict()` functions
36+
- Legacy packaging files (setup.py, setup.cfg, MANIFEST.in, requirements/)
2737

2838
## [0.8.2] -- 2025-12-01
2939

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ test = ["pytest"]
3232
[tool.pytest.ini_options]
3333
addopts = "-rfE"
3434
testpaths = ["tests"]
35+
markers = ["benchmark: locker performance benchmark tests"]
3536
python_files = "test_*.py"
3637
python_classes = "Test* *Test *Tests *Tester"
3738
python_functions = "test_* test[A-Z]*"
@@ -46,3 +47,4 @@ ignore = ["E501"]
4647

4748
[tool.ruff.lint.per-file-ignores]
4849
"tests/*" = ["F403", "F405"]
50+
"benchmarks/*" = ["E402"]

tests/test_cli_tools.py

Lines changed: 16 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
1-
"""Tests for rendering CLI options and arguments"""
1+
"""Tests for CLI tools"""
22

33
import argparse
4-
from collections import OrderedDict
54

65
import pytest
76

8-
from ubiquerg import VersionInHelpParser, build_cli_extra, convert_value, powerset
9-
10-
11-
def pytest_generate_tests(metafunc):
12-
"""Test case generation and parameterization for this module"""
13-
if "ordwrap" in metafunc.fixturenames:
14-
metafunc.parametrize("ordwrap", [tuple, OrderedDict])
7+
from ubiquerg import VersionInHelpParser, convert_value
158

169

1710
def build_parser():
@@ -244,36 +237,6 @@ def add_subparser(cmd):
244237
return parser, msg_by_cmd
245238

246239

247-
@pytest.mark.parametrize(
248-
["optargs", "expected"],
249-
[
250-
(
251-
[
252-
("-X", None),
253-
("--revert", 1),
254-
("-O", "outfile"),
255-
("--execute-locally", None),
256-
("-I", ["file1", "file2"]),
257-
],
258-
"-X --revert 1 -O outfile --execute-locally -I file1 file2",
259-
)
260-
],
261-
)
262-
def test_build_cli_extra(optargs, expected, ordwrap):
263-
"""Check that CLI optargs are rendered as expected."""
264-
observed = build_cli_extra(ordwrap(optargs))
265-
print("expected: {}".format(expected))
266-
print("observed: {}".format(observed))
267-
assert expected == observed
268-
269-
270-
@pytest.mark.parametrize("optargs", powerset([(None, "a"), (1, "one")], nonempty=True))
271-
def test_illegal_cli_extra_input_is_exceptional(optargs, ordwrap):
272-
"""Non-string keys are illegal and cause a TypeError."""
273-
with pytest.raises(TypeError):
274-
build_cli_extra(ordwrap(optargs))
275-
276-
277240
def test_dests_by_subparser_return_type():
278241
"""Check if the return type is dict of lists keyed by subcommand name"""
279242
parser, msgs_by_cmd = build_parser()
@@ -341,10 +304,23 @@ def test_arg_defaults_unqiue():
341304
parser, _ = build_parser()
342305
tld = parser.arg_defaults(unique=True)
343306
assert isinstance(tld, dict)
344-
assert len(tld) == 17
307+
assert len(tld) == 19
345308
assert len(set(tld.keys())) == len(tld.keys())
346309

347310

311+
def test_arg_defaults_unique_includes_all_subcommands():
312+
"""unique=True must merge defaults from ALL subcommands, not just the first."""
313+
parser = VersionInHelpParser(prog="test")
314+
subs = parser.add_subparsers(dest="command")
315+
sub1 = subs.add_parser("first")
316+
sub1.add_argument("--only-in-first", default="val1")
317+
sub2 = subs.add_parser("second")
318+
sub2.add_argument("--only-in-second", default="val2")
319+
result = parser.arg_defaults(unique=True)
320+
assert "only_in_first" in result, f"Missing first subcommand defaults: {result}"
321+
assert "only_in_second" in result, f"Missing second subcommand defaults: {result}"
322+
323+
348324
@pytest.mark.parametrize(
349325
["input", "output"],
350326
[

tests/test_collection.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,11 @@ def test_powerset_illegal_input(arbwrap, kwargs, exp_err, pool):
238238
@pytest.mark.parametrize(["x", "y"], [({"a": 1}, {"b": 2}), ({"a": 1, "c": 2}, {"b": 2})])
239239
def test_merging_dicts(x, y):
240240
assert "a" in list(merge_dicts(x, y).keys())
241+
242+
243+
def test_deep_update_replace_scalar_with_dict():
244+
"""Replacing a scalar value with a nested dict should not crash."""
245+
old = {"a": "string_value", "b": 1}
246+
new = {"a": {"nested": "dict"}, "b": {"also": "nested"}}
247+
deep_update(old, new)
248+
assert old == {"a": {"nested": "dict"}, "b": {"also": "nested"}}

tests/test_environment.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ def test_no_intersection(overwrite, envvars):
5656
assert v == os.getenv(k)
5757

5858

59+
def test_overwrite_restores_original_value():
60+
"""With overwrite=True, exiting the context should restore the original value, not delete it."""
61+
os.environ["UBIQUERG_TEST_RESTORE"] = "original"
62+
try:
63+
with TmpEnv(overwrite=True, UBIQUERG_TEST_RESTORE="modified"):
64+
assert os.environ["UBIQUERG_TEST_RESTORE"] == "modified"
65+
assert os.environ.get("UBIQUERG_TEST_RESTORE") == "original"
66+
finally:
67+
os.environ.pop("UBIQUERG_TEST_RESTORE", None)
68+
69+
5970
def check_unset(envvars):
6071
"""Verify that each environment variable is not set."""
6172
fails = [v for v in envvars if os.getenv(v) or v in os.environ]

0 commit comments

Comments
 (0)