frozenenv 0.1.0__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.
- frozenenv-0.1.0/.gitignore +29 -0
- frozenenv-0.1.0/LICENSE +21 -0
- frozenenv-0.1.0/PKG-INFO +42 -0
- frozenenv-0.1.0/README.md +0 -0
- frozenenv-0.1.0/pyproject.toml +37 -0
- frozenenv-0.1.0/src/envclass/__init__.py +163 -0
- frozenenv-0.1.0/src/envclass/py.typed +1 -0
- frozenenv-0.1.0/tests/test_envclass.py +155 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Virtual environment
|
|
2
|
+
.venv/
|
|
3
|
+
venv/
|
|
4
|
+
env/
|
|
5
|
+
|
|
6
|
+
# Build output
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
|
|
11
|
+
# Python cache
|
|
12
|
+
__pycache__/bytecode cache
|
|
13
|
+
*.py[cod]
|
|
14
|
+
*.pyo
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
|
|
18
|
+
# Environment / secrets
|
|
19
|
+
.envcontains
|
|
20
|
+
.env.*
|
|
21
|
+
.pypirccontains
|
|
22
|
+
|
|
23
|
+
# IDE files
|
|
24
|
+
.vscode/optional
|
|
25
|
+
.idea/
|
|
26
|
+
|
|
27
|
+
# OS junk
|
|
28
|
+
.DS_StoremacOS
|
|
29
|
+
Thumbs.db
|
frozenenv-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
frozenenv-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: frozenenv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed environment variables as a frozen dataclass — zero dependencies
|
|
5
|
+
Project-URL: Homepage, https://github.com/hudihi/frozenenv
|
|
6
|
+
Project-URL: Repository, https://github.com/hudihi/frozenenv
|
|
7
|
+
Project-URL: Issues, https://github.com/hudihi/frozenenv/issues
|
|
8
|
+
Author-email: Abdillah Issa <hudihudiabdillah@gmail.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Your Name
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: config,dotenv,environment,settings,typed
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
41
|
+
Classifier: Typing :: Typed
|
|
42
|
+
Requires-Python: >=3.10
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "frozenenv"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed environment variables as a frozen dataclass — zero dependencies"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
authors = [{ name = "Abdillah Issa", email = "hudihudiabdillah@gmail.com" }]
|
|
12
|
+
keywords = ["environment", "config", "dotenv", "settings", "typed"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
requires-python = ">=3.10"
|
|
26
|
+
dependencies = [] # ← zero deps, intentional
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/hudihi/frozenenv"
|
|
30
|
+
Repository = "https://github.com/hudihi/frozenenv"
|
|
31
|
+
Issues = "https://github.com/hudihi/frozenenv/issues"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/envclass"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import types
|
|
7
|
+
import typing
|
|
8
|
+
from typing import Any, get_type_hints
|
|
9
|
+
|
|
10
|
+
__all__ = ["envclass", "EnvError"]
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__BOOL_TRUE = {"1", "true", "yes", "on", "enabled"}
|
|
14
|
+
__BOOL_FALSE = {"0", "false", "no", "off", "disabled"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EnvError(Exception):
|
|
19
|
+
"""Raised when a required env var is missing or cannot be cast to the expected type."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_dotenv(path: str | os.PathLike) -> dict[str, str]:
|
|
23
|
+
"""Parse KEY=VALUE lines from a .env file. Returns empty dict if not found."""
|
|
24
|
+
result: dict[str, str] = {}
|
|
25
|
+
p = pathlib.Path(path)
|
|
26
|
+
|
|
27
|
+
if not p.exists():
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
for raw in p.read_text(encoding="utf-8").splitlines():
|
|
31
|
+
line = raw.strip()
|
|
32
|
+
if not line or line.startswith("#"):
|
|
33
|
+
continue
|
|
34
|
+
if line.startswith("export "):
|
|
35
|
+
line = line[7:].lstrip()
|
|
36
|
+
if "=" not in line:
|
|
37
|
+
continue
|
|
38
|
+
key, _, value = line.partition("=")
|
|
39
|
+
key = key.strip()
|
|
40
|
+
value = value.strip()
|
|
41
|
+
|
|
42
|
+
#strip surrounding quotes if present
|
|
43
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
|
44
|
+
value = value[1:-1]
|
|
45
|
+
if key:
|
|
46
|
+
result[key] = value
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _coerce(value: str, annotation: Any, name: str) -> Any:
|
|
51
|
+
"""Cast string value to the given type annontation."""
|
|
52
|
+
origin = typing.get_origin(annotation)
|
|
53
|
+
args = typing.get_args(annotation)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Optional[X] or X | None
|
|
57
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
58
|
+
non_none = [a for a in args if a is not type(None)]
|
|
59
|
+
if not value and type(None) in args:
|
|
60
|
+
return None
|
|
61
|
+
return _coerce(value, non_none[0], name) if non_none else value
|
|
62
|
+
|
|
63
|
+
# bool - must come before int (bool is a subclass of int!)
|
|
64
|
+
if annotation is bool:
|
|
65
|
+
val = value.lower()
|
|
66
|
+
if val in __BOOL_TRUE:
|
|
67
|
+
return True
|
|
68
|
+
if val in __BOOL_FALSE:
|
|
69
|
+
return False
|
|
70
|
+
opts = ", ".join(sorted(__BOOL_TRUE | __BOOL_FALSE))
|
|
71
|
+
raise EnvError(f"{name}: '{value}' is not a valid bool. Use: {opts}")
|
|
72
|
+
|
|
73
|
+
# int, float, str
|
|
74
|
+
if annotation in (int, float, str):
|
|
75
|
+
try:
|
|
76
|
+
return annotation(value)
|
|
77
|
+
except (ValueError, TypeError) as exc:
|
|
78
|
+
raise EnvError(f"{name}: cannot cast '{value} to {annotation.__name__} ") from exc
|
|
79
|
+
# list[X] - comma-separated
|
|
80
|
+
if origin is list:
|
|
81
|
+
if not value:
|
|
82
|
+
return []
|
|
83
|
+
item_type = args[0] if args else str
|
|
84
|
+
return [_coerce(v.strip(), item_type, name) for v in value.split(",")]
|
|
85
|
+
|
|
86
|
+
# Fallback: return as string
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
def envclass(cls=None, *, env_file: str | os.PathLike | None = ".env",override: bool = False):
|
|
90
|
+
"""Decorator that turns a class into a typed, frozen env-var config object.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
env_file: Path to a .env file to load. Defaults to '.env' in cwd.
|
|
94
|
+
Set to None to skip .env loading entirely.
|
|
95
|
+
override: If True, .env values overwrite real environment variables.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def wrap(klass):
|
|
99
|
+
# 1. Make it a frozen dataclass so config can't be mutated
|
|
100
|
+
dc = dataclasses.dataclass(klass, frozen=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# 2. Grab the resolved type hints (handles forward refs)
|
|
104
|
+
hints = get_type_hints(dc)
|
|
105
|
+
|
|
106
|
+
# 3. Collect defaults from the dataclass fields
|
|
107
|
+
defaults: dict[str, Any] = {}
|
|
108
|
+
for f in dataclasses.fields(dc):
|
|
109
|
+
if f.default is not dataclasses.MISSING:
|
|
110
|
+
defaults[f.name] = f.default
|
|
111
|
+
elif f.default_factory is not dataclasses.MISSING:
|
|
112
|
+
defaults[f.name] = f.default_factory()
|
|
113
|
+
|
|
114
|
+
# 4. Build a custom __init__ that reads from env
|
|
115
|
+
original_init = dc.__init__
|
|
116
|
+
|
|
117
|
+
def __init__(self, *, _env: dict[str, str] | None = None):
|
|
118
|
+
# Load .env file first
|
|
119
|
+
dotenv_vals: dict[str, str] = {}
|
|
120
|
+
|
|
121
|
+
if env_file is not None:
|
|
122
|
+
dotenv_vals = _parse_dotenv(env_file)
|
|
123
|
+
|
|
124
|
+
# Merge: real env wins unless override=True
|
|
125
|
+
source = dict(dotenv_vals)
|
|
126
|
+
if override:
|
|
127
|
+
source.update(os.environ)
|
|
128
|
+
else:
|
|
129
|
+
for k, v in os.environ.items():
|
|
130
|
+
source[k] = v
|
|
131
|
+
|
|
132
|
+
# Allow test injection via _env parameter
|
|
133
|
+
if _env is not None:
|
|
134
|
+
source.update(_env)
|
|
135
|
+
|
|
136
|
+
# Resolve each field
|
|
137
|
+
kwargs: dict[str, Any] = {}
|
|
138
|
+
missing = []
|
|
139
|
+
|
|
140
|
+
for field_name, annotation in hints.items():
|
|
141
|
+
raw = source.get(field_name)
|
|
142
|
+
if raw is not None:
|
|
143
|
+
kwargs[field_name] = _coerce(raw, annotation, field_name)
|
|
144
|
+
elif field_name in defaults:
|
|
145
|
+
kwargs[field_name] = defaults[field_name]
|
|
146
|
+
else:
|
|
147
|
+
missing.append(field_name)
|
|
148
|
+
|
|
149
|
+
if missing:
|
|
150
|
+
raise EnvError(f"Missing required env vars: {', '.join(missing)}")
|
|
151
|
+
|
|
152
|
+
# Call the frozen dataclass __init__ via object.__setattr__
|
|
153
|
+
for k, v in kwargs.items():
|
|
154
|
+
object.__setattr__(self, k, v)
|
|
155
|
+
|
|
156
|
+
dc.__init__ = __init__
|
|
157
|
+
return dc
|
|
158
|
+
|
|
159
|
+
if cls is None:
|
|
160
|
+
return wrap
|
|
161
|
+
return wrap(cls)
|
|
162
|
+
|
|
163
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# marks package as typed
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# tests file for envclass.py
|
|
2
|
+
import pytest
|
|
3
|
+
from envclass import envclass, EnvError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_reads_string(monkeypatch):
|
|
7
|
+
monkeypatch.setenv("HOST", "localhost")
|
|
8
|
+
|
|
9
|
+
@envclass(env_file=None)
|
|
10
|
+
class Config:
|
|
11
|
+
HOST: str
|
|
12
|
+
|
|
13
|
+
assert Config().HOST == "localhost"
|
|
14
|
+
|
|
15
|
+
def test_reads_int(monkeypatch):
|
|
16
|
+
monkeypatch.setenv("PORT", "9000")
|
|
17
|
+
|
|
18
|
+
@envclass(env_file=None)
|
|
19
|
+
class Config:
|
|
20
|
+
PORT: int
|
|
21
|
+
|
|
22
|
+
assert Config().PORT == 9000
|
|
23
|
+
assert isinstance(Config().PORT, int)
|
|
24
|
+
|
|
25
|
+
def test_reads_float(monkeypatch):
|
|
26
|
+
monkeypatch.setenv("RATE", "3.14")
|
|
27
|
+
|
|
28
|
+
@envclass(env_file=None)
|
|
29
|
+
class Config:
|
|
30
|
+
RATE: float
|
|
31
|
+
|
|
32
|
+
assert Config().RATE == 3.14
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.parametrize("val", ["true", "True", "1", "yes", "on", "enabled"])
|
|
36
|
+
def test_bool_true(monkeypatch, val):
|
|
37
|
+
monkeypatch.setenv("FLAG", val)
|
|
38
|
+
|
|
39
|
+
@envclass(env_file=None)
|
|
40
|
+
class Config:
|
|
41
|
+
FLAG: bool
|
|
42
|
+
|
|
43
|
+
assert Config().FLAG is True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize("val", ["false", "False", "0", "no", "off", "disabled"])
|
|
47
|
+
def test_bool_false(monkeypatch, val):
|
|
48
|
+
monkeypatch.setenv("FLAG", val)
|
|
49
|
+
|
|
50
|
+
@envclass(env_file=None)
|
|
51
|
+
class Config:
|
|
52
|
+
FLAG: bool
|
|
53
|
+
|
|
54
|
+
assert Config().FLAG is False
|
|
55
|
+
|
|
56
|
+
def test_bool_invalid(monkeypatch):
|
|
57
|
+
monkeypatch.setenv("FLAG", "notabool")
|
|
58
|
+
|
|
59
|
+
@envclass(env_file=None)
|
|
60
|
+
class Config:
|
|
61
|
+
FLAG: bool
|
|
62
|
+
|
|
63
|
+
with pytest.raises(EnvError, match="FLAG"):
|
|
64
|
+
Config()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_list_of_strings(monkeypatch):
|
|
68
|
+
monkeypatch.setenv("ITEMS", "apple,banana, cherry")
|
|
69
|
+
|
|
70
|
+
@envclass(env_file=None)
|
|
71
|
+
class Config:
|
|
72
|
+
ITEMS: list[str]
|
|
73
|
+
|
|
74
|
+
assert Config().ITEMS == ["apple", "banana", "cherry"]
|
|
75
|
+
|
|
76
|
+
def test_list_of_ints(monkeypatch):
|
|
77
|
+
monkeypatch.setenv("PORTS", "8000, 9000, 10000")
|
|
78
|
+
|
|
79
|
+
@envclass(env_file=None)
|
|
80
|
+
class Config:
|
|
81
|
+
PORTS: list[int]
|
|
82
|
+
|
|
83
|
+
assert Config().PORTS == [8000, 9000, 10000]
|
|
84
|
+
|
|
85
|
+
def test_list_empty_string_gives_empty_list(monkeypatch):
|
|
86
|
+
monkeypatch.setenv("ITEMS", "")
|
|
87
|
+
|
|
88
|
+
@envclass(env_file=None)
|
|
89
|
+
class Config:
|
|
90
|
+
ITEMS: list[str]
|
|
91
|
+
|
|
92
|
+
assert Config().ITEMS == []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_default_used_when_var_missing(monkeypatch):
|
|
96
|
+
monkeypatch.delenv("PORT", raising=False)
|
|
97
|
+
|
|
98
|
+
@envclass(env_file=None)
|
|
99
|
+
class Config:
|
|
100
|
+
PORT: int = 8000
|
|
101
|
+
|
|
102
|
+
assert Config().PORT == 8000
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_env_overrides_default(monkeypatch):
|
|
107
|
+
monkeypatch.setenv("PORT", "9000")
|
|
108
|
+
|
|
109
|
+
@envclass(env_file=None)
|
|
110
|
+
class Config:
|
|
111
|
+
PORT: int = 8000
|
|
112
|
+
|
|
113
|
+
assert Config().PORT == 9000
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_missing_required_raises(monkeypatch):
|
|
117
|
+
monkeypatch.delenv("DATABASE_URL", raising=False)
|
|
118
|
+
|
|
119
|
+
@envclass(env_file=None)
|
|
120
|
+
class Config:
|
|
121
|
+
DATABASE_URL: str
|
|
122
|
+
|
|
123
|
+
with pytest.raises(EnvError, match="DATABASE_URL"):
|
|
124
|
+
Config()
|
|
125
|
+
|
|
126
|
+
def test_config_is_frozen(monkeypatch):
|
|
127
|
+
monkeypatch.setenv("PORT", "8000")
|
|
128
|
+
|
|
129
|
+
@envclass(env_file=None)
|
|
130
|
+
class Config:
|
|
131
|
+
PORT: int
|
|
132
|
+
|
|
133
|
+
cfg = Config()
|
|
134
|
+
with pytest.raises(Exception): # FrozenInstanceError
|
|
135
|
+
cfg.PORT = 9999 # type: ignore
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_optional_present(monkeypatch):
|
|
139
|
+
monkeypatch.setenv("TOKEN", "abc123")
|
|
140
|
+
|
|
141
|
+
@envclass(env_file=None)
|
|
142
|
+
class Config:
|
|
143
|
+
TOKEN: str | None = None
|
|
144
|
+
|
|
145
|
+
assert Config().TOKEN == "abc123"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_optional_missing_gives_none(monkeypatch):
|
|
149
|
+
monkeypatch.delenv("TOKEN", raising=False)
|
|
150
|
+
|
|
151
|
+
@envclass(env_file=None)
|
|
152
|
+
class Config:
|
|
153
|
+
TOKEN: str | None = None
|
|
154
|
+
|
|
155
|
+
assert Config().TOKEN is None
|