notifig 0.1.0__py3-none-any.whl

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.
notifig/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ This project exists to allow a living Config to exist as the single
3
+ source-of-truth for every persistent value in a project's settings.
4
+
5
+ It's built on modern python with strict pydantic typing and
6
+ highly declarative, reactive principles. Declaration is behavior.
7
+
8
+ It can interact with several file formats for saving/loading,
9
+ supports both plain python syntax as well as config path semantics,
10
+ offers sync and async update notifications, declarative CLI flags,
11
+ field values restricted by Choice, static and dynamic section entries,
12
+ field links.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .annotations import (
18
+ AbsolutePath,
19
+ Choice,
20
+ CliFlags,
21
+ CommaSeparated,
22
+ Link,
23
+ Path,
24
+ PathPattern,
25
+ )
26
+ from .changes import ConfigChange, Created, Deleted, Updated
27
+ from .config import Config
28
+ from .exceptions import (
29
+ AsyncObserversNotCalled,
30
+ EntryRequiresData,
31
+ NotASection,
32
+ PathNotFound,
33
+ )
34
+ from .meta import ConfigDict
35
+ from .section.dynamic import DynamicSection
36
+ from .section.static import ConfigSection
37
+ from .sources import FILE_EXTENSIONS
38
+
39
+ __all__ = [
40
+ "Config",
41
+ "ConfigDict",
42
+ "ConfigSection",
43
+ "DynamicSection",
44
+ "Choice",
45
+ "CliFlags",
46
+ "CommaSeparated",
47
+ "Link",
48
+ "Path",
49
+ "AbsolutePath",
50
+ "PathPattern",
51
+ "PathNotFound",
52
+ "NotASection",
53
+ "EntryRequiresData",
54
+ "AsyncObserversNotCalled",
55
+ "FILE_EXTENSIONS",
56
+ "ConfigChange",
57
+ "Updated",
58
+ "Deleted",
59
+ "Created",
60
+ ]
@@ -0,0 +1,15 @@
1
+ from .choice import Choice
2
+ from .cli_flags import CliFlags
3
+ from .comma_list import CommaSeparated
4
+ from .link import Link
5
+ from .path import AbsolutePath, Path, PathPattern
6
+
7
+ __all__ = [
8
+ "Choice",
9
+ "CliFlags",
10
+ "CommaSeparated",
11
+ "Link",
12
+ "Path",
13
+ "AbsolutePath",
14
+ "PathPattern",
15
+ ]
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import operator
4
+ import typing
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
10
+ from pydantic.fields import FieldInfo
11
+ from pydantic.json_schema import JsonSchemaValue
12
+ from pydantic_core import CoreSchema, core_schema
13
+
14
+
15
+ # `eq=False` is important to maintain. Without it, pydantic can try to derive __eq__/__hash__
16
+ # from fields (for caching on schema generation), and since `entries` is a Mapping it would
17
+ # raise on an unhashable dict. `eq=False` maintains identity through object.__hash__.
18
+ @dataclass(frozen=True, slots=True, eq=False)
19
+ class Choice:
20
+ entries: Mapping[str, Any]
21
+ # Optional reverse mapping: how to get the name from an entry
22
+ key: Callable[[Any], str] = operator.attrgetter("name")
23
+ label: str = ""
24
+
25
+ @classmethod
26
+ def of(cls, field_info: FieldInfo) -> Choice | None:
27
+ """Returns the Choice for a field, if it exists."""
28
+ for annotation in field_info.metadata:
29
+ if isinstance(annotation, Choice):
30
+ return annotation
31
+ return None
32
+
33
+ @property
34
+ def options(self) -> list[str]:
35
+ return sorted(self.entries)
36
+
37
+ def __get_pydantic_core_schema__(
38
+ self, source: Any, handler: GetCoreSchemaHandler
39
+ ) -> CoreSchema:
40
+ """Defines how to validate and serialize `source` using `handler`."""
41
+ args = typing.get_args(source)
42
+ member = next((a for a in args if a is not type(None)), source)
43
+ label = self.label or getattr(member, "__name__", "entry")
44
+
45
+ def to_member(value):
46
+ if not isinstance(value, str):
47
+ return value
48
+ try:
49
+ return self.entries[value]
50
+ except KeyError as error:
51
+ raise ValueError(
52
+ f"Not a valid {label}. Use one of {self.options}"
53
+ ) from error
54
+
55
+ schema = core_schema.json_or_python_schema(
56
+ # On the wire an option is its name, so it validates as a string first.
57
+ json_schema=core_schema.chain_schema(
58
+ [
59
+ core_schema.str_schema(),
60
+ core_schema.no_info_plain_validator_function(to_member),
61
+ ]
62
+ ),
63
+ # In Python it is usually the member itself, sometimes still a name.
64
+ python_schema=core_schema.no_info_plain_validator_function(to_member),
65
+ # How pydantic serializes instances
66
+ serialization=core_schema.plain_serializer_function_ser_schema(
67
+ self.key, return_schema=core_schema.str_schema(), when_used="json"
68
+ ),
69
+ )
70
+ # Whether None is legal is the annotation's to say.
71
+ return core_schema.nullable_schema(schema) if type(None) in args else schema
72
+
73
+ def __get_pydantic_json_schema__(
74
+ self, schema: dict[str, Any], handler: GetJsonSchemaHandler
75
+ ) -> JsonSchemaValue:
76
+ json_schema = handler(core_schema.str_schema())
77
+ json_schema.update(type="string", enum=self.options)
78
+ return json_schema
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ # `init=False` ensures the dataclass doesn't auto-generate an __init__.
7
+ # Without it, we'd be forced to instantiate with `CliFlags(["-u", "--url"])`
8
+ # instead of `CliFlags("-u", "--url")`
9
+ @dataclass(frozen=True, slots=True, init=False)
10
+ class CliFlags:
11
+ flags: tuple[str, ...]
12
+
13
+ def __init__(self, *flags) -> None:
14
+ # NOTE: Not a real hack, this is the documented way to expose an alternative
15
+ # constructor with a frozen dataclass. The alternative is using `.of(*flags)`
16
+ object.__setattr__(self, "flags", flags)
17
+
18
+ def to_argparse(self) -> list[str]:
19
+ return [
20
+ (f"{'-' if len(flag) == 1 else '--'}{flag.replace('_', '-')}")
21
+ for flag in self.flags
22
+ ]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, TypeVar
4
+
5
+ from pydantic import BeforeValidator, PlainSerializer
6
+ from pydantic_settings import NoDecode
7
+
8
+ T = TypeVar("T")
9
+ CommaSeparated = Annotated[
10
+ list[T],
11
+ # Don't try to decode early with a default validator
12
+ NoDecode,
13
+ # Converts "a, bb,c," -> ["a", "bb", "c"]
14
+ BeforeValidator(
15
+ lambda value: (
16
+ [token.strip() for token in value.split(",") if token.strip()]
17
+ if isinstance(value, str)
18
+ else value
19
+ )
20
+ ),
21
+ # Serializes ["a", "bb", "c"] -> "a,bb,c"
22
+ PlainSerializer(
23
+ lambda seq: ",".join(str(token) for token in seq),
24
+ when_used="json",
25
+ ),
26
+ ]
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any, Self, overload
5
+
6
+ if TYPE_CHECKING:
7
+ from notifig.section.static import ConfigSection
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Link[T]:
12
+ """
13
+ A read-only reference to a field elsewhere in the same config.
14
+
15
+ Stores nothing: every read follows `source_path` to the source field, so the
16
+ link always shows the source's current value. A path starting with `/` is
17
+ read from the root of the config the section is attached to; any other path
18
+ is read from the section itself.
19
+ ```python
20
+ class FileToolConfig(ConfigSection):
21
+ ignored_paths: ClassVar[Link[list[PathPattern]]] = Link("/files.ignored_paths")
22
+ ```
23
+ Declared as a `ClassVar`, so pydantic doesn't treat it as a field: it isn't
24
+ validated, exported or editable, and writing to it raises.
25
+ """
26
+
27
+ source_path: str
28
+
29
+ def __post_init__(self) -> None:
30
+ if not self.source_path.removeprefix("/"):
31
+ raise ValueError(
32
+ f"A link needs a path to a field, got {self.source_path!r}"
33
+ )
34
+
35
+ # NOTE: Python calls `__get__` on attribute lookup: `section.link` passes the
36
+ # instance, `SectionClass.link` passes None and gets the Link itself.
37
+ @overload
38
+ def __get__(self, section: None, owner: Any = None) -> Self: ...
39
+ @overload
40
+ def __get__(self, section: ConfigSection, owner: Any = None) -> T: ...
41
+ def __get__(self, section: ConfigSection | None, owner: Any = None) -> Self | T:
42
+ if section is None:
43
+ return self
44
+ if not self.source_path.startswith("/"):
45
+ return section.get(self.source_path)
46
+ root = section
47
+ while root._parent is not None:
48
+ root = root._parent
49
+ if root is section:
50
+ raise ValueError(
51
+ f"{type(section).__name__} isn't attached to a config, "
52
+ f"so its link to {self.source_path!r} can't be read"
53
+ )
54
+ return root.get(self.source_path.removeprefix("/"))
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ from typing import Annotated
5
+
6
+ from pydantic import BeforeValidator, PlainSerializer
7
+
8
+ Path = Annotated[
9
+ pathlib.Path,
10
+ BeforeValidator(lambda value: pathlib.Path(value)),
11
+ PlainSerializer(str, return_type=str, when_used="json"),
12
+ ]
13
+
14
+ AbsolutePath = Annotated[
15
+ pathlib.Path,
16
+ BeforeValidator(lambda value: pathlib.Path(value).expanduser().resolve()),
17
+ PlainSerializer(str, return_type=str, when_used="json"),
18
+ ]
19
+
20
+
21
+ def _resolve_pattern(value) -> pathlib.Path:
22
+ path = pathlib.Path(value)
23
+ # Don't expand bare names, unlike AbsolutePath
24
+ if str(path) == path.name:
25
+ return path
26
+ return path.expanduser().resolve()
27
+
28
+
29
+ PathPattern = Annotated[
30
+ pathlib.Path,
31
+ BeforeValidator(_resolve_pattern),
32
+ ]
notifig/changes.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ConfigChange:
9
+ path: str
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Created(ConfigChange):
14
+ value: Any # what appeared
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Updated(ConfigChange):
19
+ before: Any
20
+ after: Any
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Deleted(ConfigChange):
25
+ value: Any # what left
notifig/config.py ADDED
@@ -0,0 +1,89 @@
1
+ import sys
2
+ from typing import override
3
+
4
+ from pydantic import Field
5
+ from pydantic_settings import BaseSettings, CliSettingsSource
6
+
7
+ from .annotations import AbsolutePath, CommaSeparated
8
+ from .meta import ConfigDict
9
+ from .section.static import ConfigSection
10
+ from .sources import ConfigFileSource
11
+
12
+
13
+ class Config(BaseSettings, ConfigSection):
14
+ model_config = ConfigDict(
15
+ cli_avoid_json=True,
16
+ cli_exit_on_error=False,
17
+ cli_kebab_case=True,
18
+ cli_implicit_flags=True,
19
+ cli_enforce_required=False,
20
+ default_config_files=[],
21
+ )
22
+
23
+ # Whether to read `sys.argv`. An internal switch, never saved or edited
24
+ parse_cli: bool = Field(default=True, exclude=True)
25
+ # Config file paths read, sorted lowest -> highest priority.
26
+ # Add flags and defaults for this field using:
27
+ # `ConfigDict(cli_shortcuts={"config_files":["c", "config"]}, default_config_files=["~/config.json"])`
28
+ config_files: CommaSeparated[AbsolutePath] = Field(
29
+ default_factory=list, exclude=True
30
+ )
31
+
32
+ @override
33
+ @classmethod
34
+ def settings_customise_sources(
35
+ cls,
36
+ settings_cls,
37
+ init_settings,
38
+ env_settings,
39
+ dotenv_settings,
40
+ file_secret_settings,
41
+ ):
42
+ """Build the settings-source stack. CLI args flow through `cli_parse_args`;
43
+ `--config` flags are split out for ConfigFileSource."""
44
+ # Parse CLI args list. If `parse_cli` is absent from init_kwargs,
45
+ # then it wasn't passed, use field default
46
+ parse_cli: bool | None = init_settings.init_kwargs.get("parse_cli")
47
+ if parse_cli is None:
48
+ parse_cli = settings_cls.model_fields["parse_cli"].get_default()
49
+ argv = sys.argv[1:] if parse_cli else []
50
+
51
+ # A key must match the flag name pydantic-settings generated for the field,
52
+ # which `cli_kebab_case` makes kebab-case: `max_context` -> `max-context`,
53
+ # a nested field -> `api.max-context`. Values are used verbatim, with no
54
+ # case conversion, and a one-character value gets a single dash. Expected:
55
+ # {"path.to.my-section": ["my-section", "m"]}
56
+ # Creates: `--my-section` and `-m`
57
+ # Shortcuts from `model_config` (for inherited fields), then from each
58
+ # field's own `CliFlags`. A value may be one flag or a list of them.
59
+ shortcuts: dict[str, list[str]] = {}
60
+ for path, flags in (cls.model_config.get("cli_shortcuts") or {}).items():
61
+ shortcuts[path.replace("_", "-")] = (
62
+ [flags] if isinstance(flags, str) else list(flags)
63
+ )
64
+ for path, all_cli_flags in cls._cli_flags_from_schema().items():
65
+ shortcuts.setdefault(path.replace("_", "-"), []).extend(
66
+ flag for cli_flags in all_cli_flags for flag in cli_flags.flags
67
+ )
68
+ # A flag can only belong to one field, wherever it was declared
69
+ claimed_by: dict[str, str] = {}
70
+ for path, flags in shortcuts.items():
71
+ for flag in flags:
72
+ if flag in claimed_by:
73
+ raise ValueError(
74
+ f"Flag {flag!r} is claimed by both "
75
+ f"{claimed_by[flag]!r} and {path!r}"
76
+ )
77
+ claimed_by[flag] = path
78
+
79
+ # Precedence high->low: init kwargs, CLI, env, then config files.
80
+ return (
81
+ init_settings,
82
+ CliSettingsSource(
83
+ settings_cls,
84
+ cli_parse_args=argv,
85
+ cli_shortcuts=shortcuts,
86
+ ),
87
+ env_settings,
88
+ ConfigFileSource(settings_cls),
89
+ )
notifig/exceptions.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class PathNotFound(AttributeError):
9
+ def __init__(self, model: type[BaseModel], path: str) -> None:
10
+ super().__init__(f"{model.__name__!r} does not contain {path!r}")
11
+
12
+
13
+ class NotASection(AttributeError):
14
+ def __init__(self, name: str, path: str | None = None) -> None:
15
+ msg = f"{name} is not a section"
16
+ if path:
17
+ msg = f"{path!r}: {msg}"
18
+ super().__init__(msg)
19
+
20
+
21
+ class EntryRequiresData(ValueError):
22
+ """A section was asked to give a name a type that its data cannot build.
23
+
24
+ Raised when the entry type declares fields with no default, and the data the
25
+ section has for the entry — nothing, an incomplete block, or another entry's
26
+ own values — carries none for them. A caller that can live without the entry
27
+ catches this and decides what the absence means; a caller that needs the
28
+ entry lets it travel.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ model: type[BaseModel],
34
+ name: str,
35
+ missing_fields: Sequence[str],
36
+ ) -> None:
37
+ self.model = model
38
+ self.name = name
39
+ self.missing_fields = list(missing_fields)
40
+ fields = ", ".join(repr(field) for field in self.missing_fields)
41
+ plural = len(self.missing_fields) > 1
42
+ super().__init__(
43
+ f"the {name!r} entry cannot be built as {model.__name__!r}: {fields} "
44
+ f"{'have' if plural else 'has'} no default, and the data available for the "
45
+ f"entry carries no value for {'them' if plural else 'it'}. Declare a "
46
+ f"default, or let {'them' if plural else 'it'} accept absence."
47
+ )
48
+
49
+
50
+ class AsyncObserversNotCalled(RuntimeError):
51
+ """
52
+ At least one async observer was found when attempting to set a value in a
53
+ context that cannot return or call it (ex: `section[name]=value`).
54
+ """
55
+
56
+ def __init__(
57
+ self, path: str, callbacks: Sequence[str], applied: bool = False
58
+ ) -> None:
59
+ self.path = path
60
+ self.callbacks = list(callbacks)
61
+ if applied:
62
+ tail = (
63
+ "The change was applied and sync observers were notified; the async "
64
+ "observers were not called and will not run until the next change "
65
+ "to this path."
66
+ )
67
+ else:
68
+ tail = (
69
+ "Nothing was applied. Write this path with `section.set(...)` and "
70
+ "await what it returns, or `await section.set_and_wait(...)`."
71
+ )
72
+ super().__init__(
73
+ f"the {path!r} change reaches async observer(s) {', '.join(self.callbacks)}, "
74
+ f"which a plain assignment can neither run nor return. {tail}"
75
+ )
notifig/meta.py ADDED
@@ -0,0 +1,16 @@
1
+ """Describes a Config's own config."""
2
+
3
+ from os import PathLike
4
+
5
+ from pydantic_settings import SettingsConfigDict
6
+
7
+
8
+ class ConfigDict(SettingsConfigDict, total=False):
9
+ """
10
+ Takes pydantic-settings defaults like `cli_shortcuts`.
11
+ Adds `default_config_files`, the default files to read a config from
12
+ if none was specified.
13
+ """
14
+
15
+ # Config files read when no source names any; each is read only if it exists
16
+ default_config_files: list[str | PathLike]
@@ -0,0 +1,7 @@
1
+ from .dynamic import DynamicSection
2
+ from .static import ConfigSection
3
+
4
+ __all__ = [
5
+ "ConfigSection",
6
+ "DynamicSection",
7
+ ]