wt-task 0.1.3__tar.gz → 0.2.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 (57) hide show
  1. wt_task-0.2.0/CHANGELOG.md +25 -0
  2. {wt_task-0.1.3/src/wt_task.egg-info → wt_task-0.2.0}/PKG-INFO +6 -2
  3. {wt_task-0.1.3 → wt_task-0.2.0}/pyproject.toml +21 -3
  4. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/_version.py +3 -3
  5. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/base.py +85 -2
  6. wt_task-0.2.0/src/wt_task/cache/__init__.py +64 -0
  7. wt_task-0.2.0/src/wt_task/cache/_codec.py +175 -0
  8. wt_task-0.2.0/src/wt_task/cache/_entry.py +34 -0
  9. wt_task-0.2.0/src/wt_task/cache/_key.py +628 -0
  10. wt_task-0.2.0/src/wt_task/cache/_report.py +123 -0
  11. wt_task-0.2.0/src/wt_task/cache/_runtime.py +294 -0
  12. wt_task-0.2.0/src/wt_task/cache/_settings.py +213 -0
  13. wt_task-0.2.0/src/wt_task/cache/_spec.py +194 -0
  14. wt_task-0.2.0/src/wt_task/cache/_store.py +135 -0
  15. wt_task-0.2.0/src/wt_task/cache/_tree.py +519 -0
  16. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/sync_task.py +16 -9
  17. {wt_task-0.1.3 → wt_task-0.2.0/src/wt_task.egg-info}/PKG-INFO +6 -2
  18. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task.egg-info/SOURCES.txt +19 -0
  19. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task.egg-info/requires.txt +6 -1
  20. wt_task-0.2.0/src/wt_task.egg-info/scm_file_list.json +50 -0
  21. wt_task-0.2.0/src/wt_task.egg-info/scm_version.json +8 -0
  22. wt_task-0.2.0/tests/test_cache.py +1381 -0
  23. wt_task-0.2.0/tests/test_codec.py +174 -0
  24. wt_task-0.2.0/tests/test_depfingerprint.py +375 -0
  25. wt_task-0.2.0/tests/test_io_ttl.py +338 -0
  26. wt_task-0.2.0/tests/test_report.py +170 -0
  27. wt_task-0.2.0/tests/test_settings.py +246 -0
  28. wt_task-0.2.0/tests/test_tree.py +285 -0
  29. {wt_task-0.1.3 → wt_task-0.2.0}/uv.lock +138 -22
  30. wt_task-0.1.3/CHANGELOG.md +0 -17
  31. {wt_task-0.1.3 → wt_task-0.2.0}/README.md +0 -0
  32. {wt_task-0.1.3 → wt_task-0.2.0}/setup.cfg +0 -0
  33. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/__init__.py +0 -0
  34. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/async_task.py +0 -0
  35. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/decorator.py +0 -0
  36. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/exceptions.py +0 -0
  37. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/executors/__init__.py +0 -0
  38. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/executors/base.py +0 -0
  39. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/executors/python.py +0 -0
  40. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/py.typed +0 -0
  41. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/skip.py +0 -0
  42. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/testing.py +0 -0
  43. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/tracing/__init__.py +0 -0
  44. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/tracing/_config.py +0 -0
  45. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task/tracing/_decorator.py +0 -0
  46. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task.egg-info/dependency_links.txt +0 -0
  47. {wt_task-0.1.3 → wt_task-0.2.0}/src/wt_task.egg-info/top_level.txt +0 -0
  48. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_decorator.py +0 -0
  49. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_exceptions.py +0 -0
  50. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_executors.py +0 -0
  51. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_map.py +0 -0
  52. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_mapvalues.py +0 -0
  53. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_partial.py +0 -0
  54. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_skip.py +0 -0
  55. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_testing.py +0 -0
  56. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_tracing.py +0 -0
  57. {wt_task-0.1.3 → wt_task-0.2.0}/tests/test_validation.py +0 -0
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## v0.2.0 — 2026-11-03
4
+
5
+ - Add a content-addressed step cache: `.cached(CacheSpec)` keys a step's result on code identity, fixed inputs and upstream output digests. Identical calls share entries across workflows, and an unchanged output stops recomputation downstream ([#275](https://github.com/wildlife-dynamics/wt/pull/275))
6
+ - Add `CacheSettings` for runtime cache configuration (`WT_CACHE__*` env vars, `.env`, `pyproject.toml`): store, epoch, scope, io TTL, verify, strict ([#275](https://github.com/wildlife-dynamics/wt/pull/275))
7
+ - Each run writes a per-step cache report (`cache-report.json`), including how far a failed run got ([#275](https://github.com/wildlife-dynamics/wt/pull/275))
8
+ - New dependencies: `packaging` and `pydantic-settings`. Object-store backends need the new `cache` extra (`obstore`)
9
+ - Raise the `wt-contracts` floor to `>=0.3.0`
10
+
11
+ ## v0.1.3 — 2026-05-13
12
+
13
+ - Standardize ruff lint config to enforce type annotations and Google-style docstrings; convert module/class/function docstrings accordingly ([#155](https://github.com/wildlife-dynamics/wt/pull/155))
14
+
15
+ ## v0.1.2 — 2026-03-13
16
+
17
+ - `configure_tracer()` and `attach_context()` now warn instead of raising `ImportError` when tracing deps are missing ([#60](https://github.com/wildlife-dynamics/wt/pull/60))
18
+
19
+ ## v0.1.1 — 2026-03-05
20
+
21
+ - Fix incorrect license metadata
22
+
23
+ ## v0.1.0 — 2026-03-05
24
+
25
+ - Initial release
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wt-task
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: Task decorator and execution features for wt ecosystem
5
5
  Author: Ecoscope Workflows Team
6
6
  License: BSD-3-Clause
@@ -14,9 +14,13 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Requires-Python: >=3.10
16
16
  Description-Content-Type: text/markdown
17
- Requires-Dist: wt-contracts<1.0.0,>=0.1.0
17
+ Requires-Dist: wt-contracts<1.0.0,>=0.3.0
18
18
  Requires-Dist: pydantic<3.0.0,>=2.0.0
19
+ Requires-Dist: packaging>=23.0
20
+ Requires-Dist: pydantic-settings<3.0.0,>=2.3.0
19
21
  Requires-Dist: typing-extensions>=4.0.0; python_version < "3.11"
22
+ Provides-Extra: cache
23
+ Requires-Dist: obstore>=0.6.0; extra == "cache"
20
24
  Provides-Extra: gcp
21
25
  Requires-Dist: opentelemetry-api>=1.20.0; extra == "gcp"
22
26
  Requires-Dist: opentelemetry-sdk>=1.20.0; extra == "gcp"
@@ -23,12 +23,19 @@ classifiers = [
23
23
  "Programming Language :: Python :: 3.12",
24
24
  ]
25
25
  dependencies = [
26
- "wt-contracts>=0.1.0,<1.0.0",
26
+ "wt-contracts>=0.3.0,<1.0.0",
27
27
  "pydantic>=2.0.0,<3.0.0",
28
+ # Parses the requirement metadata the cache key's dependency fingerprint walks.
29
+ "packaging>=23.0",
30
+ # Backs CacheSettings: env, .env and pyproject.toml sources for the cache knobs.
31
+ "pydantic-settings>=2.3.0,<3.0.0",
28
32
  "typing-extensions>=4.0.0; python_version < '3.11'",
29
33
  ]
30
34
 
31
35
  [project.optional-dependencies]
36
+ cache = [
37
+ "obstore>=0.6.0",
38
+ ]
32
39
  gcp = [
33
40
  "opentelemetry-api>=1.20.0",
34
41
  "opentelemetry-sdk>=1.20.0",
@@ -89,6 +96,13 @@ module = [
89
96
  ]
90
97
  ignore_missing_imports = true
91
98
 
99
+ [[tool.mypy.overrides]]
100
+ module = [
101
+ "obstore",
102
+ "obstore.*",
103
+ ]
104
+ ignore_missing_imports = true
105
+
92
106
  [[tool.mypy.overrides]]
93
107
  module = [
94
108
  "wt_task.tracing._config",
@@ -101,6 +115,8 @@ line-length = 100
101
115
  target-version = "py310"
102
116
 
103
117
  [tool.ruff.lint]
118
+ # Ruff's legacy defaults; extend-select below is the source of truth.
119
+ select = ["E4", "E7", "E9", "F"]
104
120
  extend-select = [
105
121
  # already-explicit families (kept; F dropped — it's a default)
106
122
  "E", # pycodestyle errors (extends defaults E4/E7/E9 with E1/E2/E5)
@@ -159,7 +175,7 @@ convention = "google"
159
175
 
160
176
  [tool.pixi.package]
161
177
  name = "wt-task"
162
- version = "0.1.3"
178
+ version = "0.2.0"
163
179
 
164
180
  [tool.pixi.package.build]
165
181
  backend = { name = "pixi-build-python", version = "*", channels = ["conda-forge", "https://prefix.dev/pixi-build-backends"] }
@@ -176,4 +192,6 @@ pip = "*"
176
192
  [tool.pixi.package.run-dependencies]
177
193
  python = ">=3.10,<3.16"
178
194
  pydantic = ">=2.0.0,<3.0.0"
179
- wt-contracts = ">=0.1.0,<1.0.0"
195
+ packaging = ">=23.0"
196
+ pydantic-settings = ">=2.3.0,<3.0.0"
197
+ wt-contracts = ">=0.3.0,<1.0.0"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.3'
22
- __version_tuple__ = version_tuple = (0, 1, 3)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g5f0f25f'
24
+ __commit_id__ = commit_id = 'gf28c604'
@@ -7,10 +7,11 @@ for SyncTask and AsyncTask implementations.
7
7
  from __future__ import annotations
8
8
 
9
9
  import functools
10
+ import inspect
10
11
  import sys
11
12
  import warnings
12
- from dataclasses import dataclass, replace
13
- from typing import TYPE_CHECKING, Generic, Literal, ParamSpec, TypeVar, overload
13
+ from dataclasses import dataclass, field, replace
14
+ from typing import TYPE_CHECKING, Any, Generic, Literal, ParamSpec, TypeVar, overload
14
15
 
15
16
  if sys.version_info >= (3, 11):
16
17
  from typing import Self
@@ -21,10 +22,12 @@ if TYPE_CHECKING:
21
22
  from collections.abc import Callable
22
23
 
23
24
  from .async_task import AsyncTask
25
+ from .cache import CacheSpec, TaskEncoding
24
26
  from .sync_task import SyncTask
25
27
 
26
28
  from pydantic import validate_call
27
29
 
30
+ from .cache import cached_call, normalized_params, resolve_task, step_params
28
31
  from .exceptions import handle_errors as handle_errors_fn
29
32
  from .executors import AsyncExecutor, SyncExecutor
30
33
  from .executors.python import PythonExecutor
@@ -35,6 +38,10 @@ P = ParamSpec("P")
35
38
  R = TypeVar("R")
36
39
  K = TypeVar("K")
37
40
  V = TypeVar("V")
41
+ # Scoped to `_through_cache`, not a class parameter: it passes through call's R
42
+ # and map's Sequence[R] alike. Module-level only for <3.12, which lacks
43
+ # per-function type parameters.
44
+ T = TypeVar("T")
38
45
 
39
46
 
40
47
  @dataclass(frozen=True)
@@ -56,12 +63,22 @@ class _Task(Generic[P, R, K, V]):
56
63
  tags: List of tags for categorization
57
64
  description: Optional description of the task
58
65
  task_instance_id: Optional unique identifier for this task instance
66
+ cache_spec: Optional cache identity for this step, set by the compiler when
67
+ the workflow is compiled with caching enabled
68
+ encoding: What `.cached()` worked out from the wrapped function
59
69
  """
60
70
 
61
71
  func: Callable[P, R]
62
72
  tags: list[str]
63
73
  description: str | None
64
74
  task_instance_id: str | None
75
+ # kw_only so that a default here does not force one onto `AsyncTask.executor`,
76
+ # which has no sensible default.
77
+ cache_spec: CacheSpec | None = field(default=None, kw_only=True)
78
+ # Set by `validate`: the function then receives validated arguments, so the
79
+ # cache can key on them instead of on their raw spelling.
80
+ validated: bool = field(default=False, kw_only=True)
81
+ encoding: TaskEncoding | None = field(default=None, kw_only=True, repr=False, compare=False)
65
82
 
66
83
  def set_task_instance_id(self, task_instance_id: str, /) -> Self:
67
84
  """Return a new Task with the task_instance_id set.
@@ -148,6 +165,7 @@ class _Task(Generic[P, R, K, V]):
148
165
  validate_return=True,
149
166
  config={"arbitrary_types_allowed": True},
150
167
  ),
168
+ validated=True,
151
169
  )
152
170
 
153
171
  def with_tracing(self) -> Self:
@@ -242,6 +260,65 @@ class _Task(Generic[P, R, K, V]):
242
260
  func=skipif_fn(self.func, conditions=conditions, unpack_depth=unpack_depth),
243
261
  )
244
262
 
263
+ def cached(self, spec: CacheSpec) -> Self:
264
+ """Return a new Task that resolves its result through the step cache.
265
+
266
+ Unlike the other builders this sets a field rather than wrapping `func`:
267
+ `map` and `mapvalues` call `func` once per item, so a wrapper would key
268
+ per item, and every item of one mapped step shares a single upstream
269
+ identity. Caching at the method level keeps one entry per step.
270
+
271
+ The rest of the step's identity — where the task lives, and how its
272
+ result encodes — is worked out here from the wrapped function, which
273
+ is why this runs before `.partial()` erases it.
274
+
275
+ Args:
276
+ spec: Compile-time cache identity for this task instance
277
+
278
+ Returns:
279
+ New task carrying the cache spec
280
+
281
+ Examples:
282
+ >>> from wt_task import task
283
+ >>> from wt_task.cache import CacheSpec
284
+ >>> @task
285
+ ... def add(a: int, b: int) -> int:
286
+ ... return a + b
287
+ >>> cached = add.cached(CacheSpec(signature="abc123"))
288
+ >>> cached.encoding.function
289
+ 'add'
290
+ >>> cached.call(1, 2) # inert with WT_CACHE__STORE unset
291
+ 3
292
+ """
293
+ return replace(self, cache_spec=spec, encoding=resolve_task(inspect.unwrap(self.func)))
294
+
295
+ def _through_cache(
296
+ self,
297
+ run: Callable[[], T],
298
+ span: Any = None, # noqa: ANN401 # an OTel span, or None when tracing is unavailable
299
+ method: str = "call",
300
+ ) -> T:
301
+ """Resolve `run()` through the cache when one is configured.
302
+
303
+ Shared by every execution method so that `call`, `map` and `mapvalues`
304
+ agree on what one cache entry covers: the whole step. The method is
305
+ passed in rather than stored, because it is the call that picks it.
306
+ """
307
+ if self.cache_spec is None or self.encoding is None:
308
+ return run()
309
+ task_instance_id = self.task_instance_id or ""
310
+ params = step_params(task_instance_id)
311
+ if self.validated:
312
+ # Only then is the validated value what the function receives; an
313
+ # unvalidated task may behave differently for "2" and 2.
314
+ params = normalized_params(self.func, params, self.cache_spec.upstream_inputs)
315
+ result, hit = cached_call(
316
+ self.cache_spec, run, self.encoding, task_instance_id, method, params
317
+ )
318
+ if span is not None:
319
+ span.set_attribute("cache.hit", hit)
320
+ return result
321
+
245
322
  @overload
246
323
  def set_executor(
247
324
  self,
@@ -302,9 +379,13 @@ class _Task(Generic[P, R, K, V]):
302
379
  tags=self.tags,
303
380
  description=self.description,
304
381
  task_instance_id=self.task_instance_id,
382
+ cache_spec=self.cache_spec,
383
+ encoding=self.encoding,
305
384
  executor=PythonExecutor(),
306
385
  )
307
386
  case AsyncExecutor():
387
+ # An async method hands back a future, which has no stored
388
+ # form, so the cache spec is not carried over.
308
389
  return AsyncTask(
309
390
  self.func,
310
391
  tags=self.tags,
@@ -318,6 +399,8 @@ class _Task(Generic[P, R, K, V]):
318
399
  tags=self.tags,
319
400
  description=self.description,
320
401
  task_instance_id=self.task_instance_id,
402
+ cache_spec=self.cache_spec,
403
+ encoding=self.encoding,
321
404
  executor=name_or_executor,
322
405
  )
323
406
  case _:
@@ -0,0 +1,64 @@
1
+ """Content-addressed step caching for compiled DAGs.
2
+
3
+ Opt-in at compile time. A step is keyed by its code, its arguments, and the
4
+ content digests of the steps feeding it, and a hit returns the stored value
5
+ instead of executing. What a result *is* comes from the task's return type —
6
+ `wt_contracts.declared_structure` resolves it at run time, and
7
+ `resolve_structure` turns it into the `Tree` that encodes the whole step.
8
+ Everything degrades to running normally: with `WT_CACHE__STORE` unset, or for any
9
+ return type that declares no codec, the cache is inert.
10
+
11
+ Runtime knobs live in `CacheSettings`, read once per `run_scope`.
12
+ """
13
+
14
+ from ._codec import BYTES, JSON, PATH, SKIP, URL, Codec, Undigestible, enum_codec
15
+ from ._entry import CacheEntry
16
+ from ._key import (
17
+ CacheRecord,
18
+ normalized_params,
19
+ record,
20
+ run_report,
21
+ run_scope,
22
+ step_identity,
23
+ step_params,
24
+ )
25
+ from ._report import REPORT_NAME, cache_report, put_cache_report
26
+ from ._runtime import CacheCorruptionError, CacheMismatchWarning, cached_call
27
+ from ._settings import CacheSettings, load_settings
28
+ from ._spec import CacheSpec, TaskEncoding, resolve_task
29
+ from ._store import open_store
30
+ from ._tree import StructureMismatch, Tree, resolve_structure
31
+
32
+ __all__ = [
33
+ "BYTES",
34
+ "JSON",
35
+ "PATH",
36
+ "REPORT_NAME",
37
+ "SKIP",
38
+ "URL",
39
+ "CacheCorruptionError",
40
+ "CacheEntry",
41
+ "CacheMismatchWarning",
42
+ "CacheRecord",
43
+ "CacheSettings",
44
+ "CacheSpec",
45
+ "Codec",
46
+ "StructureMismatch",
47
+ "TaskEncoding",
48
+ "Tree",
49
+ "Undigestible",
50
+ "cache_report",
51
+ "cached_call",
52
+ "enum_codec",
53
+ "load_settings",
54
+ "normalized_params",
55
+ "open_store",
56
+ "put_cache_report",
57
+ "record",
58
+ "resolve_structure",
59
+ "resolve_task",
60
+ "run_report",
61
+ "run_scope",
62
+ "step_identity",
63
+ "step_params",
64
+ ]
@@ -0,0 +1,175 @@
1
+ """Leaf codecs: how one declared value becomes bytes, and back.
2
+
3
+ Which codec applies is not decided here — the task's return type declares it
4
+ (`wt_contracts.codecs`) and `_tree` resolves it. This module only holds the
5
+ builtin leaves and the shape a task library's own codecs must have.
6
+
7
+ There is deliberately no pickle fallback: pickle couples every stored entry to
8
+ the exact version of the code that wrote it and turns the cache into a
9
+ deserialization surface. Refusing is always safe — the step runs normally.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import pathlib
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from pydantic_core import Url
21
+
22
+ from wt_task.skip import SKIP_SENTINEL, SkipSentinel
23
+
24
+ if TYPE_CHECKING:
25
+ import enum
26
+ from collections.abc import Callable
27
+
28
+
29
+ class Undigestible(Exception): # noqa: N818 # named for what a codec does, not for a failure
30
+ """Raised by a codec that cannot handle the value it was handed.
31
+
32
+ The step then runs normally and records itself uncacheable, so a codec can
33
+ decline a value (an unhashable cell, a subclass it does not cover) without
34
+ the caller having to probe it first.
35
+ """
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Codec:
40
+ """Encodes one family of runtime values to bytes, and digests them.
41
+
42
+ `digest` is separate from `encode` on purpose: it must be stable across
43
+ encodings, because an unstable digest silently invalidates every downstream
44
+ cache entry on each run. Parquet in particular is not byte-stable, so a
45
+ frame codec digests a canonical form rather than the bytes it writes.
46
+
47
+ Attributes:
48
+ codec_id: Stable identifier recorded in the payload header. Changing it
49
+ makes old entries miss rather than decode wrongly.
50
+ media_type: Media type of the encoded payload.
51
+ types: Runtime types this codec accepts. Used only to pick an arm of a
52
+ declared union; at a non-union position the declaration already
53
+ settled it.
54
+ encode: Value to bytes.
55
+ decode: Bytes back to a value.
56
+ digest: Stable content digest. May raise `Undigestible`.
57
+ accepts_subclasses: Match with `isinstance` rather than
58
+ `type(value) is types[0]`. Cleared by class-parameterized codecs,
59
+ which must not accept a subclass their encoder would silently narrow.
60
+ """
61
+
62
+ codec_id: str
63
+ media_type: str
64
+ types: tuple[type, ...]
65
+ encode: Callable[[Any], bytes]
66
+ decode: Callable[[bytes], Any]
67
+ digest: Callable[[Any], str]
68
+ accepts_subclasses: bool = True
69
+
70
+
71
+ def json_digest(value: Any) -> str: # noqa: ANN401 # digests arbitrary JSON-able values
72
+ """Return a stable sha256 over a JSON-able value, stringifying what it must.
73
+
74
+ The lenient `default=str` is for cache *keys*, whose inputs are compiler
75
+ literals and params rather than task results. Payload digests use the
76
+ strict `JSON` codec instead.
77
+
78
+ Examples:
79
+ >>> json_digest({"b": 1, "a": 2}) == json_digest({"a": 2, "b": 1})
80
+ True
81
+ """
82
+ return hashlib.sha256(
83
+ json.dumps(value, sort_keys=True, default=str).encode("utf-8")
84
+ ).hexdigest()
85
+
86
+
87
+ def _strict_json(value: Any) -> bytes: # noqa: ANN401 # encodes arbitrary JSON-able values
88
+ """Serialize `value` as JSON, declining anything JSON cannot hold losslessly."""
89
+ try:
90
+ return json.dumps(value, sort_keys=True, allow_nan=False).encode("utf-8")
91
+ except (TypeError, ValueError) as e:
92
+ raise Undigestible(f"not JSON-native: {e}") from e
93
+
94
+
95
+ JSON = Codec(
96
+ codec_id="json/v1",
97
+ media_type="application/json",
98
+ types=(str, int, float, bool, list, dict, type(None)),
99
+ encode=_strict_json,
100
+ decode=json.loads,
101
+ digest=lambda v: hashlib.sha256(_strict_json(v)).hexdigest(),
102
+ )
103
+ """JSON-native values. Reached only through an explicit `CodecRef`, as the
104
+ declaration that a loosely-typed return really does hold plain JSON."""
105
+
106
+ BYTES = Codec(
107
+ codec_id="bytes/v1",
108
+ media_type="application/octet-stream",
109
+ types=(bytes,),
110
+ encode=lambda v: v,
111
+ decode=lambda d: d,
112
+ digest=lambda v: hashlib.sha256(v).hexdigest(),
113
+ )
114
+
115
+ PATH = Codec(
116
+ codec_id="path/v1",
117
+ media_type="text/plain",
118
+ types=(pathlib.PurePath,),
119
+ encode=lambda v: str(v).encode("utf-8"),
120
+ decode=lambda d: pathlib.Path(d.decode("utf-8")),
121
+ digest=lambda v: hashlib.sha256(str(v).encode("utf-8")).hexdigest(),
122
+ )
123
+ """Paths travel as the path, not as the bytes they point at. A task returning
124
+ one is claiming the location is the result; `no-cache` is the way to say
125
+ otherwise."""
126
+
127
+ URL = Codec(
128
+ codec_id="url/v1",
129
+ media_type="text/plain",
130
+ types=(Url,),
131
+ encode=lambda v: str(v).encode("utf-8"),
132
+ decode=lambda d: Url(d.decode("utf-8")),
133
+ digest=lambda v: hashlib.sha256(str(v).encode("utf-8")).hexdigest(),
134
+ )
135
+
136
+ _SKIP_MARKER = b'{"wt_task":"skip"}'
137
+
138
+ SKIP = Codec(
139
+ codec_id="skip/v1",
140
+ media_type="application/json",
141
+ types=(SkipSentinel,),
142
+ encode=lambda _v: _SKIP_MARKER,
143
+ decode=lambda _d: SKIP_SENTINEL,
144
+ digest=lambda _v: hashlib.sha256(_SKIP_MARKER).hexdigest(),
145
+ )
146
+ """A skipped step is cached like any other result. Without this the sentinel
147
+ would have no codec and every step below a skip would lose its cache."""
148
+
149
+
150
+ def enum_codec(cls: type[enum.Enum]) -> Codec:
151
+ """Build the codec for one enum class: the member's value, round-tripped.
152
+
153
+ Args:
154
+ cls: The enum class, as named by the declared structure.
155
+
156
+ Returns:
157
+ A codec accepting exactly `cls`.
158
+
159
+ Examples:
160
+ >>> import enum
161
+ >>> class Unit(enum.Enum):
162
+ ... km = "km"
163
+ >>> codec = enum_codec(Unit)
164
+ >>> codec.decode(codec.encode(Unit.km))
165
+ <Unit.km: 'km'>
166
+ """
167
+ return Codec(
168
+ codec_id=f"enum/v1/{cls.__module__}.{cls.__qualname__}",
169
+ media_type="application/json",
170
+ types=(cls,),
171
+ encode=lambda v: _strict_json(v.value),
172
+ decode=lambda d: cls(json.loads(d)),
173
+ digest=lambda v: hashlib.sha256(_strict_json(v.value)).hexdigest(),
174
+ accepts_subclasses=False,
175
+ )
@@ -0,0 +1,34 @@
1
+ """The metadata record stored alongside a cached payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ def utcnow() -> datetime:
11
+ """Now, in UTC. One place, so the freshness check can be driven in tests."""
12
+ return datetime.now(timezone.utc)
13
+
14
+
15
+ class CacheEntry(BaseModel):
16
+ """Points a step's key at its payload, and says how to read it back.
17
+
18
+ Keys and payloads are stored separately so that two steps computing the same
19
+ value share one payload, and so that an entry still describes what a step
20
+ produced after its payload has been evicted.
21
+
22
+ Attributes:
23
+ content_id: `<codec>/<hex>`: the step's root codec and the value's
24
+ content digest, which together locate the payload under `content/`.
25
+ codec_id: Codec that wrote the payload and must read it back.
26
+ media_type: Media type of the payload.
27
+ created_at: When the entry was written. Read by the freshness
28
+ check that `WT_CACHE__IO_TTL` puts on io-tagged steps.
29
+ """
30
+
31
+ content_id: str
32
+ codec_id: str
33
+ media_type: str
34
+ created_at: datetime = Field(default_factory=utcnow)