Skip to content

Commit 35633c5

Browse files
committed
Type annotations! Housekeeping before 1.0
1 parent 1542f05 commit 35633c5

14 files changed

Lines changed: 409 additions & 284 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ dist
66
.vscode
77
.DS_Store
88
.nova
9+
.python-version

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# 1.0.0
2+
3+
* Initial stable release
4+
* Added type annotations throughout the code
5+
* Now always specify `cryptography` as a dependency. The `fernet` extra was removed.

README.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,6 @@ crypography and the ability to specify a maximum valid lifetime (`ttl`).
1111

1212
`pip install cconf`
1313

14-
To use `Fernet` keys for encrypting sensitive settings, `cconf` requires the
15-
[cryptography](https://cryptography.io/) module, which can be installed as an extra:
16-
17-
`pip install cconf[fernet]`
18-
1914

2015
## Usage
2116

pyproject.toml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ description = "Multi-sourced (and optionally encrypted) configuration management
55
authors = [
66
{ name = "Dan Watson", email = "watsond@imsweb.com" }
77
]
8-
dependencies = []
98
readme = "README.md"
109
requires-python = ">=3.9"
1110
license = { text = "BSD" }
11+
dependencies = [
12+
"cryptography",
13+
]
1214
classifiers = [
1315
"Intended Audience :: Developers",
1416
"License :: OSI Approved :: BSD License",
@@ -19,10 +21,14 @@ classifiers = [
1921
"Programming Language :: Python :: 3",
2022
]
2123

24+
[dependency-groups]
25+
dev = [
26+
"django>=4.2.18",
27+
]
28+
2229
[project.optional-dependencies]
23-
fernet = ["cryptography"]
2430
secretserver = ["python-tss-sdk"]
25-
all = ["cryptography", "python-tss-sdk"]
31+
all = ["python-tss-sdk"]
2632

2733
[project.urls]
2834
Homepage = "https://github.com/imsweb/cconf"
@@ -36,7 +42,6 @@ build-backend = "hatchling.build"
3642

3743
[tool.uv]
3844
dev-dependencies = [
39-
"cryptography",
4045
"python-tss-sdk",
4146
]
4247

@@ -46,3 +51,7 @@ path = "src/cconf/__init__.py"
4651
[tool.ruff.lint]
4752
extend-select = ["I"]
4853
isort.known-first-party = ["cconf"]
54+
55+
[tool.pyright]
56+
reportPrivateUsage = false
57+
reportMissingModuleSource = false

src/cconf/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414
DatabaseDict,
1515
Duration,
1616
Secret,
17+
Separated,
1718
)
1819

19-
__version__ = "0.9.9"
20+
__version__ = "1.0.0"
2021
__version_info__ = tuple(
21-
int(num) if num.isdigit() else num
22+
int(num) if num.isdigit() else str(num)
2223
for num in re.findall(r"([a-z]*\d+)", __version__)
2324
)
2425

@@ -46,4 +47,5 @@
4647
"UserOnly",
4748
"UserOrGroup",
4849
"Secret",
50+
"Separated",
4951
]

src/cconf/base.py

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import collections
21
import datetime
32
import os
43
import warnings
5-
from pathlib import Path
4+
from typing import Any, Callable, Mapping, NamedTuple, Optional, Union
65

76
from .ciphers import DecryptError
87
from .exceptions import ConfigError, ConfigWarning
98
from .sources import BaseSource, EnvDir, EnvFile, HostEnv, Source
9+
from .types import StrPath
1010

1111
BOOLEAN_STRINGS = {
1212
"true": True,
@@ -17,9 +17,18 @@
1717
"0": False,
1818
}
1919

20-
ConfigValue = collections.namedtuple(
21-
"ConfigValue", ["raw", "value", "source", "default", "sensitive", "ttl"]
22-
)
20+
21+
class ConfigValue(NamedTuple):
22+
raw: Any
23+
value: Any
24+
source: Optional[BaseSource]
25+
default: Any
26+
sensitive: bool
27+
ttl: Optional[int]
28+
29+
30+
SourceTypes = Union[BaseSource, StrPath, Mapping[str, Any]]
31+
CastCallable = Callable[..., Any]
2332

2433

2534
class undefined:
@@ -28,32 +37,35 @@ def __bool__(self):
2837

2938

3039
class Config:
31-
def __init__(self, *sources, **kwargs):
40+
_sources: list[BaseSource]
41+
_defined: dict[str, ConfigValue]
42+
43+
def __init__(self, *sources: SourceTypes, **kwargs: Any):
3244
self._debug = False
3345
self._previous_debug = False
3446
self.setup(*sources, **kwargs)
3547

3648
def __enter__(self):
3749
return self
3850

39-
def __exit__(self, *exc_details):
51+
def __exit__(self, *exc_details: Any):
4052
self._debug = self._previous_debug
4153

42-
def setup(self, *sources, **kwargs):
54+
def setup(self, *sources: SourceTypes, **kwargs: Any):
4355
self._debug = kwargs.pop("debug", self._debug)
4456
self._previous_debug = self._debug
4557
self.reset()
4658
for source in sources:
4759
if isinstance(source, BaseSource):
4860
self.source(source)
49-
elif isinstance(source, (str, Path)):
61+
elif isinstance(source, (str, os.PathLike)):
5062
if not os.path.exists(source):
5163
raise ConfigError(f"File or directory not found: `{source}`")
5264
if os.path.isdir(source):
5365
self.dir(source, **kwargs)
5466
else:
5567
self.file(source, **kwargs)
56-
elif hasattr(source, "__getitem__"):
68+
elif isinstance(source, Mapping): # type: ignore
5769
self.env(source, **kwargs)
5870
else:
5971
raise ConfigError(f"Unknown configuration source: {source}")
@@ -66,31 +78,31 @@ def reset(self):
6678
self._defined = {}
6779
return self
6880

69-
def debug(self, value=True):
81+
def debug(self, value: bool = True):
7082
self._previous_debug = self._debug
7183
self._debug = value
7284
return self
7385

74-
def source(self, source):
86+
def source(self, source: BaseSource):
7587
"""
7688
Adds a configuration source to the list of checked sources.
7789
"""
7890
self._sources.append(source)
7991
return self
8092

81-
def file(self, path, **kwargs):
93+
def file(self, path: StrPath, **kwargs: Any):
8294
"""
8395
Adds an `EnvFile` source to the list of checked sources.
8496
"""
8597
return self.source(EnvFile(path, **kwargs))
8698

87-
def dir(self, path, **kwargs):
99+
def dir(self, path: StrPath, **kwargs: Any):
88100
"""
89101
Adds an `EnvDir` source to the list of checked sources.
90102
"""
91103
return self.source(EnvDir(path, **kwargs))
92104

93-
def env(self, environ=None, **kwargs):
105+
def env(self, environ: Optional[Mapping[str, Any]] = None, **kwargs: Any):
94106
"""
95107
Adds either a `HostEnv` source, or a generic `Source` to the list of checked
96108
sources, based on whether `environ` is set.
@@ -105,16 +117,23 @@ def defined(self):
105117
"""
106118
return {k: v.value for k, v in self._defined.items()}
107119

108-
def __call__(self, key, default=undefined, cast=None, sensitive=False, ttl=None):
109-
sources_checked = []
120+
def __call__(
121+
self,
122+
key: str,
123+
default: Any = undefined,
124+
cast: Optional[CastCallable] = None,
125+
sensitive: bool = False,
126+
ttl: Optional[Union[int, datetime.timedelta]] = None,
127+
) -> Any:
128+
sources_checked: list[str] = []
110129
key = str(key)
130+
if isinstance(ttl, datetime.timedelta):
131+
ttl = int(ttl.total_seconds())
111132
for source in self._sources:
112133
sources_checked.append(str(source))
113134
try:
114135
raw = source[key]
115136
if sensitive:
116-
if isinstance(ttl, datetime.timedelta):
117-
ttl = int(ttl.total_seconds())
118137
raw = source.decrypt(raw, ttl=ttl)
119138
value = self._perform_cast(raw, cast, key=key)
120139
self._defined[key] = ConfigValue(
@@ -160,7 +179,7 @@ def __call__(self, key, default=undefined, cast=None, sensitive=False, ttl=None)
160179
raise KeyError(f"`{key}` not found in any of: {checked}")
161180
return default
162181

163-
def _perform_cast(self, value, cast, key=""):
182+
def _perform_cast(self, value: Any, cast: Optional[CastCallable], key: str = ""):
164183
if cast is None or value is None:
165184
return value
166185
elif cast is bool and isinstance(value, str):

src/cconf/cacheurl.py

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
1-
import urllib.parse as urlparse
1+
from collections.abc import Iterable
2+
from typing import Any, Callable, NamedTuple, Optional, Union
3+
from urllib.parse import ParseResult, parse_qs, urlparse
24

3-
CACHE_SCHEMES = {}
5+
Processor = Callable[[ParseResult, dict[str, Any]], Any]
46

57

6-
class Engine:
7-
def __init__(self, backend, process):
8-
self.backend = backend
9-
self.process = process
8+
class Engine(NamedTuple):
9+
backend: str
10+
process: Processor
1011

1112

12-
def register(backend, schemes=None, process=None):
13+
CACHE_SCHEMES: dict[str, Engine] = {}
14+
15+
16+
def register(backend: str, schemes: Optional[Iterable[str]] = None):
1317
if schemes is None:
1418
schemes = [backend.rsplit(".")[-1]]
1519
elif isinstance(schemes, str):
1620
schemes = [schemes]
1721

18-
engine = Engine(backend, process)
19-
for scheme in schemes:
20-
CACHE_SCHEMES[scheme] = engine
21-
22-
def wrapper(func):
23-
engine.process = func
22+
def wrapper(func: Processor):
23+
engine = Engine(backend, func)
24+
for scheme in schemes:
25+
CACHE_SCHEMES[scheme] = engine
2426
return func
2527

2628
return wrapper
@@ -29,18 +31,18 @@ def wrapper(func):
2931
# Support all the first-party Django backends out of the box.
3032
@register("django.core.cache.backends.locmem.LocMemCache", ("local", "locmem"))
3133
@register("django.core.cache.backends.db.DatabaseCache", ("db", "database"))
32-
def hostname_location(url, options):
34+
def hostname_location(url: ParseResult, options: dict[str, Any]):
3335
return url.hostname
3436

3537

3638
@register("django.core.cache.backends.filebased.FileBasedCache", "file")
37-
def path_location(url, options):
39+
def path_location(url: ParseResult, options: dict[str, Any]):
3840
return url.path
3941

4042

4143
@register("django.core.cache.backends.memcached.PyMemcacheCache", "pymemcache")
4244
@register("django.core.cache.backends.memcached.PyLibMCCache", "pylibmc")
43-
def memcached_location(url, options):
45+
def memcached_location(url: ParseResult, options: dict[str, Any]):
4446
addresses = url.netloc.split(",")
4547
if len(addresses) == 1:
4648
return addresses[0]
@@ -49,31 +51,35 @@ def memcached_location(url, options):
4951

5052

5153
@register("django.core.cache.backends.dummy.DummyCache", "dummy")
52-
def null_location(url, options):
54+
def null_location(url: ParseResult, options: dict[str, Any]):
5355
return None
5456

5557

5658
@register("django.core.cache.backends.redis.RedisCache", "redis")
57-
def redis_location(url, options):
59+
def redis_location(url: ParseResult, options: dict[str, Any]):
5860
path = url.path.lstrip("/")
5961
if path.isdigit() and "db" not in options:
6062
options["db"] = path
6163
return "{}://{}".format(url.scheme, url.netloc)
6264

6365

64-
def parse(url, backend=None, **settings):
65-
if isinstance(url, dict):
66-
return {**url, **settings}
66+
def parse(
67+
url_or_config: Union[str, dict[str, Any]],
68+
backend: Optional[str] = None,
69+
**settings: Any,
70+
):
71+
if isinstance(url_or_config, dict):
72+
return {**url_or_config, **settings}
6773

68-
url = urlparse.urlparse(url)
74+
url = urlparse(url_or_config)
6975
if url.scheme not in CACHE_SCHEMES:
7076
raise ValueError(f"Unknown cache scheme: {url.scheme}")
7177
engine = CACHE_SCHEMES[url.scheme]
72-
options = {}
78+
options: dict[str, Any] = {}
7379

7480
# Pass the query string into OPTIONS.
7581
if url.query:
76-
for key, values in urlparse.parse_qs(url.query).items():
82+
for key, values in parse_qs(url.query).items():
7783
if values:
7884
if len(values) == 1:
7985
options[key] = values[0]
@@ -84,7 +90,7 @@ def parse(url, backend=None, **settings):
8490
options.update(settings.pop("OPTIONS", {}))
8591

8692
# Update with environment configuration.
87-
config = {"BACKEND": backend or engine.backend}
93+
config: dict[str, Any] = {"BACKEND": backend or engine.backend}
8894

8995
# Allow Django's cache settings to be specified as querystring options.
9096
for name in ("timeout", "key_prefix", "key_function", "version"):

0 commit comments

Comments
 (0)