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.
- apppy_env-0.1.0/.gitignore +2 -0
- apppy_env-0.1.0/PKG-INFO +12 -0
- apppy_env-0.1.0/README.md +0 -0
- apppy_env-0.1.0/env.mk +23 -0
- apppy_env-0.1.0/pyproject.toml +26 -0
- apppy_env-0.1.0/src/apppy/env/.secrets/dummy/APP_TEST_SECRET +1 -0
- apppy_env-0.1.0/src/apppy/env/__init__.py +359 -0
- apppy_env-0.1.0/src/apppy/env/env_fixtures.py +64 -0
- apppy_env-0.1.0/src/apppy/env/env_unit_test.py +35 -0
apppy_env-0.1.0/PKG-INFO
ADDED
|
@@ -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}"
|