without-configmap 0.0.1__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.
- without_configmap-0.0.1/PKG-INFO +61 -0
- without_configmap-0.0.1/README.md +38 -0
- without_configmap-0.0.1/pyproject.toml +34 -0
- without_configmap-0.0.1/src/without_configmap/__init__.py +7 -0
- without_configmap-0.0.1/src/without_configmap/parse.py +24 -0
- without_configmap-0.0.1/src/without_configmap/py.typed +0 -0
- without_configmap-0.0.1/src/without_configmap/source.py +44 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: without-configmap
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A without behavior source backed by a Kubernetes ConfigMap mount, reloaded with watchfiles.
|
|
5
|
+
Author: Josh Karpel
|
|
6
|
+
Author-email: Josh Karpel <josh.karpel@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: without-core==0.0.1
|
|
18
|
+
Requires-Dist: watchfiles>=0.21
|
|
19
|
+
Requires-Dist: pydantic>=2
|
|
20
|
+
Requires-Dist: pyyaml>=6
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# without-configmap
|
|
25
|
+
|
|
26
|
+
A `without` behavior source backed by a Kubernetes ConfigMap mount: the first
|
|
27
|
+
context that actually *changes*, proving the context-updated-by-a-stream half of
|
|
28
|
+
the model end to end. Where [`without-env`](../without-env) loads a static value
|
|
29
|
+
once, this one re-parses on every mount change.
|
|
30
|
+
|
|
31
|
+
`watch_config(mount, parse)` is a `Stream` that yields the parsed config now and a
|
|
32
|
+
freshly parsed value every time the mount changes. It watches the mount
|
|
33
|
+
*directory*, not the file, so it catches the atomic `..data` symlink swap that
|
|
34
|
+
[projected ConfigMaps](https://kubernetes.io/docs/concepts/configuration/configmap/#mounted-configmaps-are-updated-automatically)
|
|
35
|
+
use. Feed the stream through `without.sample` to read the latest value as a
|
|
36
|
+
`Context`:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from pydantic import BaseModel
|
|
42
|
+
from without import sample
|
|
43
|
+
from without_configmap import read_yaml_file, watch_config
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MyConfig(BaseModel):
|
|
47
|
+
model_config = {"frozen": True}
|
|
48
|
+
default_mode: str
|
|
49
|
+
max_bytes: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
source = watch_config(Path("/etc/config"), read_yaml_file(MyConfig, "config.yaml"))
|
|
53
|
+
async with sample(source) as config:
|
|
54
|
+
config.current() # always the latest reloaded value, never blocks
|
|
55
|
+
await config.updated() # block until the next reload lands, then return it
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
See the
|
|
59
|
+
[`without-configmap` guide](https://without.help/guides/without-configmap/)
|
|
60
|
+
(with the [API reference](https://without.help/reference/without_configmap/))
|
|
61
|
+
for the full surface.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# without-configmap
|
|
2
|
+
|
|
3
|
+
A `without` behavior source backed by a Kubernetes ConfigMap mount: the first
|
|
4
|
+
context that actually *changes*, proving the context-updated-by-a-stream half of
|
|
5
|
+
the model end to end. Where [`without-env`](../without-env) loads a static value
|
|
6
|
+
once, this one re-parses on every mount change.
|
|
7
|
+
|
|
8
|
+
`watch_config(mount, parse)` is a `Stream` that yields the parsed config now and a
|
|
9
|
+
freshly parsed value every time the mount changes. It watches the mount
|
|
10
|
+
*directory*, not the file, so it catches the atomic `..data` symlink swap that
|
|
11
|
+
[projected ConfigMaps](https://kubernetes.io/docs/concepts/configuration/configmap/#mounted-configmaps-are-updated-automatically)
|
|
12
|
+
use. Feed the stream through `without.sample` to read the latest value as a
|
|
13
|
+
`Context`:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
from without import sample
|
|
20
|
+
from without_configmap import read_yaml_file, watch_config
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MyConfig(BaseModel):
|
|
24
|
+
model_config = {"frozen": True}
|
|
25
|
+
default_mode: str
|
|
26
|
+
max_bytes: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
source = watch_config(Path("/etc/config"), read_yaml_file(MyConfig, "config.yaml"))
|
|
30
|
+
async with sample(source) as config:
|
|
31
|
+
config.current() # always the latest reloaded value, never blocks
|
|
32
|
+
await config.updated() # block until the next reload lands, then return it
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
See the
|
|
36
|
+
[`without-configmap` guide](https://without.help/guides/without-configmap/)
|
|
37
|
+
(with the [API reference](https://without.help/reference/without_configmap/))
|
|
38
|
+
for the full surface.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.25,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "without-configmap"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "A without behavior source backed by a Kubernetes ConfigMap mount, reloaded with watchfiles."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Josh Karpel", email = "josh.karpel@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.14"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"without-core==0.0.1",
|
|
28
|
+
"watchfiles>=0.21",
|
|
29
|
+
"pydantic>=2",
|
|
30
|
+
"pyyaml>=6",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.uv.sources]
|
|
34
|
+
without-core = { workspace = true }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_yaml_file[ModelT: BaseModel](model_type: type[ModelT], file_name: str) -> Callable[[Path], ModelT]:
|
|
11
|
+
"""
|
|
12
|
+
Parse a single YAML file from the mount into a validated model.
|
|
13
|
+
|
|
14
|
+
The ConfigMap is mounted with one file (e.g. `config.yaml`) holding a YAML
|
|
15
|
+
mapping. This reads `mount / file_name`, loads it, and validates it into
|
|
16
|
+
`model_type` at the boundary, so missing keys fall back to declared model
|
|
17
|
+
defaults and the rest of the system sees an already-valid value.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def parse(mount: Path) -> ModelT:
|
|
21
|
+
contents = yaml.safe_load((mount / file_name).read_text())
|
|
22
|
+
return model_type.model_validate(contents)
|
|
23
|
+
|
|
24
|
+
return parse
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# A behavior source backed by a Kubernetes ConfigMap mount. Watches the mount
|
|
2
|
+
# directory (not a file) because a projected ConfigMap swaps an atomic ..data
|
|
3
|
+
# symlink rather than rewriting files in place, so a per-file watch can miss
|
|
4
|
+
# updates. On each change the whole mount is reparsed to its desired state
|
|
5
|
+
# (declarative) instead of applying deltas. Feed the result to without.sample.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from watchfiles import awatch
|
|
14
|
+
from without.contracts import Stream
|
|
15
|
+
|
|
16
|
+
type Changes = Callable[[Path], AsyncIterator[object]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _awatch_changes(mount: Path) -> AsyncIterator[object]:
|
|
20
|
+
async for batch in awatch(mount): # pragma: no cover - default I/O adapter; tests drive the `changes` seam
|
|
21
|
+
yield batch
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def watch_config[T](
|
|
25
|
+
mount: Path,
|
|
26
|
+
parse: Callable[[Path], T],
|
|
27
|
+
*,
|
|
28
|
+
changes: Changes = _awatch_changes,
|
|
29
|
+
) -> Stream[T]:
|
|
30
|
+
"""
|
|
31
|
+
The parsed config now, and a freshly parsed value on every mount change.
|
|
32
|
+
|
|
33
|
+
`parse` is the boundary: it turns the mount directory into a validated
|
|
34
|
+
value. `changes` is the source of reload signals, injectable so tests can
|
|
35
|
+
drive reloads deterministically without real filesystem events; it defaults
|
|
36
|
+
to watching the mount directory with `watchfiles`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
async def source() -> AsyncIterator[T]:
|
|
40
|
+
yield parse(mount)
|
|
41
|
+
async for _change in changes(mount):
|
|
42
|
+
yield parse(mount)
|
|
43
|
+
|
|
44
|
+
return source()
|