Skip to content

Commit 758fccc

Browse files
committed
Drop Python 3.9, lots of typing updates
1 parent 35633c5 commit 758fccc

12 files changed

Lines changed: 342 additions & 274 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
strategy:
2020
matrix:
2121
os: ["ubuntu-latest"]
22-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
22+
python-version: ["3.10", "3.11", "3.12", "3.13"]
2323
runs-on: ${{ matrix.os }}
2424
steps:
2525
- uses: actions/checkout@v4

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# 1.0.0
22

33
* Initial stable release
4+
* Dropped Python 3.9 support
45
* Added type annotations throughout the code
5-
* Now always specify `cryptography` as a dependency. The `fernet` extra was removed.
6+
* Now always specify `cryptography` as a dependency (the `fernet` extra was removed)
7+
* Boolean casts now additionally accept `t`, `y`, `f`, and `n`
8+
* `cast`, `sensitive`, and `ttl` are now keyword-only arguments. This is technically a breaking change but all documented usage used keywords anyway.

pyproject.toml

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cconf"
3-
dynamic = ["version"]
3+
version = "1.0.0.dev"
44
description = "Multi-sourced (and optionally encrypted) configuration management."
55
authors = [
66
{ name = "Dan Watson", email = "watsond@imsweb.com" }
@@ -16,14 +16,13 @@ classifiers = [
1616
"License :: OSI Approved :: BSD License",
1717
"Operating System :: OS Independent",
1818
"Programming Language :: Python",
19-
"Topic :: Software Development :: Libraries :: Python Modules",
20-
"Programming Language :: Python",
2119
"Programming Language :: Python :: 3",
2220
]
2321

2422
[dependency-groups]
2523
dev = [
2624
"django>=4.2.18",
25+
"python-tss-sdk",
2726
]
2827

2928
[project.optional-dependencies]
@@ -37,16 +36,8 @@ Homepage = "https://github.com/imsweb/cconf"
3736
cconf = "cconf.cli:main"
3837

3938
[build-system]
40-
requires = ["hatchling"]
41-
build-backend = "hatchling.build"
42-
43-
[tool.uv]
44-
dev-dependencies = [
45-
"python-tss-sdk",
46-
]
47-
48-
[tool.hatch.version]
49-
path = "src/cconf/__init__.py"
39+
requires = ["uv_build>=0.8.3,<0.9.0"]
40+
build-backend = "uv_build"
5041

5142
[tool.ruff.lint]
5243
extend-select = ["I"]

src/cconf/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import importlib.metadata
12
import re
23

34
from .base import Config, config, undefined
@@ -17,7 +18,7 @@
1718
Separated,
1819
)
1920

20-
__version__ = "1.0.0"
21+
__version__ = importlib.metadata.version("cconf")
2122
__version_info__ = tuple(
2223
int(num) if num.isdigit() else str(num)
2324
for num in re.findall(r"([a-z]*\d+)", __version__)

src/cconf/base.py

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import datetime
22
import os
33
import warnings
4-
from typing import Any, Callable, Mapping, NamedTuple, Optional, Union
4+
from collections.abc import Callable, Mapping
5+
from typing import Any, NamedTuple, TypeVar, overload
56

67
from .ciphers import DecryptError
78
from .exceptions import ConfigError, ConfigWarning
@@ -10,32 +11,40 @@
1011

1112
BOOLEAN_STRINGS = {
1213
"true": True,
14+
"t": True,
1315
"yes": True,
16+
"y": True,
1417
"1": True,
1518
"false": False,
19+
"f": False,
1620
"no": False,
21+
"n": False,
1722
"0": False,
1823
}
1924

2025

26+
SourceTypes = BaseSource | StrPath | Mapping[str, Any]
27+
28+
T = TypeVar("T")
29+
30+
2131
class ConfigValue(NamedTuple):
2232
raw: Any
2333
value: Any
24-
source: Optional[BaseSource]
34+
source: BaseSource | None
2535
default: Any
2636
sensitive: bool
27-
ttl: Optional[int]
28-
29-
30-
SourceTypes = Union[BaseSource, StrPath, Mapping[str, Any]]
31-
CastCallable = Callable[..., Any]
37+
ttl: int | None
3238

3339

34-
class undefined:
40+
class Undefined:
3541
def __bool__(self):
3642
return False
3743

3844

45+
undefined = Undefined()
46+
47+
3948
class Config:
4049
_sources: list[BaseSource]
4150
_defined: dict[str, ConfigValue]
@@ -102,7 +111,7 @@ def dir(self, path: StrPath, **kwargs: Any):
102111
"""
103112
return self.source(EnvDir(path, **kwargs))
104113

105-
def env(self, environ: Optional[Mapping[str, Any]] = None, **kwargs: Any):
114+
def env(self, environ: Mapping[str, Any] | None = None, **kwargs: Any):
106115
"""
107116
Adds either a `HostEnv` source, or a generic `Source` to the list of checked
108117
sources, based on whether `environ` is set.
@@ -117,13 +126,38 @@ def defined(self):
117126
"""
118127
return {k: v.value for k, v in self._defined.items()}
119128

129+
# When default=None, the returned value may be None (any cast of None is None).
130+
@overload
131+
def __call__(
132+
self,
133+
key: str,
134+
default: None,
135+
*,
136+
cast: Callable[..., T] = str,
137+
sensitive: bool = ...,
138+
ttl: int | datetime.timedelta | None = ...,
139+
) -> T | None: ...
140+
141+
# Otherwise, no matter what the default, the returned value is of type T.
142+
@overload
143+
def __call__(
144+
self,
145+
key: str,
146+
default: Any = undefined,
147+
*,
148+
cast: Callable[..., T] = str,
149+
sensitive: bool = ...,
150+
ttl: int | datetime.timedelta | None = ...,
151+
) -> T: ...
152+
120153
def __call__(
121154
self,
122155
key: str,
123156
default: Any = undefined,
124-
cast: Optional[CastCallable] = None,
157+
*,
158+
cast: Callable = str,
125159
sensitive: bool = False,
126-
ttl: Optional[Union[int, datetime.timedelta]] = None,
160+
ttl: int | datetime.timedelta | None = None,
127161
) -> Any:
128162
sources_checked: list[str] = []
129163
key = str(key)
@@ -175,16 +209,38 @@ def __call__(
175209
ConfigWarning,
176210
stacklevel=2,
177211
)
178-
else:
179-
raise KeyError(f"`{key}` not found in any of: {checked}")
180-
return default
212+
return default
213+
raise KeyError(f"`{key}` not found in any of: {checked}")
181214

182-
def _perform_cast(self, value: Any, cast: Optional[CastCallable], key: str = ""):
183-
if cast is None or value is None:
215+
# None always casts to None.
216+
@overload
217+
def _perform_cast(
218+
self,
219+
value: None,
220+
cast: Callable[..., T],
221+
key: str = "",
222+
) -> None: ...
223+
224+
# Otherwise, this always returns the cast type.
225+
@overload
226+
def _perform_cast(
227+
self,
228+
value: Any,
229+
cast: Callable[..., T],
230+
key: str = "",
231+
) -> T: ...
232+
233+
def _perform_cast(
234+
self,
235+
value: Any,
236+
cast: Callable,
237+
key: str = "",
238+
) -> Any:
239+
if value is None:
184240
return value
185241
elif cast is bool and isinstance(value, str):
186242
try:
187-
return BOOLEAN_STRINGS[value.lower()]
243+
return cast(BOOLEAN_STRINGS[value.lower()])
188244
except KeyError:
189245
raise ValueError(f"Invalid boolean for `{key}`: `{value}`")
190246
try:

src/cconf/cacheurl.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from collections.abc import Iterable
2-
from typing import Any, Callable, NamedTuple, Optional, Union
1+
from collections.abc import Callable, Iterable
2+
from typing import Any, NamedTuple, Optional, Union
33
from urllib.parse import ParseResult, parse_qs, urlparse
44

55
Processor = Callable[[ParseResult, dict[str, Any]], Any]

src/cconf/ciphers.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import base64
22
import binascii
3-
from typing import Iterable, Optional, TextIO, Union
3+
from collections.abc import Iterable
4+
from typing import ClassVar, TextIO
45

56
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
67

@@ -28,27 +29,27 @@ def read_keys(fileobj: TextIO) -> MultiFernet:
2829

2930

3031
class Cipher:
31-
secure = False
32+
secure: ClassVar[bool]
3233

3334
def encrypt(self, value: str) -> str:
3435
raise NotImplementedError()
3536

36-
def decrypt(self, value: str, ttl: Optional[int] = None) -> str:
37+
def decrypt(self, value: str, ttl: int | None = None) -> str:
3738
raise NotImplementedError()
3839

3940

4041
class Keys(Cipher):
4142
secure = True
4243

43-
def __init__(self, keyiter: Iterable[Union[str, bytes, Fernet]]):
44+
def __init__(self, keyiter: Iterable[str | bytes | Fernet]):
4445
self._keys = MultiFernet(
4546
[k if isinstance(k, Fernet) else Fernet(k) for k in keyiter]
4647
)
4748

4849
def encrypt(self, value: str) -> str:
4950
return self._keys.encrypt(value.encode()).decode()
5051

51-
def decrypt(self, value: str, ttl: Optional[int] = None) -> str:
52+
def decrypt(self, value: str, ttl: int | None = None) -> str:
5253
try:
5354
return self._keys.decrypt(value.encode(), ttl=ttl).decode()
5455
except InvalidToken:
@@ -57,15 +58,15 @@ def decrypt(self, value: str, ttl: Optional[int] = None) -> str:
5758

5859
class KeyFile(Cipher):
5960
secure = True
60-
_keys: Optional[MultiFernet] = None
6161

62-
def __init__(self, filename: StrPath, policy: Optional[PolicyCallable] = UserOnly):
63-
self.filename = filename
64-
self.policy = policy
62+
def __init__(self, filename: StrPath, policy: PolicyCallable | None = UserOnly):
63+
self._filename = filename
64+
self._policy = policy
65+
self._keys = None
6566

66-
def _load_keys(self):
67+
def _load_keys(self) -> MultiFernet:
6768
if self._keys is None:
68-
with safe_open(self.filename, policy=self.policy) as fileobj:
69+
with safe_open(self._filename, policy=self._policy) as fileobj:
6970
self._keys = read_keys(fileobj)
7071
if not self._keys:
7172
raise ConfigError(f"No keys found for: {self}")
@@ -74,7 +75,7 @@ def _load_keys(self):
7475
def encrypt(self, value: str) -> str:
7576
return self._load_keys().encrypt(value.encode()).decode()
7677

77-
def decrypt(self, value: str, ttl: Optional[int] = None) -> str:
78+
def decrypt(self, value: str, ttl: int | None = None) -> str:
7879
try:
7980
return self._load_keys().decrypt(value.encode(), ttl=ttl).decode()
8081
except InvalidToken:
@@ -87,18 +88,18 @@ class Base64(Cipher):
8788
def encrypt(self, value: str) -> str:
8889
return base64.b64encode(value.encode()).decode()
8990

90-
def decrypt(self, value: str, ttl: Optional[int] = None) -> str:
91+
def decrypt(self, value: str, ttl: int | None = None) -> str:
9192
try:
9293
return base64.b64decode(value.encode()).decode()
9394
except binascii.Error:
9495
raise DecryptError
9596

9697

97-
class Identity:
98+
class Identity(Cipher):
9899
secure = False
99100

100101
def encrypt(self, value: str) -> str:
101102
return value
102103

103-
def decrypt(self, value: str, ttl: Optional[int] = None) -> str:
104+
def decrypt(self, value: str, ttl: int | None = None) -> str:
104105
return value

src/cconf/dburl.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Iterable
2-
from typing import Any, NamedTuple, Optional, Union
2+
from typing import Any, NamedTuple
33
from urllib.parse import parse_qs, unquote, unquote_plus, urlparse
44

55

@@ -14,9 +14,9 @@ class Engine(NamedTuple):
1414

1515
def register(
1616
backend: str,
17-
schemes: Optional[Iterable[str]] = None,
17+
schemes: Iterable[str] | None = None,
1818
string_ports: bool = False,
19-
options: Optional[dict[str, Any]] = None,
19+
options: dict[str, Any] | None = None,
2020
):
2121
if schemes is None:
2222
schemes = [backend.rsplit(".")[-1]]
@@ -40,8 +40,8 @@ def register(
4040

4141

4242
def parse(
43-
url_or_config: Union[str, dict[str, Any]],
44-
backend: Optional[str] = None,
43+
url_or_config: str | dict[str, Any],
44+
backend: str | None = None,
4545
**settings: Any,
4646
) -> dict[str, Any]:
4747
if isinstance(url_or_config, dict):

src/cconf/policy.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import os
22
import stat
33
import warnings
4-
from typing import Any, Callable, Optional
4+
from collections.abc import Callable
5+
from typing import Any
56

67
from .exceptions import ConfigWarning, PolicyError
78
from .types import StrPath
@@ -38,7 +39,7 @@ def UserOrGroup(path: StrPath):
3839
def safe_open(
3940
path: StrPath,
4041
*,
41-
policy: Optional[PolicyCallable] = None,
42+
policy: PolicyCallable | None = None,
4243
**kwargs: Any,
4344
):
4445
if policy:

0 commit comments

Comments
 (0)