izana-core 0.0.1__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.
@@ -26,7 +26,7 @@ examples/django-react-site/harness/test-results/
26
26
  .vscode/
27
27
 
28
28
  # Build artifacts
29
- protocol/mizan-generate/bin/mizan-generate-*
29
+ protocol/izana-generate/bin/izana-generate-*
30
30
 
31
31
  # Env
32
32
  .env
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.5
2
+ Name: izana-core
3
+ Version: 0.2.0
4
+ Summary: Izana Python core — @client registry, dispatch executor, KDL IR, HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter.
5
+ License-Expression: Elastic-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: jinja2>=3.1
8
+ Requires-Dist: pydantic>=2.0
9
+ Requires-Dist: pyjwt>=2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: ckdl>=1.0; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "izana-core"
3
+ version = "0.2.0"
4
+ license = "Elastic-2.0"
5
+ description = "Izana Python core — @client registry, dispatch executor, KDL IR, HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter."
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "PyJWT>=2.0",
9
+ "jinja2>=3.1",
10
+ "pydantic>=2.0",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ dev = [
15
+ "pytest>=8.0",
16
+ "ckdl>=1.0",
17
+ ]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/izana_core"]
25
+
26
+ [tool.pytest.ini_options]
27
+ pythonpath = ["src"]
28
+ testpaths = ["tests"]
29
+ python_classes = ["*Tests", "*Test", "Test*"]
30
+ python_functions = ["test_*"]
File without changes
File without changes
@@ -0,0 +1,110 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ import abc
5
+
6
+
7
+ class CacheBackend(abc.ABC):
8
+
9
+ @abc.abstractmethod
10
+ def get(self, key: str) -> bytes | None:
11
+ ...
12
+
13
+ @abc.abstractmethod
14
+ def set(self, key: str, value: bytes) -> None:
15
+ ...
16
+
17
+ @abc.abstractmethod
18
+ def delete(self, key: str) -> bool:
19
+ ...
20
+
21
+ @abc.abstractmethod
22
+ def delete_by_prefix(self, prefix: str) -> int:
23
+ ...
24
+
25
+ @abc.abstractmethod
26
+ def clear(self) -> None:
27
+ ...
28
+
29
+
30
+ class MemoryCache(CacheBackend):
31
+
32
+ def __init__(self) -> None:
33
+ self._store: dict[str, bytes] = {}
34
+
35
+ def get(self, key: str) -> bytes | None:
36
+ return self._store.get(key)
37
+
38
+ def set(self, key: str, value: bytes) -> None:
39
+ self._store[key] = value
40
+
41
+ def delete(self, key: str) -> bool:
42
+ if key in self._store:
43
+ del self._store[key]
44
+ return True
45
+ return False
46
+
47
+ def delete_by_prefix(self, prefix: str) -> int:
48
+ to_delete = [k for k in self._store if k.startswith(prefix)]
49
+ for k in to_delete:
50
+ del self._store[k]
51
+ return len(to_delete)
52
+
53
+ def clear(self) -> None:
54
+ self._store.clear()
55
+
56
+
57
+ class RedisCache(CacheBackend):
58
+
59
+ DEFAULT_TTL = 86400 # 24h safety-net
60
+
61
+ def __init__(
62
+ self,
63
+ redis_url: str,
64
+ prefix: str = "izana:",
65
+ ttl: int | None = None,
66
+ ) -> None:
67
+ try:
68
+ import redis as redis_lib
69
+ except ImportError as exc:
70
+ raise ImportError(
71
+ "Redis is required for Izana's cache backend. "
72
+ "Install it with: pip install izana[cache]"
73
+ ) from exc
74
+ self._client = redis_lib.from_url(
75
+ redis_url,
76
+ socket_connect_timeout=5,
77
+ socket_timeout=5,
78
+ health_check_interval=30,
79
+ retry_on_timeout=True,
80
+ max_connections=50,
81
+ )
82
+ self._prefix = prefix
83
+ self._ttl = ttl if ttl is not None else self.DEFAULT_TTL
84
+
85
+ def _key(self, key: str) -> str:
86
+ return f"{self._prefix}{key}"
87
+
88
+ def get(self, key: str) -> bytes | None:
89
+ return self._client.get(self._key(key))
90
+
91
+ def set(self, key: str, value: bytes) -> None:
92
+ self._client.set(self._key(key), value, ex=self._ttl)
93
+
94
+ def delete(self, key: str) -> bool:
95
+ return self._client.unlink(self._key(key)) > 0
96
+
97
+ def delete_by_prefix(self, prefix: str) -> int:
98
+ pattern = f"{self._prefix}{prefix}*"
99
+ count = 0
100
+ cursor = 0
101
+ while True:
102
+ cursor, keys = self._client.scan(cursor, match=pattern, count=1000)
103
+ if keys:
104
+ count += self._client.unlink(*keys)
105
+ if cursor == 0:
106
+ break
107
+ return count
108
+
109
+ def clear(self) -> None:
110
+ self.delete_by_prefix("")
@@ -0,0 +1,41 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ from typing import Any
8
+
9
+ CONTEXT_KEY_PREFIX = "ctx:"
10
+
11
+
12
+ def derive_cache_key(
13
+ secret: str,
14
+ context: str,
15
+ params: dict[str, Any],
16
+ user_id: str | None = None,
17
+ rev: int = 0,
18
+ ) -> str:
19
+ def _normalize(v: Any) -> str:
20
+ if v is True:
21
+ return "true"
22
+ if v is False:
23
+ return "false"
24
+ if v is None:
25
+ return "null"
26
+ return str(v)
27
+
28
+ sorted_params = {k: _normalize(v) for k, v in sorted(params.items())}
29
+
30
+ key_data: dict[str, Any] = {"c": context, "p": sorted_params, "r": rev}
31
+ if user_id is not None:
32
+ key_data["u"] = str(user_id)
33
+
34
+ message = json.dumps(key_data, sort_keys=True, separators=(",", ":"))
35
+ hmac_hex = hmac.new(
36
+ secret.encode("utf-8"),
37
+ message.encode("utf-8"),
38
+ hashlib.sha256,
39
+ ).hexdigest()
40
+
41
+ return f"{CONTEXT_KEY_PREFIX}{context}:{hmac_hex}"
File without changes