doeff-core-effects 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,85 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ .venv/
10
+ ENV/
11
+ env.bak/
12
+ venv.bak/
13
+ .env
14
+ .env.*
15
+
16
+ # Distribution / packaging
17
+ .Python
18
+ build/
19
+ develop-eggs/
20
+ dist/
21
+ downloads/
22
+ eggs/
23
+ .eggs/
24
+ lib/
25
+ lib64/
26
+ parts/
27
+ sdist/
28
+ var/
29
+ wheels/
30
+ *.egg-info/
31
+ .installed.cfg
32
+ *.egg
33
+ MANIFEST
34
+
35
+ # Testing
36
+ .pytest_cache/
37
+ .coverage
38
+ .coverage.*
39
+ coverage.xml
40
+ *.cover
41
+ .hypothesis/
42
+ htmlcov/
43
+ .tox/
44
+
45
+ # IDE
46
+ .idea/
47
+ .vscode/
48
+ *.swp
49
+ *.swo
50
+ *~
51
+ .DS_Store
52
+
53
+ # UV
54
+ uv.lock
55
+ .venv/
56
+
57
+ # Ruff
58
+ .ruff_cache/
59
+
60
+ # MyPy
61
+ .mypy_cache/
62
+ .dmypy.json
63
+ dmypy.json
64
+
65
+ # Rust build artifacts
66
+ rust/**/target/
67
+ packages/**/target/
68
+
69
+ # Local deploy keys
70
+ proboscis_doeff_deploy
71
+ proboscis_doeff_deploy.pub
72
+
73
+ # Worktrees and orchestration
74
+ .git-worktrees/
75
+ .orch/
76
+ .sisyphus/
77
+ VAULT
78
+
79
+ # Override global gitignore for examples
80
+ !examples/
81
+ ORCH_PROMPT.md
82
+ .worktrees/
83
+
84
+ # Local workspace artifacts
85
+ python/
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: doeff-core-effects
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: doeff
6
+ Requires-Dist: frozendict>=2.4.0
@@ -0,0 +1,42 @@
1
+ """
2
+ doeff-core-effects — reference implementation of effects and handlers.
3
+
4
+ This package provides:
5
+ - Core effects: Ask, Get, Put, Tell
6
+ - Core handlers: reader, state, writer
7
+ - Scheduler: Spawn, Wait, Gather, Race, Cancel, Promise, ExternalPromise, Semaphore
8
+ """
9
+
10
+ from doeff_core_effects.effects import (
11
+ Ask, Get, Put, Tell, Try, Slog, WriterTellEffect,
12
+ Local, Listen, Await, slog,
13
+ )
14
+ from doeff_core_effects.handlers import (
15
+ reader, state, writer, try_handler, slog_handler,
16
+ local_handler, listen_handler, await_handler,
17
+ lazy_ask, env_var_ask,
18
+ )
19
+ from doeff_core_effects.scheduler import (
20
+ Cancel,
21
+ CreateExternalPromise,
22
+ CreatePromise,
23
+ CreateSemaphore,
24
+ AcquireSemaphore,
25
+ ReleaseSemaphore,
26
+ CompletePromise,
27
+ ExternalPromise,
28
+ FailPromise,
29
+ Future,
30
+ Gather,
31
+ PRIORITY_HIGH,
32
+ PRIORITY_IDLE,
33
+ PRIORITY_NORMAL,
34
+ Promise,
35
+ Race,
36
+ Semaphore,
37
+ Spawn,
38
+ Task,
39
+ TaskCancelledError,
40
+ Wait,
41
+ scheduled,
42
+ )
@@ -0,0 +1,172 @@
1
+ """Cache decorator for doeff programs using cache effects.
2
+
3
+ The @cache decorator memoizes @do function results via CacheGet/CachePut effects.
4
+ Requires a cache handler (cache_handler, in_memory_cache_handler, or sqlite_cache_handler)
5
+ to be installed via WithHandler.
6
+
7
+ NOTE: The full @cache decorator with execution context-based call site attribution
8
+ is not yet ported. This module provides the core cache helpers and a simplified
9
+ @cache decorator.
10
+ """
11
+
12
+ import os
13
+ import tempfile
14
+ from collections.abc import Callable, Mapping
15
+ from pathlib import Path
16
+ from typing import Any, TypeVar
17
+
18
+ from frozendict import frozendict as FrozenDict
19
+
20
+ from doeff import UnhandledEffect, do
21
+ from doeff_core_effects.memo_effects import MemoExists, MemoGet, MemoPut
22
+ from doeff_core_effects.memo_policy import Lifecycle, MemoPolicy, ensure_memo_policy
23
+
24
+ T = TypeVar("T")
25
+
26
+ CACHE_PATH_ENV_KEY = "DOEFF_CACHE_PATH"
27
+
28
+
29
+ def persistent_cache_path() -> Path:
30
+ """Return the persistent cache path from env or default."""
31
+ env_path = os.environ.get(CACHE_PATH_ENV_KEY)
32
+ if env_path:
33
+ return Path(env_path)
34
+ return Path(tempfile.gettempdir()) / "doeff_cache"
35
+
36
+
37
+ def clear_persistent_cache() -> None:
38
+ """Clear the persistent cache directory."""
39
+ path = persistent_cache_path()
40
+ if path.exists():
41
+ import shutil
42
+ shutil.rmtree(path)
43
+
44
+
45
+ def _function_identifier(target: Any) -> str:
46
+ """Return a descriptive identifier for a callable (module.qualname)."""
47
+ module = getattr(target, "__module__", None)
48
+ qualname = getattr(target, "__qualname__", None)
49
+ name = getattr(target, "__name__", None)
50
+
51
+ parts: list[str] = []
52
+ if module:
53
+ parts.append(module)
54
+ if qualname:
55
+ parts.append(qualname)
56
+ elif name:
57
+ parts.append(name)
58
+
59
+ if parts:
60
+ return ".".join(parts)
61
+
62
+ func_attr = getattr(target, "func", None)
63
+ if func_attr is not None and func_attr is not target:
64
+ inner = _function_identifier(func_attr)
65
+ cls = target.__class__
66
+ return f"{cls.__module__}.{cls.__qualname__}({inner})"
67
+
68
+ cls = target.__class__
69
+ return f"{cls.__module__}.{cls.__qualname__}"
70
+
71
+
72
+ def default_cache_key(func_name: str, args: tuple, kwargs: FrozenDict) -> tuple:
73
+ """Default cache key: (func_name, args, FrozenDict(kwargs))."""
74
+ return (func_name, args, kwargs)
75
+
76
+
77
+ def cache(
78
+ ttl: float | None = None,
79
+ key_func: Callable | None = None,
80
+ *,
81
+ lifecycle: Lifecycle | str | None = None,
82
+ metadata: Mapping[str, Any] | None = None,
83
+ policy: MemoPolicy | Mapping[str, Any] | None = None,
84
+ ) -> Callable:
85
+ """Cache decorator that uses MemoGet/MemoPut effects for memoization.
86
+
87
+ The cache key defaults to ``(func_name, args, FrozenDict(kwargs))`` where
88
+ ``func_name`` is the fully qualified module path of the wrapped callable.
89
+
90
+ Args:
91
+ ttl: Expiry in seconds. None means no expiry.
92
+ key_func: Optional key builder. Receives (func_name, args, FrozenDict(kwargs)).
93
+ lifecycle: Lifecycle hint for custom handlers.
94
+ metadata: Extra metadata for custom handlers.
95
+ policy: Full MemoPolicy (overrides ttl/lifecycle/metadata).
96
+ """
97
+ memo_policy = ensure_memo_policy(
98
+ ttl=ttl,
99
+ lifecycle=lifecycle,
100
+ metadata=metadata,
101
+ policy=policy,
102
+ )
103
+
104
+ def decorator(func: Callable) -> Callable:
105
+ func_name = _function_identifier(func)
106
+
107
+ @do
108
+ def cached_fn(*args, **kwargs):
109
+ frozen_kwargs = FrozenDict(kwargs) if kwargs else FrozenDict()
110
+ cache_key_obj = (
111
+ key_func(func_name, args, frozen_kwargs)
112
+ if key_func
113
+ else (func_name, args, frozen_kwargs)
114
+ )
115
+
116
+ # Memo handler is optional. If absent (UnhandledEffect on MemoExists/Get)
117
+ # or storage misses (KeyError), fall through to compute. The
118
+ # yield func(...) below is NOT wrapped: its failures must propagate.
119
+ try:
120
+ if (yield MemoExists(cache_key_obj)):
121
+ try:
122
+ cached_value = yield MemoGet(cache_key_obj)
123
+ return cached_value
124
+ except KeyError:
125
+ pass
126
+ except UnhandledEffect:
127
+ pass
128
+
129
+ # Memo miss — run the function
130
+ result = yield func(*args, **kwargs)
131
+
132
+ # Best-effort store: if no memo handler is installed, swallow. The
133
+ # compute result returned above is authoritative.
134
+ try:
135
+ yield MemoPut(cache_key_obj, result, policy=memo_policy)
136
+ except UnhandledEffect:
137
+ pass
138
+ return result
139
+
140
+ # Preserve metadata
141
+ for attr in ("__name__", "__qualname__", "__doc__", "__module__"):
142
+ val = getattr(func, attr, None)
143
+ if val is not None:
144
+ try:
145
+ setattr(cached_fn, attr, val)
146
+ except (AttributeError, TypeError):
147
+ pass
148
+
149
+ return cached_fn
150
+
151
+ return decorator
152
+
153
+
154
+ # Convenience presets
155
+ cache_1min = cache(ttl=60)
156
+ cache_5min = cache(ttl=300)
157
+ cache_1hour = cache(ttl=3600)
158
+ cache_forever = cache(ttl=None)
159
+
160
+
161
+ __all__ = [
162
+ "CACHE_PATH_ENV_KEY",
163
+ "FrozenDict",
164
+ "cache",
165
+ "cache_1hour",
166
+ "cache_1min",
167
+ "cache_5min",
168
+ "cache_forever",
169
+ "clear_persistent_cache",
170
+ "default_cache_key",
171
+ "persistent_cache_path",
172
+ ]
@@ -0,0 +1,116 @@
1
+ """Cache effects for memoization."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from doeff_core_effects.cache_policy import (
7
+ CacheLifecycle,
8
+ CachePolicy,
9
+ CacheStorage,
10
+ ensure_cache_policy,
11
+ )
12
+ from doeff_vm import EffectBase
13
+
14
+
15
+ class CacheGetEffect(EffectBase):
16
+ """Requests the cached value for the key."""
17
+ def __init__(self, key):
18
+ super().__init__()
19
+ self.key = key
20
+
21
+ def __repr__(self):
22
+ return f"CacheGet({self.key!r})"
23
+
24
+
25
+ class CachePutEffect(EffectBase):
26
+ """Persists the value under the key."""
27
+ def __init__(self, key, value, policy):
28
+ super().__init__()
29
+ if not isinstance(policy, CachePolicy):
30
+ raise TypeError(f"policy must be CachePolicy, got {type(policy).__name__}")
31
+ self.key = key
32
+ self.value = value
33
+ self.policy = policy
34
+
35
+ def __repr__(self):
36
+ return f"CachePut({self.key!r}, ...)"
37
+
38
+
39
+ class CacheDeleteEffect(EffectBase):
40
+ """Deletes the value under the key."""
41
+ def __init__(self, key):
42
+ super().__init__()
43
+ self.key = key
44
+
45
+ def __repr__(self):
46
+ return f"CacheDelete({self.key!r})"
47
+
48
+
49
+ class CacheExistsEffect(EffectBase):
50
+ """Checks if a key exists in the cache."""
51
+ def __init__(self, key):
52
+ super().__init__()
53
+ self.key = key
54
+
55
+ def __repr__(self):
56
+ return f"CacheExists({self.key!r})"
57
+
58
+
59
+ # Convenience constructors (lowercase)
60
+
61
+ def cache_get(key: Any) -> CacheGetEffect:
62
+ return CacheGetEffect(key=key)
63
+
64
+
65
+ def cache_put(
66
+ key: Any,
67
+ value: Any,
68
+ ttl: float | None = None,
69
+ *,
70
+ lifecycle: CacheLifecycle | str | None = None,
71
+ storage: CacheStorage | str | None = None,
72
+ metadata: Mapping[str, Any] | None = None,
73
+ policy: CachePolicy | Mapping[str, Any] | None = None,
74
+ ) -> CachePutEffect:
75
+ cache_policy = ensure_cache_policy(
76
+ ttl=ttl,
77
+ lifecycle=lifecycle,
78
+ storage=storage,
79
+ metadata=metadata,
80
+ policy=policy,
81
+ )
82
+ return CachePutEffect(key=key, value=value, policy=cache_policy)
83
+
84
+
85
+ def cache_delete(key: Any) -> CacheDeleteEffect:
86
+ return CacheDeleteEffect(key=key)
87
+
88
+
89
+ def cache_exists(key: Any) -> CacheExistsEffect:
90
+ return CacheExistsEffect(key=key)
91
+
92
+
93
+ # Capitalized aliases
94
+ CacheGet = cache_get
95
+ CachePut = cache_put
96
+ CacheDelete = cache_delete
97
+ CacheExists = cache_exists
98
+
99
+
100
+ __all__ = [
101
+ "CacheDelete",
102
+ "CacheDeleteEffect",
103
+ "CacheExists",
104
+ "CacheExistsEffect",
105
+ "CacheGet",
106
+ "CacheGetEffect",
107
+ "CacheLifecycle",
108
+ "CachePolicy",
109
+ "CachePut",
110
+ "CachePutEffect",
111
+ "CacheStorage",
112
+ "cache_delete",
113
+ "cache_exists",
114
+ "cache_get",
115
+ "cache_put",
116
+ ]
@@ -0,0 +1,222 @@
1
+ """Cache effect handlers and memoization helpers."""
2
+
3
+ import dataclasses
4
+ import hashlib
5
+ import json
6
+ from collections.abc import Callable, Mapping, Set
7
+ from pathlib import Path
8
+ from typing import Any, TypeAlias
9
+
10
+ from doeff import do
11
+ from doeff.program import Resume, Pass
12
+
13
+ from doeff_core_effects.cache_effects import (
14
+ CacheExistsEffect,
15
+ CacheGetEffect,
16
+ CachePutEffect,
17
+ CacheGet,
18
+ CachePut,
19
+ CacheExists,
20
+ )
21
+ from doeff_core_effects.effects import Try
22
+ from doeff_core_effects.storage import DurableStorage, InMemoryStorage, SQLiteStorage
23
+
24
+ MemoKeyFn: TypeAlias = Callable[[object], str]
25
+
26
+
27
+ def _dumps(value: object) -> bytes:
28
+ try:
29
+ import cloudpickle as serializer
30
+ except ModuleNotFoundError:
31
+ import pickle as serializer
32
+
33
+ return serializer.dumps(value)
34
+
35
+
36
+ def _normalize_for_hash(value: object) -> object:
37
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
38
+ return {
39
+ "__type__": f"{type(value).__module__}.{type(value).__qualname__}",
40
+ **{
41
+ field.name: _normalize_for_hash(getattr(value, field.name))
42
+ for field in dataclasses.fields(value)
43
+ },
44
+ }
45
+ elif isinstance(value, Mapping):
46
+ return {
47
+ str(key): _normalize_for_hash(item)
48
+ for key, item in sorted(value.items(), key=lambda item: str(item[0]))
49
+ }
50
+ elif isinstance(value, tuple):
51
+ return {"__tuple__": [_normalize_for_hash(item) for item in value]}
52
+ elif isinstance(value, list):
53
+ return [_normalize_for_hash(item) for item in value]
54
+ elif isinstance(value, Set) and not isinstance(value, (str, bytes, bytearray)):
55
+ normalized = [_normalize_for_hash(item) for item in value]
56
+ return {
57
+ "__set__": sorted(normalized, key=lambda item: json.dumps(item, sort_keys=True))
58
+ }
59
+ elif isinstance(value, Path):
60
+ return {"__path__": str(value)}
61
+ elif isinstance(value, bytes):
62
+ return {"__bytes__": value.hex()}
63
+ elif isinstance(value, type):
64
+ return f"{value.__module__}.{value.__qualname__}"
65
+ elif hasattr(value, "__dict__") and not isinstance(value, type):
66
+ # Generic object with __dict__ (e.g. EffectBase subclasses)
67
+ return {
68
+ "__type__": f"{type(value).__module__}.{type(value).__qualname__}",
69
+ **{
70
+ k: _normalize_for_hash(v)
71
+ for k, v in sorted(value.__dict__.items())
72
+ },
73
+ }
74
+ return value
75
+
76
+
77
+ def _storage_key(key: object) -> str:
78
+ if isinstance(key, str):
79
+ return key
80
+ return f"sha256:{content_address(key)}"
81
+
82
+
83
+ def _persist_value(
84
+ storage: DurableStorage,
85
+ storage_key: str,
86
+ *,
87
+ original_key: object,
88
+ value: object,
89
+ ) -> None:
90
+ try:
91
+ storage.put(storage_key, value)
92
+ except Exception as exc:
93
+ raise RuntimeError(
94
+ f"Cache handler could not persist value for key {original_key!r}: {exc}"
95
+ ) from exc
96
+
97
+
98
+ def content_address(effect: object) -> str:
99
+ """Return a SHA-256 content address for an effect payload."""
100
+ try:
101
+ payload = _dumps(effect)
102
+ except Exception:
103
+ payload = json.dumps(_normalize_for_hash(effect), sort_keys=True, default=repr).encode(
104
+ "utf-8"
105
+ )
106
+ return hashlib.sha256(payload).hexdigest()
107
+
108
+
109
+ def cache_handler(storage: DurableStorage):
110
+ """Interpret CacheGet/CachePut/CacheExists against a pluggable storage backend.
111
+
112
+ Storage methods return Programs (DoExpr). The handler yields them to get values.
113
+ This makes the handler transparent to sync (Pure) and async (Await) storage alike.
114
+ """
115
+
116
+ @do
117
+ def handler(effect, k):
118
+ if not isinstance(effect, (CacheGetEffect, CacheExistsEffect, CachePutEffect)):
119
+ yield Pass(effect, k)
120
+ return
121
+
122
+ key = _storage_key(effect.key)
123
+
124
+ if isinstance(effect, CacheExistsEffect):
125
+ exists = yield storage.exists(key)
126
+ result = yield Resume(k, exists)
127
+ return result
128
+
129
+ if isinstance(effect, CacheGetEffect):
130
+ value = yield storage.get(key)
131
+ if value is None:
132
+ exists = yield storage.exists(key)
133
+ if not exists:
134
+ from doeff.program import ResumeThrow
135
+ return (yield ResumeThrow(k, KeyError(effect.key)))
136
+ result = yield Resume(k, value)
137
+ return result
138
+
139
+ # CachePutEffect
140
+ yield storage.put(key, effect.value)
141
+ result = yield Resume(k, None)
142
+ return result
143
+
144
+ return handler
145
+
146
+
147
+ def in_memory_cache_handler():
148
+ """Return a cache handler backed by in-memory storage."""
149
+ return cache_handler(InMemoryStorage())
150
+
151
+
152
+ def sqlite_cache_handler(db_path: str | Path):
153
+ """Return a cache handler backed by SQLite storage."""
154
+ return cache_handler(SQLiteStorage(db_path))
155
+
156
+
157
+ def make_memo_rewriter(
158
+ effect_type: type,
159
+ key_fn: MemoKeyFn = content_address,
160
+ ):
161
+ """Create a handler that memoizes effects of the given type through cache.
162
+
163
+ On cache hit: Resume with cached value (outer handler not called).
164
+ On cache miss: re-perform effect → outer handler handles it → store result in cache.
165
+ """
166
+ from doeff_core_effects.effects import WriterTellEffect as Slog
167
+
168
+ @do
169
+ def handler(effect, k):
170
+ if not isinstance(effect, effect_type):
171
+ yield Pass(effect, k)
172
+ return
173
+
174
+ key = key_fn(effect)
175
+ yield Slog(f"[memo] checking {effect_type.__name__} key={key[:16]}…")
176
+
177
+ # Check cache — narrow try/except to CacheGet only.
178
+ # Resume(k) must NOT be inside try/except: if the resumed body raises
179
+ # KeyError, it must propagate, not fall through to the MISS path.
180
+ _MISS = object() # sentinel
181
+ if (yield CacheExists(key)):
182
+ try:
183
+ cached = yield CacheGet(key)
184
+ except KeyError:
185
+ cached = _MISS # race: evicted between Exists and Get
186
+
187
+ if cached is not _MISS:
188
+ yield Slog(f"[memo] HIT {effect_type.__name__} key={key[:16]}…")
189
+ result = yield Resume(k, cached)
190
+ return result
191
+
192
+ # Cache miss — "delegate" by re-performing the effect.
193
+ yield Slog(f"[memo] MISS {effect_type.__name__} key={key[:16]}… → delegating")
194
+ delegated = yield effect
195
+
196
+ # Store in cache
197
+ yield CachePut(key, delegated)
198
+ yield Slog(f"[memo] STORED {effect_type.__name__} key={key[:16]}…")
199
+
200
+ # Resume body with result
201
+ result = yield Resume(k, delegated)
202
+ return result
203
+
204
+ return handler
205
+
206
+
207
+ def memo_rewriters(
208
+ *effect_types: type,
209
+ key_fn: MemoKeyFn = content_address,
210
+ ) -> list:
211
+ """Create memo rewriter handlers for each provided effect type."""
212
+ return [make_memo_rewriter(et, key_fn=key_fn) for et in effect_types]
213
+
214
+
215
+ __all__ = [
216
+ "cache_handler",
217
+ "content_address",
218
+ "in_memory_cache_handler",
219
+ "make_memo_rewriter",
220
+ "memo_rewriters",
221
+ "sqlite_cache_handler",
222
+ ]