fencekit 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,19 @@
1
+ .venv/
2
+ venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+ *.egg-info/
7
+ .eggs/
8
+ dist/
9
+ build/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ .hypothesis/
14
+ .coverage
15
+ htmlcov/
16
+ *.cover
17
+ .env
18
+ .DS_Store
19
+ uv.lock
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-07-16
9
+
10
+ ### Added
11
+
12
+ - `idempotency_key` with deterministic JSON canonicalization
13
+ - owner-bound `IdempotencyGuard` via Redis `SET NX EX`
14
+ - `DistributedLock` with atomic acquire + fencing token (Lua)
15
+ - `FenceGate` with atomic fenced Redis string writes
16
+ - Typed public API (`py.typed`), errors, and key helpers
17
+ - Unit, property (Hypothesis), and Redis integration tests
18
+ - `DESIGN.md` documenting guarantees, non-guarantees, and crash semantics
@@ -0,0 +1,226 @@
1
+ # Design: fencekit
2
+
3
+ ## Purpose
4
+
5
+ fencekit coordinates repeated delivery of background jobs and rejects stale
6
+ writes through Redis fencing tokens. Celery orchestration and Redlock are outside
7
+ its scope. The library makes no exactly-once delivery claim.
8
+
9
+ ## Origin
10
+
11
+ ChessMate's Celery jobs analyze imported games with Stockfish and then write coaching
12
+ reports. Worker crashes caused some jobs to be delivered again. The same batch could
13
+ run twice. Duplicate runs wasted Stockfish CPU and left the saved progress ambiguous.
14
+ fencekit contains the Redis operations pulled from those tasks. Its tests reproduce
15
+ worker death and lock-expiry races.
16
+
17
+ ## Threat model
18
+
19
+ Trusted: Redis is trusted infrastructure in v0.1 (no authz inside the library;
20
+ use Redis ACLs/network policy at the ops layer). Callers are trusted not to forge
21
+ `owner_id` for locks they do not hold (owner IDs are random UUIDs per acquire).
22
+
23
+ Untrusted / hostile: concurrent workers, process kill mid-task, GC pauses
24
+ longer than lock TTL, network delay delivering stale writes, Celery redelivery.
25
+
26
+ Redis administrators are outside the threat model. So are compromised workers that
27
+ bypass the API. The v0.1 model excludes split-brain and multi-primary failover.
28
+
29
+ ## Algorithms
30
+
31
+ ### Idempotency
32
+
33
+ ```
34
+ SET {prefix}:idem:{namespace}:{sha256} "pending:{owner_id}" NX EX ttl
35
+ ```
36
+
37
+ Only one caller begins. `mark_done` uses Lua to compare the guard owner before
38
+ setting `done` (with an optional TTL refresh), so a stale worker cannot complete
39
+ a key reclaimed by another worker after expiry.
40
+
41
+ Canonicalization rules:
42
+
43
+ - top-level payload and nested objects are mappings with string keys;
44
+ - mapping keys are sorted recursively; sequence order is preserved;
45
+ - JSON uses compact separators, UTF-8 input, and `ensure_ascii=True`;
46
+ - integers and floats retain JSON's distinct spellings (`1` vs `1.0`);
47
+ - NaN/infinity, bytes, sets, datetimes, and custom objects are rejected;
48
+ - the key suffix is lowercase SHA-256 hex of the canonical JSON bytes; and
49
+ - namespaces are non-empty and contain neither whitespace nor `:`.
50
+
51
+ Callers must normalize business values (for example, datetime ISO format and
52
+ case normalization) before calling `idempotency_key`. Key stability is part of
53
+ the producer contract.
54
+
55
+ ### Lock + fencing token (atomic)
56
+
57
+ Lua on acquire:
58
+
59
+ ```lua
60
+ -- KEYS[1]=lock, KEYS[2]=sequence; ARGV[1]=owner, ARGV[2]=ttl_ms
61
+ if redis.call('EXISTS', KEYS[1]) == 1 then
62
+ return false
63
+ end
64
+ local token = redis.call('INCR', KEYS[2])
65
+ if redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2], 'NX') then
66
+ return token
67
+ end
68
+ return false
69
+ ```
70
+
71
+ The script is atomic: no other client can acquire between `EXISTS` and `SET`.
72
+ `INCR` precedes `SET` because Redis scripts do not roll back earlier
73
+ writes after a runtime error. A corrupt or overflowing counter therefore cannot
74
+ leave a lock without a token. Failed attempts can consume a token only if the
75
+ final `SET` unexpectedly fails. Gaps are safe; the counter only needs to increase.
76
+
77
+ Release and extend compare ownership in the same script as the mutation:
78
+
79
+ ```lua
80
+ -- release: KEYS[1]=lock, ARGV[1]=owner
81
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
82
+ return redis.call('DEL', KEYS[1])
83
+ end
84
+ return 0
85
+
86
+ -- extend: KEYS[1]=lock, ARGV[1]=owner, ARGV[2]=ttl_ms
87
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
88
+ return redis.call('PEXPIRE', KEYS[1], ARGV[2])
89
+ end
90
+ return 0
91
+ ```
92
+
93
+ This prevents a delayed owner from deleting or extending a replacement lease.
94
+
95
+ ### Fence gate
96
+
97
+ ```lua
98
+ local max = tonumber(redis.call('GET', max_key) or '0')
99
+ if tonumber(token) < max then return 0 end -- stale
100
+ redis.call('SET', max_key, token) -- equal token may rewrite
101
+ return 1
102
+ ```
103
+
104
+ `check` and `check_and_advance` operate only on the token registry. They are
105
+ useful for diagnostics and coordination, but a check followed by a separate
106
+ write has a time-of-check/time-of-use race. For Redis string state,
107
+ `FenceGate.set_if_fresh` checks the token and writes the target in one Lua script.
108
+ The script also records the accepted token. Other Redis data types require an
109
+ equivalent application Lua script. External stores must compare the token in the
110
+ same transaction or statement as their mutation.
111
+
112
+ The Redis string helper is:
113
+
114
+ ```lua
115
+ -- KEYS[1]=max_key, KEYS[2]=target; ARGV[1]=token, ARGV[2]=value
116
+ local max = tonumber(redis.call('GET', KEYS[1]) or '0')
117
+ local token = tonumber(ARGV[1])
118
+ if token < max then return 0 end
119
+ redis.call('SET', KEYS[1], ARGV[1])
120
+ redis.call('SET', KEYS[2], ARGV[2])
121
+ return 1
122
+ ```
123
+
124
+ Martin Kleppmann describes the stale-holder problem in
125
+ [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html).
126
+ TTL locks cannot stop a paused holder, so **the storage layer must reject stale
127
+ tokens**. Redis's
128
+ [distributed-lock documentation](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/)
129
+ also motivates random owner values and compare-before-delete release. fencekit
130
+ uses that ownership rule and does not implement Redlock.
131
+
132
+ ## Guarantees
133
+
134
+ | Claim | Status |
135
+ |-------|--------|
136
+ | At-most-once *start* within the idempotency TTL (Redis up) | Yes |
137
+ | Mutual exclusion while lease held (single primary) | Best-effort |
138
+ | Stale holder cannot mutate via atomic `set_if_fresh` | Yes |
139
+ | Exactly-once delivery | No |
140
+ | HA / failover / Redlock safety | No |
141
+ | Writes that skip the token | No |
142
+
143
+ At-most-once side effects require the application storage layer to combine the
144
+ fencing check with a unique business-operation key in one transaction. A fence
145
+ prevents stale overwrites. It cannot make a non-idempotent external API, such as
146
+ a card charge, execute exactly once.
147
+
148
+ ## Non-guarantees and risks
149
+
150
+ 1. Redlock. fencekit does not implement multi-key majority locking. Its v0.1
151
+ safety model uses one Redis primary and storage-side fencing.
152
+ 2. Single Redis primary. Asynchronous failover may allow overlapping leases
153
+ or roll back a fence counter, so do not claim HA mutual exclusion or global
154
+ token monotonicity across failover. Redis Cluster is unsupported in v0.1;
155
+ multi-key Lua scripts also require keys in one hash slot.
156
+ 3. Clock skew. Redis server `PX` controls expiry. Worker-to-server skew does not
157
+ decide ownership. A Redis server clock jump can still make leases expire early
158
+ or late. Do not implement "lock valid until" on the worker wall clock; blocking
159
+ waits use the client monotonic clock only.
160
+ 4. Cooperative storage. Postgres (or S3, etc.) must also compare tokens in
161
+ the mutation itself. A separate preflight check cannot protect another store.
162
+ 5. GC pause > TTL. A newer token fences out the paused holder after its lease
163
+ expires.
164
+ 6. Idempotency TTL. Too short means a duplicate begin after expiry. Too long can
165
+ leave a key stuck in `pending` if a worker dies without `mark_done`/`clear`.
166
+ Align with the job SLA; v0.2 may add explicit takeover rules.
167
+ 7. Counter persistence and overflow. Fence counters intentionally do not
168
+ expire. Deleting/restoring/rolling them back can reuse old token values and
169
+ breaks monotonicity. Redis integer overflow makes acquisition fail safely
170
+ before the lock key is written.
171
+ 8. Ambiguous network outcomes. A client timeout does not prove Redis did not
172
+ apply a command. A fresh acquire cannot create a second lease while the first
173
+ key remains, but the caller may not know it owns the first lease. Likewise,
174
+ an idempotency `SET` whose reply was lost may suppress work until its TTL.
175
+ Applications must treat connection errors as an unknown outcome and reconcile.
176
+
177
+ ## Crash / restart semantics
178
+
179
+ 1. Kill while lock valid: lock remains until TTL; redelivery fails acquire /
180
+ `try_begin` while key is pending/done.
181
+ 2. Kill after TTL: new owner gets higher token; stale atomic fenced writes
182
+ fail (`FencedOutError`). See `tests/property/test_fencing_expiry.py`.
183
+ 3. Celery redelivery: same idempotency key means `try_begin` is False while TTL
184
+ holds.
185
+ 4. Partial side effects (temp files, half-written rows) remain an application
186
+ concern; fence every durable mutation.
187
+
188
+ ## TTL guidance
189
+
190
+ Set the lock TTL longer than one chunk of work plus its heartbeat margin. Extend
191
+ the lease regularly. Short leases recover faster after worker death than
192
+ multi-hour leases.
193
+
194
+ The idempotency TTL should cover retries and the maximum job duration. Use a longer
195
+ value when a duplicate start after completion would be unsafe.
196
+
197
+ ## Key naming
198
+
199
+ | Purpose | Pattern |
200
+ |---------|---------|
201
+ | Idempotency | `{prefix}:idem:{namespace}:{sha256}` |
202
+ | Lock | `{prefix}:lock:{resource}` |
203
+ | Fence sequence | `{prefix}:fence:seq:{resource}` |
204
+ | Fence max | `{prefix}:fence:max:{resource}` |
205
+
206
+ Default `prefix=fencekit`.
207
+
208
+ ## Trust boundaries
209
+
210
+ Payloads are JSON-canonicalized; fencekit does not use pickle. The library does
211
+ not log Redis URLs or credentials. v0.1 treats Redis as trusted infrastructure,
212
+ so production deployments should restrict it with network controls and Redis
213
+ ACLs.
214
+
215
+ ## Postgres pattern (app-owned)
216
+
217
+ ```sql
218
+ UPDATE analysis_job
219
+ SET progress = %(progress)s, fence_token = %(token)s
220
+ WHERE id = %(id)s AND fence_token <= %(token)s;
221
+ -- rowcount 0 => fenced out
222
+ ```
223
+
224
+ ## Versioning
225
+
226
+ Semver from 0.1.0. Breaking API changes bump minor while 0.x.
fencekit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ahmed Mohamed
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,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: fencekit
3
+ Version: 0.1.0
4
+ Summary: Redis-backed idempotent task primitives: fencing tokens and distributed locks
5
+ Project-URL: Homepage, https://github.com/ahmed5145/fencekit
6
+ Project-URL: Documentation, https://github.com/ahmed5145/fencekit/blob/main/DESIGN.md
7
+ Project-URL: Changelog, https://github.com/ahmed5145/fencekit/blob/main/CHANGELOG.md
8
+ Project-URL: Repository, https://github.com/ahmed5145/fencekit
9
+ Author-email: Ahmed Mohamed <ahmed5145@users.noreply.github.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: celery,distributed-lock,fencing-token,idempotency,redis
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: System :: Distributed Computing
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: redis>=5.0.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
27
+ Requires-Dist: mypy>=1.13; extra == 'dev'
28
+ Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.8; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # fencekit
34
+
35
+ Redis-backed idempotency and fenced locks for background jobs. A destination that
36
+ atomically checks the fencing token can reject writes from a stale lock holder.
37
+
38
+ ## Why
39
+
40
+ ChessMate ([chess-mate.online](https://chess-mate.online)) runs imported games
41
+ through Stockfish before writing a coaching report. With Redis as the Celery broker
42
+ and late acknowledgements enabled, a worker crash can cause the same job to be
43
+ delivered again. I saw batches progress twice. That wasted Stockfish CPU and left
44
+ the recorded progress ambiguous.
45
+
46
+ Celery requires late-ack tasks to be idempotent, but each task still needs code to
47
+ enforce that property. fencekit collects the Redis operations I used in ChessMate.
48
+ Its tests reproduce worker death and lock-expiry races.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install fencekit
54
+ # or
55
+ uv add fencekit
56
+ ```
57
+
58
+ Requires Redis 6+ (tested with Redis 7) and Python 3.10+.
59
+
60
+ ## 30-second example
61
+
62
+ ```python
63
+ from datetime import timedelta
64
+ from redis import Redis
65
+
66
+ from fencekit import (
67
+ DistributedLock,
68
+ FenceGate,
69
+ IdempotencyGuard,
70
+ idempotency_key,
71
+ )
72
+
73
+ r = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
74
+ guard = IdempotencyGuard(r)
75
+ lock = DistributedLock(r)
76
+ fence = FenceGate(r)
77
+
78
+ def analyze_batch() -> None:
79
+ key = idempotency_key(
80
+ {"game_ids": ["abc", "def"], "engine": "sf16"},
81
+ namespace="analysis",
82
+ )
83
+ if not guard.try_begin(key, ttl=timedelta(hours=24)):
84
+ return # already started or completed
85
+
86
+ handle = lock.acquire("analysis:batch-42", ttl=timedelta(minutes=5))
87
+ try:
88
+ # Atomic fence check + Redis progress write:
89
+ fence.set_if_fresh(handle.token, "analysis:batch-42:status", "running")
90
+ # Extend the lock on heartbeats and fence every durable write.
91
+ guard.mark_done(key)
92
+ finally:
93
+ lock.release(handle)
94
+ ```
95
+
96
+ ## Guarantees
97
+
98
+ | Claim | Status |
99
+ |-------|--------|
100
+ | At-most-once *start* within the idempotency TTL (Redis available) | Yes (`SET NX`) |
101
+ | Mutual exclusion while lock TTL held (single Redis primary) | Best-effort lease |
102
+ | Stale holder cannot overwrite via atomic `FenceGate.set_if_fresh` | Yes |
103
+ | Exactly-once delivery | No |
104
+ | Safety under Redis failover / split brain | No (v0.1) |
105
+ | Safety if the app writes without presenting the token | No |
106
+
107
+ See [DESIGN.md](DESIGN.md) for the threat model, Lua algorithms, TTL guidance, and
108
+ crash/restart semantics. fencekit does not implement Redlock.
109
+
110
+ Fencing covers stale overwrites. Exactly-once external effects also require the
111
+ destination to enforce the fence token and a unique business-operation key in
112
+ one atomic operation.
113
+
114
+ ## Running tests
115
+
116
+ ```bat
117
+ REM CMD (Windows), no uv required
118
+ cd fencekit
119
+ python -m pip install -e ".[dev]"
120
+ python -m ruff check --fix src tests
121
+ python -m ruff check src tests
122
+ python -m mypy src
123
+ python -m pytest -m "not integration" -q
124
+ ```
125
+
126
+ The full suite needs Redis on `localhost:6379`:
127
+
128
+ ```bat
129
+ set FENCEKIT_REDIS_URL=redis://localhost:6379/15
130
+ python -m pytest -q
131
+ ```
132
+
133
+ Integration tests skip cleanly when Redis is unreachable or `FENCEKIT_REDIS_URL` is unset.
134
+
135
+ ```bash
136
+ # Linux/macOS with uv + Docker
137
+ uv sync --extra dev
138
+ uv run pytest -m "not integration"
139
+ docker run -d -p 6379:6379 --name fencekit-redis redis:7
140
+ export FENCEKIT_REDIS_URL=redis://localhost:6379/15
141
+ uv run pytest
142
+ ```
143
+
144
+ v0.1 has no Celery adapter. A later release may add one as an optional extra.
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,116 @@
1
+ # fencekit
2
+
3
+ Redis-backed idempotency and fenced locks for background jobs. A destination that
4
+ atomically checks the fencing token can reject writes from a stale lock holder.
5
+
6
+ ## Why
7
+
8
+ ChessMate ([chess-mate.online](https://chess-mate.online)) runs imported games
9
+ through Stockfish before writing a coaching report. With Redis as the Celery broker
10
+ and late acknowledgements enabled, a worker crash can cause the same job to be
11
+ delivered again. I saw batches progress twice. That wasted Stockfish CPU and left
12
+ the recorded progress ambiguous.
13
+
14
+ Celery requires late-ack tasks to be idempotent, but each task still needs code to
15
+ enforce that property. fencekit collects the Redis operations I used in ChessMate.
16
+ Its tests reproduce worker death and lock-expiry races.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install fencekit
22
+ # or
23
+ uv add fencekit
24
+ ```
25
+
26
+ Requires Redis 6+ (tested with Redis 7) and Python 3.10+.
27
+
28
+ ## 30-second example
29
+
30
+ ```python
31
+ from datetime import timedelta
32
+ from redis import Redis
33
+
34
+ from fencekit import (
35
+ DistributedLock,
36
+ FenceGate,
37
+ IdempotencyGuard,
38
+ idempotency_key,
39
+ )
40
+
41
+ r = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
42
+ guard = IdempotencyGuard(r)
43
+ lock = DistributedLock(r)
44
+ fence = FenceGate(r)
45
+
46
+ def analyze_batch() -> None:
47
+ key = idempotency_key(
48
+ {"game_ids": ["abc", "def"], "engine": "sf16"},
49
+ namespace="analysis",
50
+ )
51
+ if not guard.try_begin(key, ttl=timedelta(hours=24)):
52
+ return # already started or completed
53
+
54
+ handle = lock.acquire("analysis:batch-42", ttl=timedelta(minutes=5))
55
+ try:
56
+ # Atomic fence check + Redis progress write:
57
+ fence.set_if_fresh(handle.token, "analysis:batch-42:status", "running")
58
+ # Extend the lock on heartbeats and fence every durable write.
59
+ guard.mark_done(key)
60
+ finally:
61
+ lock.release(handle)
62
+ ```
63
+
64
+ ## Guarantees
65
+
66
+ | Claim | Status |
67
+ |-------|--------|
68
+ | At-most-once *start* within the idempotency TTL (Redis available) | Yes (`SET NX`) |
69
+ | Mutual exclusion while lock TTL held (single Redis primary) | Best-effort lease |
70
+ | Stale holder cannot overwrite via atomic `FenceGate.set_if_fresh` | Yes |
71
+ | Exactly-once delivery | No |
72
+ | Safety under Redis failover / split brain | No (v0.1) |
73
+ | Safety if the app writes without presenting the token | No |
74
+
75
+ See [DESIGN.md](DESIGN.md) for the threat model, Lua algorithms, TTL guidance, and
76
+ crash/restart semantics. fencekit does not implement Redlock.
77
+
78
+ Fencing covers stale overwrites. Exactly-once external effects also require the
79
+ destination to enforce the fence token and a unique business-operation key in
80
+ one atomic operation.
81
+
82
+ ## Running tests
83
+
84
+ ```bat
85
+ REM CMD (Windows), no uv required
86
+ cd fencekit
87
+ python -m pip install -e ".[dev]"
88
+ python -m ruff check --fix src tests
89
+ python -m ruff check src tests
90
+ python -m mypy src
91
+ python -m pytest -m "not integration" -q
92
+ ```
93
+
94
+ The full suite needs Redis on `localhost:6379`:
95
+
96
+ ```bat
97
+ set FENCEKIT_REDIS_URL=redis://localhost:6379/15
98
+ python -m pytest -q
99
+ ```
100
+
101
+ Integration tests skip cleanly when Redis is unreachable or `FENCEKIT_REDIS_URL` is unset.
102
+
103
+ ```bash
104
+ # Linux/macOS with uv + Docker
105
+ uv sync --extra dev
106
+ uv run pytest -m "not integration"
107
+ docker run -d -p 6379:6379 --name fencekit-redis redis:7
108
+ export FENCEKIT_REDIS_URL=redis://localhost:6379/15
109
+ uv run pytest
110
+ ```
111
+
112
+ v0.1 has no Celery adapter. A later release may add one as an optional extra.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,100 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fencekit"
7
+ version = "0.1.0"
8
+ description = "Redis-backed idempotent task primitives: fencing tokens and distributed locks"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Ahmed Mohamed", email = "ahmed5145@users.noreply.github.com" },
14
+ ]
15
+ keywords = [
16
+ "redis",
17
+ "distributed-lock",
18
+ "fencing-token",
19
+ "idempotency",
20
+ "celery",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 4 - Beta",
24
+ "Intended Audience :: Developers",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.10",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Topic :: System :: Distributed Computing",
32
+ "Typing :: Typed",
33
+ ]
34
+ dependencies = [
35
+ "redis>=5.0.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/ahmed5145/fencekit"
40
+ Documentation = "https://github.com/ahmed5145/fencekit/blob/main/DESIGN.md"
41
+ Changelog = "https://github.com/ahmed5145/fencekit/blob/main/CHANGELOG.md"
42
+ Repository = "https://github.com/ahmed5145/fencekit"
43
+
44
+ [project.optional-dependencies]
45
+ dev = [
46
+ "pytest>=8.0",
47
+ "pytest-timeout>=2.3",
48
+ "hypothesis>=6.100",
49
+ "ruff>=0.8",
50
+ "mypy>=1.13",
51
+ ]
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/fencekit"]
55
+
56
+ [tool.hatch.build.targets.sdist]
57
+ include = [
58
+ "/src",
59
+ "/tests",
60
+ "/README.md",
61
+ "/DESIGN.md",
62
+ "/CHANGELOG.md",
63
+ "/LICENSE",
64
+ ]
65
+
66
+ [tool.pytest.ini_options]
67
+ minversion = "8.0"
68
+ testpaths = ["tests"]
69
+ markers = [
70
+ "integration: tests that require a real Redis instance (FENCEKIT_REDIS_URL)",
71
+ ]
72
+ timeout = 60
73
+ addopts = "-ra"
74
+
75
+ [tool.ruff]
76
+ target-version = "py310"
77
+ line-length = 88
78
+ src = ["src", "tests"]
79
+
80
+ [tool.ruff.lint]
81
+ select = [
82
+ "E",
83
+ "F",
84
+ "I",
85
+ "UP",
86
+ "B",
87
+ "SIM",
88
+ "RUF",
89
+ ]
90
+
91
+ [tool.ruff.lint.isort]
92
+ known-first-party = ["fencekit"]
93
+
94
+ [tool.mypy]
95
+ python_version = "3.10"
96
+ strict = true
97
+ warn_return_any = true
98
+ warn_unused_configs = true
99
+ mypy_path = "src"
100
+ packages = ["fencekit"]
@@ -0,0 +1,38 @@
1
+ """Redis idempotency and fenced locks for background jobs."""
2
+
3
+ from fencekit._version import __version__
4
+ from fencekit.canonicalize import canonicalize, idempotency_key
5
+ from fencekit.client import RedisClient, SyncRedis
6
+ from fencekit.errors import (
7
+ CanonicalizeError,
8
+ FencedOutError,
9
+ FenceKitError,
10
+ IdempotencyNotOwned,
11
+ LockNotAcquired,
12
+ LockNotOwned,
13
+ TokenExpiredError,
14
+ )
15
+ from fencekit.fence import FenceGate
16
+ from fencekit.idempotency import IdempotencyGuard
17
+ from fencekit.lock import DistributedLock
18
+ from fencekit.types import FenceToken, LockHandle
19
+
20
+ __all__ = [
21
+ "CanonicalizeError",
22
+ "DistributedLock",
23
+ "FenceGate",
24
+ "FenceKitError",
25
+ "FenceToken",
26
+ "FencedOutError",
27
+ "IdempotencyGuard",
28
+ "IdempotencyNotOwned",
29
+ "LockHandle",
30
+ "LockNotAcquired",
31
+ "LockNotOwned",
32
+ "RedisClient",
33
+ "SyncRedis",
34
+ "TokenExpiredError",
35
+ "__version__",
36
+ "canonicalize",
37
+ "idempotency_key",
38
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"