apppy-env 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.
@@ -0,0 +1,2 @@
1
+ # Allow a dummy .secrets dir for testing purposes
2
+ !src/apppy/env/.secrets
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: apppy-env
3
+ Version: 0.1.0
4
+ Summary: Python environment configuration helpers for server development
5
+ Project-URL: Homepage, https://github.com/spals/apppy
6
+ Author: Tim Kral
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: pydantic-settings==2.10.1
12
+ Requires-Dist: pydantic==2.11.7
File without changes
apppy_env-0.1.0/env.mk ADDED
@@ -0,0 +1,23 @@
1
+ ifndef APPPY_ENV_MK_INCLUDED
2
+ APPPY_ENV_MK_INCLUDED := 1
3
+ ENV_PKG_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
4
+
5
+ .PHONY: env env-dev env/build env/clean env/install env/install-dev
6
+
7
+ env: env/clean env/install
8
+
9
+ env-dev: env/clean env/install-dev
10
+
11
+ env/build:
12
+ cd $(ENV_PKG_DIR) && uvx --from build pyproject-build
13
+
14
+ env/clean:
15
+ cd $(ENV_PKG_DIR) && rm -rf dist/ *.egg-info .venv
16
+
17
+ env/install: env/build
18
+ cd $(ENV_PKG_DIR) && uv pip install dist/*.whl
19
+
20
+ env/install-dev:
21
+ cd $(ENV_PKG_DIR) && uv pip install -e .
22
+
23
+ endif # APPPY_ENV_MK_INCLUDED
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apppy-env"
7
+ version = "0.1.0"
8
+ description = "Python environment configuration helpers for server development"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [{ name = "Tim Kral" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ ]
17
+ dependencies = [
18
+ "pydantic==2.11.7",
19
+ "pydantic-settings==2.10.1",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/spals/apppy"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/apppy"]
@@ -0,0 +1 @@
1
+ my-secret-value
@@ -0,0 +1,359 @@
1
+ import abc
2
+ import inspect
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic.fields import FieldInfo
9
+ from pydantic_settings import (
10
+ BaseSettings,
11
+ PydanticBaseSettingsSource,
12
+ SettingsConfigDict,
13
+ )
14
+ from pydantic_settings.sources import PydanticBaseEnvSettingsSource
15
+
16
+
17
+ class DictSettingsSource(PydanticBaseEnvSettingsSource):
18
+ _logger = logging.getLogger("apppy.env.DictSettingsSource")
19
+
20
+ def __init__(
21
+ self,
22
+ settings_cls: type[BaseSettings],
23
+ d: dict | None,
24
+ ) -> None:
25
+ super().__init__(settings_cls)
26
+ self._dict: dict | None = d
27
+
28
+ def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
29
+ # See implementation in EnvSettingsSource
30
+ dict_val: str | None = None
31
+ for field_key, env_name, value_is_complex in self._extract_field_info(field, field_name): # noqa: B007
32
+ # In some cases (e.g. Env object with no overrides)
33
+ # the central dictionary will be None so we'll just
34
+ # return with the field information
35
+ if self._dict is None:
36
+ # Because we break here, we're technically
37
+ # returning the first field information
38
+ break
39
+
40
+ dict_val = self._dict.get(env_name)
41
+ if dict_val is not None:
42
+ break
43
+ # Attempt a lookup by the field key so that we
44
+ # accept that as well (i.e. the Robustness Principle)
45
+ dict_val = self._dict.get(field_key)
46
+ if dict_val is not None:
47
+ break
48
+
49
+ return dict_val, field_key, value_is_complex
50
+
51
+
52
+ ##### ##### ##### Environment ##### ##### #####
53
+ # An environment represents the external space
54
+ # in which an application is executing.
55
+
56
+
57
+ class Env(abc.ABC):
58
+ _logger = logging.getLogger("apppy.env.Env")
59
+
60
+ def __init__(self, prefix: str, name: str, overrides: dict | None = None) -> None:
61
+ self.prefix: str = prefix
62
+ self.name: str = name
63
+ self.overrides: dict | None = overrides
64
+
65
+ @abc.abstractmethod
66
+ def exists(self) -> bool:
67
+ return False
68
+
69
+ @property
70
+ def is_ci(self) -> bool:
71
+ return self.name.startswith("ci") or self.name.endswith("ci")
72
+
73
+ @property
74
+ def is_production(self) -> bool:
75
+ return self.name == "prod" or self.name == "production"
76
+
77
+ @property
78
+ def is_test(self) -> bool:
79
+ return self.name.startswith("test") or self.name.endswith("test")
80
+
81
+ @property
82
+ @abc.abstractmethod
83
+ def settings_config(self) -> SettingsConfigDict:
84
+ pass
85
+
86
+ @abc.abstractmethod
87
+ def settings_sources(
88
+ self,
89
+ settings_cls: type[BaseSettings],
90
+ init_settings: PydanticBaseSettingsSource,
91
+ env_settings: PydanticBaseSettingsSource,
92
+ dotenv_settings: PydanticBaseSettingsSource,
93
+ file_secret_settings: PydanticBaseSettingsSource,
94
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
95
+ pass
96
+
97
+ @staticmethod
98
+ def load(prefix: str = "APP", name: str | None = None, overrides: dict | None = None) -> "Env":
99
+ env_prefix = prefix
100
+
101
+ # Find the environment name
102
+ # Prefer an explicitly provided name over an environment variable name
103
+ if name:
104
+ env_name = name
105
+ elif "{env_prefix}_ENV" in os.environ:
106
+ env_name = os.environ[f"{env_prefix}_ENV"]
107
+ else:
108
+ logging.fatal("!!! No environment name provided !!!")
109
+ os._exit(1)
110
+
111
+ file_env = FileEnv(prefix=env_prefix, name=env_name, overrides=overrides)
112
+ if file_env.exists():
113
+ return file_env
114
+
115
+ vars_env = VarsEnv(prefix=env_prefix, name=env_name, overrides=overrides)
116
+ if vars_env.exists():
117
+ return vars_env
118
+
119
+ logging.fatal(f"!!! {env_name} environment not found !!!")
120
+ os._exit(1)
121
+
122
+ @staticmethod
123
+ def find_secrets_dir(env: "Env") -> Path | None:
124
+ """
125
+ Search upward from the *caller*'s file for either:
126
+ - CI/TEST: <ancestor>/.github/ci/secrets
127
+ - otherwise: <ancestor>/.secrets/<env.name>
128
+
129
+ Returns the resolved Path if found, else None.
130
+ """
131
+ if env.is_production:
132
+ # Never use file-based secrets in production(-like) envs.
133
+ return None
134
+
135
+ # Start from the callsite's directory; fall back to CWD if inspection fails.
136
+ try:
137
+ call_stack = inspect.stack()
138
+ call_file = next(
139
+ Path(frame.filename).resolve()
140
+ for frame in call_stack
141
+ # Skip to the first frame outside of the package
142
+ if (
143
+ frame.filename.find("/apppy/app/") == -1
144
+ and frame.filename.find("/apppy/env/") == -1
145
+ )
146
+ or
147
+ # or, as a special case, allow the package to run tests
148
+ (frame.filename.find("/apppy/env") > -1 and frame.filename.endswith("_test.py"))
149
+ )
150
+ start_dir = call_file.parent
151
+ except Exception:
152
+ start_dir = Path.cwd()
153
+
154
+ # Walk upward: start_dir, then its parents up to filesystem root.
155
+ search_roots = [start_dir, *start_dir.parents]
156
+
157
+ # CI/TEST: prefer .github/ci/secrets anywhere up the tree (usually repo root)
158
+ if env.is_ci or env.is_test:
159
+ for base in search_roots:
160
+ ci_dir = (base / ".github" / "ci" / "secrets").resolve()
161
+ if ci_dir.is_dir():
162
+ logging.info(
163
+ "A CI/Test secrets directory was found at %s; it will be INCLUDED "
164
+ "in settings configuration.",
165
+ ci_dir,
166
+ )
167
+ return ci_dir
168
+
169
+ # Non-CI: look for application-local .secrets/<env.name> up the tree
170
+ for base in search_roots:
171
+ candidate = (base / ".secrets" / env.name).resolve()
172
+ if candidate.is_dir():
173
+ logging.info(
174
+ "A secrets directory exists for environment '%s' at %s; it will be "
175
+ "INCLUDED in settings configuration.",
176
+ env.name,
177
+ candidate,
178
+ )
179
+ return candidate
180
+
181
+ logging.info(
182
+ f"No secrets directory exists for environment '{env.name}'. It will be SKIPPED in settings configuration." # noqa: E501
183
+ )
184
+ return None
185
+
186
+
187
+ class DictEnv(Env):
188
+ _logger = logging.getLogger("apppy.env.DictEnv")
189
+
190
+ def __init__(self, name: str, d: dict) -> None:
191
+ super().__init__(prefix="", name=name, overrides=None)
192
+ self._dict: dict = d
193
+ # Ensure that the env name is available under
194
+ # the env key (FileEnv and VarsEnv naturally have this construct)
195
+ self._dict["env"] = name
196
+
197
+ def __hash__(self):
198
+ # Define a hash function here in order
199
+ # to cache the same envs used in testing
200
+ return hash(frozenset(self._dict.items()))
201
+
202
+ def exists(self) -> bool:
203
+ return True
204
+
205
+ @property
206
+ def settings_config(self) -> SettingsConfigDict:
207
+ return SettingsConfigDict(
208
+ env_prefix=self.prefix,
209
+ # A DictEnv is only used in narrow testing flows in which
210
+ # secrets should not be necessary so we will force that
211
+ # to be None
212
+ secrets_dir=None,
213
+ )
214
+
215
+ def settings_sources(
216
+ self,
217
+ settings_cls: type[BaseSettings],
218
+ init_settings: PydanticBaseSettingsSource,
219
+ env_settings: PydanticBaseSettingsSource,
220
+ dotenv_settings: PydanticBaseSettingsSource,
221
+ file_secret_settings: PydanticBaseSettingsSource,
222
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
223
+ return (
224
+ init_settings,
225
+ DictSettingsSource(settings_cls, self._dict),
226
+ file_secret_settings,
227
+ )
228
+
229
+
230
+ class FileEnv(Env):
231
+ _logger = logging.getLogger("apppy.env.FileEnv")
232
+
233
+ def __init__(self, prefix: str, name: str, overrides: dict | None = None) -> None:
234
+ super().__init__(prefix=prefix, name=name, overrides=overrides)
235
+ self._secrets_dir = Env.find_secrets_dir(self)
236
+ self.env_file: Path = (
237
+ Path(".github/ci/.env.ci") if self.is_ci or self.is_test else Path(f".env.{self.name}")
238
+ )
239
+
240
+ def exists(self) -> bool:
241
+ return self.env_file.exists()
242
+
243
+ @property
244
+ def settings_config(self) -> SettingsConfigDict:
245
+ return SettingsConfigDict(
246
+ env_file=self.env_file,
247
+ env_prefix=self.prefix,
248
+ secrets_dir=self._secrets_dir,
249
+ )
250
+
251
+ def settings_sources(
252
+ self,
253
+ settings_cls: type[BaseSettings],
254
+ init_settings: PydanticBaseSettingsSource,
255
+ env_settings: PydanticBaseSettingsSource,
256
+ dotenv_settings: PydanticBaseSettingsSource,
257
+ file_secret_settings: PydanticBaseSettingsSource,
258
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
259
+ return (
260
+ # Put overrides here so they take precedence over
261
+ # all other settings
262
+ DictSettingsSource(settings_cls, self.overrides),
263
+ init_settings,
264
+ env_settings,
265
+ dotenv_settings,
266
+ file_secret_settings,
267
+ )
268
+
269
+
270
+ class VarsEnv(Env):
271
+ _logger = logging.getLogger("apppy.env.VarsEnv")
272
+
273
+ def __init__(self, prefix: str, name: str, overrides: dict | None = None) -> None:
274
+ super().__init__(prefix=prefix, name=name, overrides=overrides)
275
+ self._secrets_dir = Env.find_secrets_dir(self)
276
+
277
+ def exists(self) -> bool:
278
+ return f"{self.prefix}_ENV" in os.environ and os.environ[f"{self.prefix}_ENV"] == self.name
279
+
280
+ @property
281
+ def settings_config(self) -> SettingsConfigDict:
282
+ return SettingsConfigDict(
283
+ env_prefix=self.prefix,
284
+ secrets_dir=self._secrets_dir,
285
+ )
286
+
287
+ def settings_sources(
288
+ self,
289
+ settings_cls: type[BaseSettings],
290
+ init_settings: PydanticBaseSettingsSource,
291
+ env_settings: PydanticBaseSettingsSource,
292
+ dotenv_settings: PydanticBaseSettingsSource,
293
+ file_secret_settings: PydanticBaseSettingsSource,
294
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
295
+ return (
296
+ # Put overrides here so they take precedence over
297
+ # all other settings
298
+ DictSettingsSource(settings_cls, self.overrides),
299
+ init_settings,
300
+ env_settings,
301
+ file_secret_settings,
302
+ )
303
+
304
+
305
+ ##### ##### ##### Environment Settings ##### ##### #####
306
+
307
+
308
+ class EnvSettings(BaseSettings):
309
+ model_config = SettingsConfigDict(
310
+ extra="ignore",
311
+ )
312
+
313
+ def __init__(self, env: Env) -> None:
314
+ super().__init__(
315
+ _case_sensitive=env.settings_config.get("case_sensitive"),
316
+ _nested_model_default_partial_update=env.settings_config.get(
317
+ "nested_model_default_partial_update"
318
+ ),
319
+ _env_prefix=env.settings_config.get("env_prefix"),
320
+ _env_file=env.settings_config.get("env_file"),
321
+ _env_file_encoding=env.settings_config.get("env_file_encoding"),
322
+ _env_ignore_empty=env.settings_config.get("env_ignore_empty"),
323
+ _env_nested_delimiter=env.settings_config.get("env_nested_delimiter"),
324
+ _env_parse_none_str=env.settings_config.get("env_parse_none_str"),
325
+ _env_parse_enums=env.settings_config.get("env_parse_enums"),
326
+ # _cli_prog_name: str | None = None,
327
+ # _cli_parse_args: bool | list[str] | tuple[str, ...] | None = None,
328
+ # _cli_settings_source: CliSettingsSource[Any] | None = None,
329
+ # _cli_parse_none_str: str | None = None,
330
+ # _cli_hide_none_type: bool | None = None,
331
+ # _cli_avoid_json: bool | None = None,
332
+ # _cli_enforce_required: bool | None = None,
333
+ # _cli_use_class_docs_for_groups: bool | None = None,
334
+ # _cli_exit_on_error: bool | None = None,
335
+ # _cli_prefix: str | None = None,
336
+ # _cli_flag_prefix_char: str | None = None,
337
+ # _cli_implicit_flags: bool | None = None,
338
+ # _cli_ignore_unknown_args: bool | None = None,
339
+ _secrets_dir=env.settings_config.get("secrets_dir"),
340
+ values=env,
341
+ )
342
+
343
+ @classmethod
344
+ def settings_customise_sources(
345
+ cls,
346
+ settings_cls: type[BaseSettings],
347
+ init_settings: PydanticBaseSettingsSource,
348
+ env_settings: PydanticBaseSettingsSource,
349
+ dotenv_settings: PydanticBaseSettingsSource,
350
+ file_secret_settings: PydanticBaseSettingsSource,
351
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
352
+ env = init_settings.init_kwargs["values"] # type: ignore
353
+ return env.settings_sources(
354
+ settings_cls,
355
+ init_settings,
356
+ env_settings,
357
+ dotenv_settings,
358
+ file_secret_settings,
359
+ )
@@ -0,0 +1,64 @@
1
+ import datetime
2
+ import os
3
+ import pathlib
4
+
5
+ import pytest
6
+
7
+ from . import Env
8
+
9
+
10
+ @pytest.fixture(scope="session")
11
+ def env_ci():
12
+ ci_env: Env = Env.load(name="ci")
13
+ yield ci_env
14
+
15
+
16
+ @pytest.fixture(scope="session")
17
+ def env_local():
18
+ """
19
+ Environment fixure which reads the currently configured
20
+ local environment and makes this globally available to
21
+ all tests.
22
+
23
+ This may be helpful in the case where you would like to
24
+ tweak local settings and run tests against them without
25
+ having to push those settings into a code change.
26
+ """
27
+ test_env: Env = Env.load(name="local")
28
+ yield test_env
29
+
30
+
31
+ @pytest.fixture
32
+ def env_overrides(request):
33
+ """
34
+ Environment fixure which allows any test
35
+ to override the currently configured environment.
36
+ """
37
+ return getattr(request, "param", {})
38
+
39
+
40
+ @pytest.fixture
41
+ def env_unit(monkeypatch: pytest.MonkeyPatch, env_overrides):
42
+ """
43
+ Environment fixure which allows a unit test to
44
+ use an Env instance.
45
+ """
46
+ monkeypatch.setenv("APP_ENV", current_test_name())
47
+ return Env.load(name=current_test_name(), overrides=env_overrides)
48
+
49
+
50
+ def current_test_file() -> str:
51
+ # Returns the base name (without suffix) of the test file currently being executed.
52
+ test_path = os.environ["PYTEST_CURRENT_TEST"].split("::")[0]
53
+ return pathlib.Path(test_path).stem
54
+
55
+
56
+ def current_test_name() -> str:
57
+ test_name = os.environ["PYTEST_CURRENT_TEST"].split(":")[-1].split(" ")[0]
58
+ # Test names can carry parameterize names (e.g. my_test[my_parameters])
59
+ # brakcets are not safe for use in some circumstances so strip those out
60
+ return test_name.replace("[", "_").replace("]", "_")
61
+
62
+
63
+ def current_test_time() -> str:
64
+ return datetime.datetime.now().strftime("%Y%m%d%H%M%S")
@@ -0,0 +1,35 @@
1
+ import pytest
2
+
3
+ from apppy.env import Env
4
+
5
+
6
+ def test_find_secrets_dir_for_ci():
7
+ env = Env.load(name="ci")
8
+ secrets_dir = Env.find_secrets_dir(env)
9
+
10
+ assert secrets_dir.exists() is True
11
+ assert str(secrets_dir).endswith(
12
+ ".github/ci/secrets"
13
+ ), f"Unexpected secrets dir for ci: {secrets_dir}"
14
+
15
+
16
+ def test_find_secrets_dir_for_test():
17
+ env = Env.load(name="test_find_secrets_dir_for_test")
18
+ secrets_dir = Env.find_secrets_dir(env)
19
+
20
+ assert secrets_dir.exists() is True
21
+ assert str(secrets_dir).endswith(
22
+ ".github/ci/secrets"
23
+ ), f"Unexpected secrets dir for test: {secrets_dir}"
24
+
25
+
26
+ def test_find_secrets_dir(monkeypatch: pytest.MonkeyPatch):
27
+ monkeypatch.setenv("APP_ENV", "dummy")
28
+
29
+ env = Env.load(name="dummy")
30
+ secrets_dir = Env.find_secrets_dir(env)
31
+
32
+ assert secrets_dir.exists() is True
33
+ assert str(secrets_dir).endswith(
34
+ "src/apppy/env/.secrets/dummy"
35
+ ), f"Unexpected secrets dir found: {secrets_dir}"