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 +60 -0
- notifig/annotations/__init__.py +15 -0
- notifig/annotations/choice.py +78 -0
- notifig/annotations/cli_flags.py +22 -0
- notifig/annotations/comma_list.py +26 -0
- notifig/annotations/link.py +54 -0
- notifig/annotations/path.py +32 -0
- notifig/changes.py +25 -0
- notifig/config.py +89 -0
- notifig/exceptions.py +75 -0
- notifig/meta.py +16 -0
- notifig/section/__init__.py +7 -0
- notifig/section/dynamic.py +446 -0
- notifig/section/static.py +640 -0
- notifig/sources.py +74 -0
- notifig-0.1.0.dist-info/METADATA +770 -0
- notifig-0.1.0.dist-info/RECORD +20 -0
- notifig-0.1.0.dist-info/WHEEL +5 -0
- notifig-0.1.0.dist-info/licenses/LICENSE +674 -0
- notifig-0.1.0.dist-info/top_level.txt +1 -0
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]
|