aignostics-foundry-core 0.0.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,90 @@
1
+ # .gitignore of project Foundry Python Core
2
+
3
+ # Environment
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+ .envrc
8
+ ENV/
9
+ env/
10
+
11
+ ## secrets
12
+ .secret
13
+ .secrets
14
+ .secrets.toml
15
+ .secrets.yaml
16
+ .secrets.yml
17
+ .secrets.json
18
+ .act-env-secret
19
+
20
+ # More secrets
21
+ .ssh
22
+ .aws
23
+
24
+ # Python virtual environment
25
+ venv/
26
+ .venv/
27
+
28
+ # Python temps
29
+ *.py[cdo]
30
+ __pycache__/
31
+ *.so
32
+ *.egg
33
+ *.egg-info/
34
+ *.log
35
+ dist/
36
+ build/
37
+ eggs/
38
+ parts/
39
+ sdist/
40
+ develop-eggs/
41
+ .installed.cfg
42
+ .Python
43
+ .pytest_cache/
44
+ .ruff_cache/
45
+ .nox
46
+ .coverage
47
+ .coverage.*
48
+
49
+
50
+ # Build Report
51
+ reports/*
52
+ !reports/.keep
53
+ !reports/README.md
54
+
55
+ # IDE
56
+ .idea/
57
+ .vscode/
58
+ *.swp
59
+ *.swo
60
+
61
+ # macOS
62
+ .DS_Store
63
+
64
+ # Other OS
65
+ lib/
66
+ lib64/
67
+
68
+ # Data
69
+ var/
70
+ tmp/
71
+
72
+ # Node temps
73
+ node_modules/
74
+
75
+
76
+ # AI workflow
77
+ .fixme
78
+
79
+ # Copier
80
+ *.rej
81
+
82
+ # Scalene
83
+ profile.json
84
+ profile.html
85
+
86
+
87
+
88
+
89
+
90
+ # Application specific
@@ -0,0 +1,8 @@
1
+ Aignostics Commercial License
2
+
3
+ Copyright (c) 2025 Aignostics GmbH
4
+
5
+ All rights reserved.
6
+
7
+ No part of this software may be used, copied, modified, or distributed
8
+ without prior written permission from Aignostics GmbH.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: aignostics-foundry-core
3
+ Version: 0.0.0
4
+ Summary: 🏭 Foundational infrastructure for Foundry components.
5
+ Project-URL: Homepage, https://github.com/aignostics/foundry-python-core
6
+ Project-URL: Documentation, https://github.com/aignostics/foundry-python-core#readme
7
+ Project-URL: Source, https://github.com/aignostics/foundry-python-core
8
+ Project-URL: Changelog, https://github.com/aignostics/foundry-python-core/releases
9
+ Project-URL: Issues, https://github.com/aignostics/foundry-python-core/issues
10
+ Author-email: Oliver Meyer <oliver.meyer@aignostics.com>
11
+ License: Aignostics Commercial License
12
+
13
+ Copyright (c) 2025 Aignostics GmbH
14
+
15
+ All rights reserved.
16
+
17
+ No part of this software may be used, copied, modified, or distributed
18
+ without prior written permission from Aignostics GmbH.
19
+ License-File: LICENSE
20
+ Keywords: act,aignostics-foundry-core,codecov,copier,cyclonedx,detect-secrets,foundry,foundry-python,jupyter,nox,pip-audit,pip-licenses,pre-commit,pypi,pytest,python,ruff,sonarcloud,sonarqube,uv
21
+ Classifier: Development Status :: 2 - Pre-Alpha
22
+ Classifier: Framework :: Pytest
23
+ Classifier: Intended Audience :: Developers
24
+ Classifier: License :: OSI Approved :: MIT License
25
+ Classifier: Natural Language :: English
26
+ Classifier: Operating System :: MacOS :: MacOS X
27
+ Classifier: Operating System :: POSIX :: Linux
28
+ Classifier: Programming Language :: Python
29
+ Classifier: Programming Language :: Python :: 3
30
+ Classifier: Programming Language :: Python :: 3.11
31
+ Classifier: Programming Language :: Python :: 3.12
32
+ Classifier: Programming Language :: Python :: 3.13
33
+ Classifier: Programming Language :: Python :: 3.14
34
+ Classifier: Typing :: Typed
35
+ Requires-Python: <3.15,>=3.11
36
+ Requires-Dist: pydantic-settings<3,>=2
37
+ Requires-Dist: pydantic<3,>=2
38
+ Requires-Dist: rich<15,>=14
39
+ Description-Content-Type: text/markdown
40
+
41
+ # 🏭 Foundry Python Core
42
+
43
+ [![License](https://img.shields.io/badge/license-Proprietary-A41831?labelColor=414042)](https://github.com/aignostics/foundry-python-core/blob/main/LICENSE)
44
+ [![CI](https://github.com/aignostics/foundry-python-core/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/aignostics/foundry-python-core/actions/workflows/ci-cd.yml)
45
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=alert_status&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
46
+ [![Security](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=security_rating&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
47
+ [![Maintainability](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=sqale_rating&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
48
+ [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=sqale_index&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
49
+ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=code_smells&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
50
+ [![Dependabot](https://img.shields.io/badge/dependabot-active-brightgreen?style=flat-square&logo=dependabot)](https://github.com/aignostics/foundry-python-core/security/dependabot)
51
+ [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://github.com/aignostics/foundry-python-core/issues?q=is%3Aissue%20state%3Aopen%20Dependency%20Dashboard)
52
+ [![Coverage](https://codecov.io/gh/aignostics/foundry-python-core/graph/badge.svg?token=MXmzYbXguM)](https://codecov.io/gh/aignostics/foundry-python-core)
53
+ [![Ruff](https://img.shields.io/badge/style-Ruff-blue?color=D6FF65)](https://github.com/aignostics/foundry-python-core/blob/main/noxfile.py)
54
+ [![Pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/)
55
+ [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-orange.json)](https://github.com/aignostics/foundry-python)
56
+
57
+ > [!NOTE]
58
+ > This is your project README - please feel free to update as you see fit.
59
+ > For first steps after scaffolding, check out [FOUNDRY_README.md](FOUNDRY_README.md).
60
+
61
+ ---
62
+
63
+ Foundational infrastructure for Foundry components.
64
+
65
+ ## Prerequisites
66
+
67
+ Install [mise](https://mise.jdx.dev/) (task runner and dev tool manager):
68
+
69
+ ```shell
70
+ brew install mise
71
+ ```
72
+
73
+ Or follow the [installation guide](https://mise.jdx.dev/getting-started.html) for other methods. Then [activate mise](https://mise.jdx.dev/getting-started.html#activate-mise) in your shell profile.
74
+
75
+ ## Usage
76
+
77
+ ```python
78
+ from aignostics_foundry_core.health import Health, HealthStatus
79
+
80
+ health = Health(status=HealthStatus.UP)
81
+ ```
82
+
83
+ ## Further Reading
84
+
85
+ - [Foundry Project Guide](FOUNDRY_README.md) - Complete toolchain, testing, CI/CD, and project setup guide
86
+ - [Security policy](SECURITY.md) - Documentation of security checks, tools, and principles
87
+ - [Release notes](https://github.com/aignostics/foundry-python-core/releases) - Complete log of improvements and changes
88
+ - [Attributions](ATTRIBUTIONS.md) - Open source projects this project builds upon
@@ -0,0 +1,48 @@
1
+ # 🏭 Foundry Python Core
2
+
3
+ [![License](https://img.shields.io/badge/license-Proprietary-A41831?labelColor=414042)](https://github.com/aignostics/foundry-python-core/blob/main/LICENSE)
4
+ [![CI](https://github.com/aignostics/foundry-python-core/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/aignostics/foundry-python-core/actions/workflows/ci-cd.yml)
5
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=alert_status&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
6
+ [![Security](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=security_rating&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
7
+ [![Maintainability](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=sqale_rating&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
8
+ [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=sqale_index&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
9
+ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=code_smells&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
10
+ [![Dependabot](https://img.shields.io/badge/dependabot-active-brightgreen?style=flat-square&logo=dependabot)](https://github.com/aignostics/foundry-python-core/security/dependabot)
11
+ [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://github.com/aignostics/foundry-python-core/issues?q=is%3Aissue%20state%3Aopen%20Dependency%20Dashboard)
12
+ [![Coverage](https://codecov.io/gh/aignostics/foundry-python-core/graph/badge.svg?token=MXmzYbXguM)](https://codecov.io/gh/aignostics/foundry-python-core)
13
+ [![Ruff](https://img.shields.io/badge/style-Ruff-blue?color=D6FF65)](https://github.com/aignostics/foundry-python-core/blob/main/noxfile.py)
14
+ [![Pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/)
15
+ [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-orange.json)](https://github.com/aignostics/foundry-python)
16
+
17
+ > [!NOTE]
18
+ > This is your project README - please feel free to update as you see fit.
19
+ > For first steps after scaffolding, check out [FOUNDRY_README.md](FOUNDRY_README.md).
20
+
21
+ ---
22
+
23
+ Foundational infrastructure for Foundry components.
24
+
25
+ ## Prerequisites
26
+
27
+ Install [mise](https://mise.jdx.dev/) (task runner and dev tool manager):
28
+
29
+ ```shell
30
+ brew install mise
31
+ ```
32
+
33
+ Or follow the [installation guide](https://mise.jdx.dev/getting-started.html) for other methods. Then [activate mise](https://mise.jdx.dev/getting-started.html#activate-mise) in your shell profile.
34
+
35
+ ## Usage
36
+
37
+ ```python
38
+ from aignostics_foundry_core.health import Health, HealthStatus
39
+
40
+ health = Health(status=HealthStatus.UP)
41
+ ```
42
+
43
+ ## Further Reading
44
+
45
+ - [Foundry Project Guide](FOUNDRY_README.md) - Complete toolchain, testing, CI/CD, and project setup guide
46
+ - [Security policy](SECURITY.md) - Documentation of security checks, tools, and principles
47
+ - [Release notes](https://github.com/aignostics/foundry-python-core/releases) - Complete log of improvements and changes
48
+ - [Attributions](ATTRIBUTIONS.md) - Open source projects this project builds upon
@@ -0,0 +1,282 @@
1
+ [project]
2
+ name = "aignostics-foundry-core"
3
+ version = "0.0.0"
4
+ description = "🏭 Foundational infrastructure for Foundry components."
5
+ readme = "README.md"
6
+ authors = [{ name = "Oliver Meyer", email = "oliver.meyer@aignostics.com" }]
7
+ license = { file = "LICENSE" }
8
+
9
+ keywords = [
10
+ "aignostics-foundry-core",
11
+ "act",
12
+ "codecov",
13
+ "copier",
14
+ "cyclonedx",
15
+ "detect-secrets",
16
+ "jupyter",
17
+ "nox",
18
+ "foundry",
19
+ "foundry-python",
20
+ "pip-audit",
21
+ "pip-licenses",
22
+ "pre-commit",
23
+ "pytest",
24
+ "python",
25
+ "pypi",
26
+ "ruff",
27
+ "sonarqube",
28
+ "sonarcloud",
29
+ "uv",
30
+ ]
31
+
32
+ classifiers = [
33
+ "Development Status :: 2 - Pre-Alpha",
34
+ "Intended Audience :: Developers",
35
+ "Programming Language :: Python",
36
+ "Programming Language :: Python :: 3",
37
+ "Programming Language :: Python :: 3.11",
38
+ "Programming Language :: Python :: 3.12",
39
+ "Programming Language :: Python :: 3.13",
40
+ "Programming Language :: Python :: 3.14",
41
+ "License :: OSI Approved :: MIT License",
42
+ "Operating System :: MacOS :: MacOS X",
43
+ "Operating System :: POSIX :: Linux",
44
+ "Framework :: Pytest",
45
+ "Typing :: Typed",
46
+ "Natural Language :: English"
47
+ ]
48
+
49
+ requires-python = ">=3.11, <3.15"
50
+
51
+ dependencies = [
52
+ "pydantic>=2,<3",
53
+ "pydantic-settings>=2,<3",
54
+ "rich>=14,<15",
55
+ ]
56
+
57
+ [dependency-groups]
58
+ dev = [
59
+ "commitizen>=4.1.0,<5",
60
+ "cyclonedx-py>=1.0.1",
61
+ "detect-secrets>=1.5.0",
62
+ "enum-tools>=0.13.0",
63
+ "nox>=2025.11.12",
64
+ "pip-audit>=2.10.0,<3",
65
+ "pip-licenses @ git+https://github.com/neXenio/pip-licenses.git@master", # https://github.com/raimon49/pip-licenses/pull/224
66
+ "pre-commit>=4.5.0,<5",
67
+ "pyright>=1.1.408,<2", # Regression in 1.1.407, see https://github.com/microsoft/pyright/issues/11060
68
+ "pytest>=9.0.2,<10",
69
+ "pytest-asyncio>=1.3.0,<2",
70
+ "pytest-cov>=7.0.0,<8",
71
+ "pytest-env>=1.1.5,<2",
72
+ "pytest-md-report>=0.7.0,<1",
73
+ "pytest-regressions>=2.7.0,<3",
74
+ "pytest-retry>=1.7.0,<2",
75
+ "pytest-subprocess>=1.5.3,<2",
76
+ "pytest-timeout>=2.3.1,<3",
77
+ "pytest-watcher>=0.4.3,<1",
78
+ "pytest-xdist[psutil]>=3.6.1,<4",
79
+ "ruff>=0.14.8,<1",
80
+ "tomli>=2.1.0",
81
+ "types-pyyaml>=6.0.12.20250402",
82
+ "types-requests>=2.32.0.20250328",
83
+ "watchdog>=6.0.0",
84
+ ]
85
+
86
+ [tool.uv]
87
+ required-version = ">=0.9.7" # CVE-2025-54368, GHSA-w476-p2h3-79g9, GHSA-pqhf-p39g-3x64
88
+ override-dependencies = [ # https://github.com/astral-sh/uv/issues/4422
89
+ "pytest>=9.0.2", # pytest-md-report depends on pytest<9 unnecessarily
90
+ ]
91
+
92
+
93
+ [project.urls]
94
+ Homepage = "https://github.com/aignostics/foundry-python-core"
95
+ Documentation = "https://github.com/aignostics/foundry-python-core#readme"
96
+ Source = "https://github.com/aignostics/foundry-python-core"
97
+ Changelog = "https://github.com/aignostics/foundry-python-core/releases"
98
+ Issues = "https://github.com/aignostics/foundry-python-core/issues"
99
+
100
+
101
+ [build-system]
102
+ requires = ["hatchling==1.29.0"]
103
+ build-backend = "hatchling.build"
104
+
105
+ [tool.hatch.build]
106
+ include = ["src/*"]
107
+
108
+ [tool.hatch.build.targets.wheel]
109
+
110
+ packages = ["src/aignostics_foundry_core"]
111
+
112
+
113
+
114
+ [tool.ruff]
115
+ target-version = "py311"
116
+ preview = true
117
+ fix = true
118
+ line-length = 120
119
+
120
+ [tool.ruff.lint]
121
+ select = ["ALL"]
122
+
123
+ ignore = [
124
+ "ANN002", # missing type annotation for `*args` -> provides no value
125
+ "ANN003", # missing type annotation for `**kwargs`` -> provides no value
126
+ "ASYNC109", # async function definition with a `timeout` parameter -> as mentioned by ruff, "This rule is highly opinionated and may not be suitable for all use cases."
127
+ "CPY001", # missing copyright notice -> not for OSS
128
+ "DOC502", # docstrings with exceptions not raised in the code of the function -> not always necessary
129
+ "D203", # incomptatible with D211 -> prefer D211
130
+ "D212", # incompatible with D213 -> prefer D213
131
+ "FBT001", # boolean positional arguments -> disagree
132
+ "FBT002", # boolean defautl value positionl arguments -> disagree
133
+ "FBT003", # boolean positional value in function call -> disagree
134
+ "PGH003", # use specific rule codes when ignoring type issues -> quite a hassle, no value
135
+ "TRY300", # else instead of return before except. -> strongly disagree, hinders readabilty.
136
+ "COM812", # conflicts with ruff formatter -> not feasible nor recommended
137
+ "ISC001", # conflicts with ruff formatter -> not feasible nor recommended
138
+ "S404", # subprocess` module is possibly insecure -> as mentioned by ruff, unstable and preview
139
+ "FIX002", # line contains todo -> yes, that's what todo's are for?!
140
+ "TD003", # missing issue link for todo -> not in OSS
141
+ "PTH123", # use of open to be replaced with Path.open
142
+ "T201", # Remove `print`
143
+ "INP001", # Checks for packages that are missing an __init__.py file.
144
+ "RUF067", # __init__ module contains conditional imports -> needed for optional dependencies (preview rule)
145
+ ]
146
+
147
+ [tool.ruff.lint.per-file-ignores]
148
+ "**/tests/**/*.py" = [
149
+ # we are more relaxed in tests, while sill applying hundreds of rules
150
+ "S101", # asserts allowed in tests...
151
+ "ARG", # unused function args -> fixtures nevertheless are functionally relevant...
152
+ "FBT", # don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize()
153
+ "PLR2004", # magic value used in comparison, ...
154
+ "PLR6301", # method could be a function, class method, or static method -> test organization pattern
155
+ "PT011", # exception to broad
156
+ "PLC2701", # private import, but required for unit testing
157
+ "PLC0415", # local import
158
+ "PT012", # exception to broad
159
+ "S311", # standard pseudo-random generators are not suitable for cryptographic purposes
160
+ "SLF001", # private member access required for unit testing
161
+ "S603", # check for execution of untrusted input
162
+ "ANN001", # missing type annotation for function argument
163
+ "ANN002", # missing type annotation
164
+ "ANN003", # missing type annotation
165
+ "ANN202", # missing return type annotation
166
+ "DOC201", # `return` is not documented in docstring
167
+ "ASYNC230", # async functions should not open files with blocking methods like `open`
168
+ "S104", # bind to all ports
169
+ "S607", # subprocess with partial path
170
+ ]
171
+
172
+ [tool.ruff.format]
173
+ docstring-code-format = true
174
+
175
+ [tool.ruff.lint.pydocstyle]
176
+ convention = "google"
177
+
178
+
179
+ [tool.pytest.ini_options]
180
+ addopts = "-v --strict-markers --cov=aignostics_foundry_core --cov-report=term-missing --cov-report=xml:reports/coverage.xml --cov-report=html:reports/coverage_html"
181
+ testpaths = ["tests"]
182
+ python_files = ["*_test.py", "test_*.py"]
183
+ asyncio_mode = "auto"
184
+ asyncio_default_fixture_loop_scope = "function"
185
+ timeout = 10 # We use a rather short default timeout. Override with @pytest.mark.timeout(timeout=N)
186
+ env = ["COVERAGE_FILE=.coverage", "COVERAGE_PROCESS_START=pyproject.toml"]
187
+ markers = [
188
+ # Test Categories (following Martin Fowler's Solitary vs Sociable unit test distinction)
189
+ "scheduled: Tests to run on a schedule. They will still be part on non-scheduled test executions.",
190
+ "sequential: Exclude from parallel test execution.",
191
+ "unit: Solitary unit tests - test a layer of a module in isolation with all dependencies mocked. Unit tests must be able to pass offline, i.e. not calls to external services. The timeout should not be bigger than the default 10s, and must be <5 min.",
192
+ "integration: Sociable integration tests - test interactions across architectural layers, using real file I/O and real subprocesses. Integration test must be able to pass offline, i.e. mock external services. The timeout should not be bigger than the default 10s, and must be <5 min.",
193
+ "e2e: End-to-end tests - test complete workflows with real external network services.",
194
+ ]
195
+ md_report = true
196
+ md_report_output = "reports/pytest.md"
197
+ md_report_verbose = 1
198
+ md_report_flavor = "github"
199
+ md_report_color = "never"
200
+ md_report_exclude_outcomes = ["passed", "skipped"]
201
+
202
+ [tool.coverage.run]
203
+ sigterm = true
204
+ relative_files = true
205
+ source = ["src"]
206
+ omit = []
207
+ branch = true
208
+ parallel = true
209
+ concurrency = ["thread", "multiprocessing"]
210
+
211
+ [tool.coverage.paths]
212
+ source = ["src/"]
213
+
214
+
215
+ [tool.commitizen]
216
+ name = "cz_conventional_commits"
217
+ use_shortcuts = true
218
+ version_provider = "scm"
219
+ change_type_map = { "feat" = "Feat", "fix" = "Fix", "refactor" = "Refactor", "perf" = "Perf", "chore" = "Chores", "docs" = "Documentation" }
220
+ version_files = [
221
+ "pyproject.toml:version",
222
+ "VERSION",
223
+ "sonar-project.properties:sonar.projectVersion",
224
+ ]
225
+ update_changelog_on_bump = true
226
+ major_version_zero = true # Allows breaking changes in 0.x.x versions; remove this line using a breaking-change commit to bump to 1.0.0
227
+ version_scheme = "semver2"
228
+ tag_format = "v$version"
229
+ tag = true
230
+ bump_message = "bump: version $current_version → $new_version [skip-ci]"
231
+ breaking_change_exclamation_in_title = true
232
+
233
+ # Pre-release configuration
234
+ prerelease = "alpha" # Default prerelease type
235
+ prerelease_offset = 0
236
+
237
+ changelog_merge_prerelease = true
238
+ style = [
239
+ [
240
+ "qmark",
241
+ "fg:#ff9d00 bold",
242
+ ],
243
+ [
244
+ "question",
245
+ "bold",
246
+ ],
247
+ [
248
+ "answer",
249
+ "fg:#ff9d00 bold",
250
+ ],
251
+ [
252
+ "pointer",
253
+ "fg:#ff9d00 bold",
254
+ ],
255
+ [
256
+ "highlighted",
257
+ "fg:#ff9d00 bold",
258
+ ],
259
+ [
260
+ "selected",
261
+ "fg:#cc5454",
262
+ ],
263
+ [
264
+ "separator",
265
+ "fg:#cc5454",
266
+ ],
267
+ [
268
+ "instruction",
269
+ "",
270
+ ],
271
+ [
272
+ "text",
273
+ "",
274
+ ],
275
+ [
276
+ "disabled",
277
+ "fg:#858585 italic",
278
+ ],
279
+ ]
280
+
281
+ # Changelog template configuration
282
+ template = ".cz-templates/CHANGELOG.md.j2"
@@ -0,0 +1,146 @@
1
+ # CLAUDE.md - Foundry Python Core Package Overview
2
+
3
+ This file provides an overview of all modules in `aignostics_foundry_core`, their features, and interactions.
4
+
5
+ ## Module Index
6
+
7
+ <!-- Document your modules in a table format. Customize columns based on your architecture. -->
8
+
9
+ | Module | Purpose | Description |
10
+ |--------|---------|-------------|
11
+ | **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory |
12
+ | **di** | Dependency injection | `locate_subclasses`, `locate_implementations`, `load_modules`, `discover_plugin_packages`, `clear_caches`, `PLUGIN_ENTRY_POINT_GROUP` for plugin and subclass discovery |
13
+ | **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status |
14
+ | **settings** | Pydantic settings loading | `OpaqueSettings`, `load_settings`, `strip_to_none_before_validator`, `UNHIDE_SENSITIVE_INFO` for env-based settings with secret masking and user-friendly validation errors |
15
+
16
+ ## Module Descriptions
17
+
18
+ <!-- For each module, document its purpose, features, dependencies, and usage. -->
19
+
20
+ ### console
21
+
22
+ **Themed Rich console for structured terminal output**
23
+
24
+ - **Purpose**: Provides a module-level `console` object pre-configured with a colour theme for consistent, styled terminal output across all Foundry components
25
+ - **Key Features**:
26
+ - `console` — module-level `Console` singleton, ready to use
27
+ - Colour theme: `success` (green), `info` / `logging.level.info` (purple4), `warning` (yellow1), `error` (red1), `debug` (light_cyan3)
28
+ - `AIGNOSTICS_CONSOLE_WIDTH` env var — overrides console width (defaults to Rich's auto-detect, 80 in non-TTY environments)
29
+ - `legacy_windows=False` — modern Windows terminal support
30
+ - **Location**: `aignostics_foundry_core/console.py`
31
+ - **Dependencies**: `rich>=13`
32
+
33
+ ### settings
34
+
35
+ **Pydantic settings loading with secret masking and user-friendly validation errors**
36
+
37
+ - **Purpose**: Provides reusable infrastructure for loading `pydantic-settings` classes from the environment, with secret masking and Rich-formatted validation error output
38
+ - **Key Features**:
39
+ - `UNHIDE_SENSITIVE_INFO: str` — context key constant to reveal secrets in `model_dump()`
40
+ - `strip_to_none_before_validator(v)` — before-validator that strips whitespace and converts empty strings to `None`
41
+ - `OpaqueSettings(BaseSettings)` — base class with `serialize_sensitive_info` (masks `SecretStr` fields) and `serialize_path_resolve` (resolves `Path` fields to absolute strings)
42
+ - `load_settings(settings_class)` — instantiates settings; on `ValidationError` prints a Rich `Panel` listing each invalid field and calls `sys.exit(78)`
43
+ - **Location**: `aignostics_foundry_core/settings.py`
44
+ - **Dependencies**: `pydantic>=2`, `pydantic-settings>=2`, `rich>=14`
45
+
46
+ ### di
47
+
48
+ **Plugin and subclass discovery for dependency injection**
49
+
50
+ - **Purpose**: Provides reusable infrastructure for dynamically discovering plugin packages, class implementations, and subclasses across a project and its registered plugins
51
+ - **Key Features**:
52
+ - `PLUGIN_ENTRY_POINT_GROUP: str` — `"aignostics.plugins"` entry-point group constant
53
+ - `discover_plugin_packages()` — discovers plugin packages registered via `[project.entry-points."aignostics.plugins"]`; LRU-cached
54
+ - `load_modules(project_name)` — imports all top-level submodules of the given package
55
+ - `locate_implementations(_class, project_name)` — finds all instances of `_class` via shallow plugin scan + deep project scan; cached per `(_class, project_name)` to prevent cross-project pollution
56
+ - `locate_subclasses(_class, project_name)` — finds all subclasses of `_class` via shallow plugin scan + deep project scan; cached per `(_class, project_name)`
57
+ - `clear_caches()` — resets all module-level caches (`_implementation_cache`, `_subclass_cache`, `discover_plugin_packages` LRU cache)
58
+ - Two internal scan helpers: `_scan_packages_shallow` (plugin top-level exports only) and `_scan_packages_deep` (full submodule walk for the main project)
59
+ - **Location**: `aignostics_foundry_core/di.py`
60
+ - **Dependencies**: Python stdlib only (`importlib`, `pkgutil`, `importlib.metadata`)
61
+
62
+ ### health
63
+
64
+ **Tree-structured health status for service health checks**
65
+
66
+ - **Purpose**: Provides `Health` and `HealthStatus` for modelling UP / DEGRADED / DOWN status across a tree of service components
67
+ - **Key Features**:
68
+ - `HealthStatus(StrEnum)` — `UP`, `DEGRADED`, `DOWN` values
69
+ - `Health(BaseModel)` — pydantic model with `status`, `reason`, `components`, `uptime_statistics`
70
+ - `compute_health_from_components()` — recursively propagates DOWN/DEGRADED from children to parent (DOWN trumps DEGRADED)
71
+ - `validate_health_state()` — model validator: DOWN/DEGRADED require a reason; UP must not have one
72
+ - `__str__` — returns `"UP"`, `"DEGRADED: <reason>"`, or `"DOWN: <reason>"`
73
+ - `__bool__` — `True` iff status is `UP`
74
+ - `Health.Code` — `ClassVar` alias for `HealthStatus` (convenience)
75
+ - **Location**: `aignostics_foundry_core/health.py`
76
+ - **Dependencies**: `pydantic>=2`
77
+
78
+ ## Architecture
79
+
80
+ <!-- Document your package's architecture here. Consider including:
81
+ - Module dependency diagrams
82
+ - Data flow patterns
83
+ - Key abstractions and interfaces
84
+ - Integration points
85
+ -->
86
+
87
+ ```text
88
+ ┌─────────────────────────────┐
89
+ │ Your Application │
90
+ └──────────────┬──────────────┘
91
+
92
+ ┌──────────────┴──────────────┐
93
+ │ aignostics_foundry_core │
94
+ ├─────────────────────────────┤
95
+ │ console │
96
+ │ health │
97
+ └─────────────────────────────┘
98
+ ```
99
+
100
+ ## Usage Examples
101
+
102
+ ```python
103
+ from aignostics_foundry_core import console
104
+
105
+ # Print with theme styles
106
+ console.print("[success]Done![/success]")
107
+ console.print("[warning]Caution: retrying...[/warning]")
108
+ console.print("[error]Failed to connect.[/error]")
109
+ ```
110
+
111
+ ```python
112
+ from aignostics_foundry_core.health import Health, HealthStatus
113
+
114
+ # Simple UP status
115
+ health = Health(status=HealthStatus.UP)
116
+ assert bool(health) # True
117
+ assert str(health) == "UP"
118
+
119
+ # Composite health — DOWN propagates from components automatically
120
+ system = Health(
121
+ status=HealthStatus.UP,
122
+ components={
123
+ "db": Health(status=HealthStatus.UP),
124
+ "cache": Health(status=HealthStatus.DOWN, reason="Connection refused"),
125
+ },
126
+ )
127
+ assert system.status == HealthStatus.DOWN
128
+ assert "cache" in system.reason
129
+ ```
130
+
131
+ ## Development Guidelines
132
+
133
+ ### Adding New Modules
134
+
135
+ 1. Create module in `src/aignostics_foundry_core/`
136
+ 2. Export public API in `__init__.py`
137
+ 3. Add tests in `tests/aignostics_foundry_core/`
138
+ 4. Document in this file (add to Module Index and Module Descriptions)
139
+
140
+ ### Module Documentation
141
+
142
+ Consider creating `CLAUDE.md` files in module subdirectories for detailed documentation of complex modules.
143
+
144
+ ---
145
+
146
+ *Keep this documentation updated as the package evolves.*
@@ -0,0 +1,4 @@
1
+ """Foundational infrastructure for Foundry components.
2
+
3
+ We do NOT export any of the public APIs of the Foundry components here; see docs/decisions/0002-module-structure.md.
4
+ """
@@ -0,0 +1,31 @@
1
+ """Themed rich console."""
2
+
3
+ import os
4
+
5
+ from rich.console import Console
6
+ from rich.theme import Theme
7
+
8
+
9
+ def _get_console() -> Console:
10
+ """Get a themed rich console.
11
+
12
+ The console width can be set via the AIGNOSTICS_CONSOLE_WIDTH environment variable.
13
+
14
+ Returns:
15
+ Console: The themed rich console.
16
+ """
17
+ return Console(
18
+ theme=Theme({
19
+ "logging.level.info": "purple4",
20
+ "debug": "light_cyan3",
21
+ "success": "green",
22
+ "info": "purple4",
23
+ "warning": "yellow1",
24
+ "error": "red1",
25
+ }),
26
+ width=int(os.environ.get("AIGNOSTICS_CONSOLE_WIDTH", "0")) or None,
27
+ legacy_windows=False, # Modern Windows (10+) doesn't need width adjustment
28
+ )
29
+
30
+
31
+ console = _get_console()
@@ -0,0 +1,200 @@
1
+ """Dependency injection using dynamic import and discovery of implementations and subclasses."""
2
+
3
+ import importlib
4
+ import pkgutil
5
+ from collections.abc import Callable
6
+ from functools import lru_cache
7
+ from importlib.metadata import entry_points
8
+ from inspect import isclass
9
+ from typing import Any
10
+
11
+ _implementation_cache: dict[tuple[Any, str], list[Any]] = {}
12
+ _subclass_cache: dict[tuple[Any, str], list[Any]] = {}
13
+
14
+ PLUGIN_ENTRY_POINT_GROUP = "aignostics.plugins"
15
+
16
+
17
+ @lru_cache(maxsize=1)
18
+ def discover_plugin_packages() -> tuple[str, ...]:
19
+ """Discover plugin packages using entry points.
20
+
21
+ Plugins register themselves in their pyproject.toml:
22
+
23
+ [project.entry-points."aignostics.plugins"]
24
+ my_plugin = "my_plugin"
25
+
26
+ Results are cached after the first call.
27
+
28
+ Returns:
29
+ Tuple of discovered plugin package names.
30
+ """
31
+ eps = entry_points(group=PLUGIN_ENTRY_POINT_GROUP)
32
+ return tuple(ep.value for ep in eps)
33
+
34
+
35
+ def load_modules(project_name: str) -> None:
36
+ """Import all top-level submodules of the given project package.
37
+
38
+ Args:
39
+ project_name: The importable package name to scan (e.g. ``"bridge"``).
40
+ """
41
+ package = importlib.import_module(project_name)
42
+ for _, name, _ in pkgutil.iter_modules(package.__path__):
43
+ importlib.import_module(f"{project_name}.{name}")
44
+
45
+
46
+ def _scan_packages_deep(
47
+ package_name: str,
48
+ predicate: Callable[[Any], bool],
49
+ ) -> list[Any]:
50
+ """Deep-scan a single package by walking all submodules via pkgutil.iter_modules.
51
+
52
+ Used for the main project package. Imports each submodule discovered via
53
+ ``pkgutil.iter_modules`` and examines every name in ``dir(module)`` against
54
+ *predicate*. Silently skips submodules that raise ``ImportError``.
55
+
56
+ Args:
57
+ package_name: The package to scan (e.g. ``"bridge"``).
58
+ predicate: Called with each member; members where this returns ``True``
59
+ are included in the result.
60
+
61
+ Returns:
62
+ All members from submodules of *package_name* that satisfy *predicate*.
63
+ """
64
+ results: list[Any] = []
65
+ try:
66
+ package = importlib.import_module(package_name)
67
+ except ImportError:
68
+ return results
69
+ for _, name, _ in pkgutil.iter_modules(package.__path__):
70
+ try:
71
+ module = importlib.import_module(f"{package_name}.{name}")
72
+ for member_name in dir(module):
73
+ member = getattr(module, member_name)
74
+ if predicate(member):
75
+ results.append(member)
76
+ except ImportError:
77
+ continue
78
+ return results
79
+
80
+
81
+ def _scan_packages_shallow(
82
+ package_names: tuple[str, ...],
83
+ predicate: Callable[[Any], bool],
84
+ ) -> list[Any]:
85
+ """Shallow-scan the top-level exports of each plugin package.
86
+
87
+ For each plugin package, imports only the top-level package and examines
88
+ ``dir(package)`` for matches. Does **not** walk submodules via
89
+ ``pkgutil.iter_modules``.
90
+
91
+ This prevents nested objects from plugin submodules (e.g.
92
+ ``stargate.demeter.cli``) from being discovered alongside the intended
93
+ top-level export (``stargate.cli``). Only what the plugin's ``__init__.py``
94
+ explicitly exports is considered.
95
+
96
+ Silently skips packages that raise ``ImportError``.
97
+
98
+ Args:
99
+ package_names: Plugin package names to scan.
100
+ predicate: Called with each member; members where this returns ``True``
101
+ are included in the result.
102
+
103
+ Returns:
104
+ All members from the top-level namespace of each plugin that satisfy
105
+ *predicate*.
106
+ """
107
+ results: list[Any] = []
108
+ for package_name in package_names:
109
+ try:
110
+ package = importlib.import_module(package_name)
111
+ except ImportError:
112
+ continue
113
+ for member_name in dir(package):
114
+ member = getattr(package, member_name)
115
+ if predicate(member):
116
+ results.append(member)
117
+ return results
118
+
119
+
120
+ def locate_implementations(_class: type[Any], project_name: str) -> list[Any]:
121
+ """Dynamically discover all instances of some class.
122
+
123
+ Searches plugin top-level exports first (shallow scan), then deep-scans all
124
+ submodules of the main project package. Plugins are registered via entry
125
+ points; only their top-level ``__init__.py`` exports are examined (submodules
126
+ are not walked). The main package retains full deep-scan behaviour.
127
+
128
+ Cache keys include *project_name* to avoid cross-project cache pollution when
129
+ multiple projects share this library.
130
+
131
+ Args:
132
+ _class: Class to search for.
133
+ project_name: Importable package name of the calling project
134
+ (e.g. ``"bridge"``). Used as the deep-scan root and as part of the
135
+ cache key.
136
+
137
+ Returns:
138
+ List of discovered instances of the given class.
139
+ """
140
+ cache_key = (_class, project_name)
141
+ if cache_key in _implementation_cache:
142
+ return _implementation_cache[cache_key]
143
+
144
+ def predicate(member: object) -> bool:
145
+ return isinstance(member, _class)
146
+
147
+ results = [
148
+ *_scan_packages_shallow(discover_plugin_packages(), predicate),
149
+ *_scan_packages_deep(project_name, predicate),
150
+ ]
151
+ _implementation_cache[cache_key] = results
152
+ return results
153
+
154
+
155
+ def locate_subclasses(_class: type[Any], project_name: str) -> list[Any]:
156
+ """Dynamically discover all classes that are subclasses of some type.
157
+
158
+ Searches plugin top-level exports first (shallow scan), then deep-scans all
159
+ submodules of the main project package. Plugins are registered via entry
160
+ points; only their top-level ``__init__.py`` exports are examined (submodules
161
+ are not walked). The main package retains full deep-scan behaviour.
162
+
163
+ Cache keys include *project_name* to avoid cross-project cache pollution when
164
+ multiple projects share this library.
165
+
166
+ Args:
167
+ _class: Parent class of subclasses to search for.
168
+ project_name: Importable package name of the calling project
169
+ (e.g. ``"bridge"``). Used as the deep-scan root and as part of the
170
+ cache key.
171
+
172
+ Returns:
173
+ List of discovered subclasses of the given class.
174
+ """
175
+ cache_key = (_class, project_name)
176
+ if cache_key in _subclass_cache:
177
+ return _subclass_cache[cache_key]
178
+
179
+ def predicate(member: object) -> bool:
180
+ return isclass(member) and issubclass(member, _class) and member != _class
181
+
182
+ results = [
183
+ *_scan_packages_shallow(discover_plugin_packages(), predicate),
184
+ *_scan_packages_deep(project_name, predicate),
185
+ ]
186
+ _subclass_cache[cache_key] = results
187
+ return results
188
+
189
+
190
+ def clear_caches() -> None:
191
+ """Reset all module-level discovery caches.
192
+
193
+ Clears ``_implementation_cache``, ``_subclass_cache``, and the
194
+ ``discover_plugin_packages`` LRU cache so that subsequent calls to
195
+ ``locate_implementations``, ``locate_subclasses``, and
196
+ ``discover_plugin_packages`` perform fresh discovery.
197
+ """
198
+ _implementation_cache.clear()
199
+ _subclass_cache.clear()
200
+ discover_plugin_packages.cache_clear()
@@ -0,0 +1,137 @@
1
+ """Health models and status definitions for service health checks."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Any, ClassVar, Self
5
+
6
+ from pydantic import BaseModel, Field, model_validator
7
+
8
+
9
+ class HealthStatus(StrEnum):
10
+ """Health status enumeration for service health checks.
11
+
12
+ Values:
13
+ UP: Service is operating normally
14
+ DEGRADED: Service is operational but with reduced functionality
15
+ DOWN: Service is not operational
16
+ """
17
+
18
+ UP = "UP"
19
+ DEGRADED = "DEGRADED"
20
+ DOWN = "DOWN"
21
+
22
+
23
+ class Health(BaseModel):
24
+ """Represents the health status of a service with optional components and failure reasons.
25
+
26
+ - A health object can have child components, i.e. health forms a tree.
27
+ - Any node in the tree can set itself to DOWN or DEGRADED. If DOWN, the node is required
28
+ to set the reason attribute. If reason is not set when DOWN, automatic model validation fails.
29
+ - DOWN trumps DEGRADED, DEGRADED trumps UP. If any child is DOWN, parent is DOWN.
30
+ If none are DOWN but any are DEGRADED, parent is DEGRADED.
31
+ - The root of the health tree is computed in the system module.
32
+ The health of other modules is automatically picked up by the system module.
33
+ """
34
+
35
+ Code: ClassVar[type[HealthStatus]] = HealthStatus
36
+ status: HealthStatus
37
+ reason: str | None = None
38
+ components: dict[str, "Health"] = Field(default_factory=dict)
39
+ uptime_statistics: dict[str, dict[str, Any]] | None = None # Optional uptime stats
40
+
41
+ def compute_health_from_components(self) -> Self:
42
+ """Recursively compute health status from components.
43
+
44
+ - If health is already DOWN, it remains DOWN with its original reason.
45
+ - If health is UP but any component is DOWN, health becomes DOWN with
46
+ a reason listing all failed components.
47
+ - If no components are DOWN but any are DEGRADED, health becomes DEGRADED with a reason.
48
+
49
+ Returns:
50
+ Self: The updated health instance with computed status.
51
+ """
52
+ # Skip recomputation if already known to be DOWN
53
+ if self.status == HealthStatus.DOWN:
54
+ return self
55
+
56
+ # No components means we keep the existing status
57
+ if not self.components:
58
+ return self
59
+
60
+ # Find all DOWN and DEGRADED components
61
+ down_components: list[tuple[str, str | None]] = []
62
+ degraded_components: list[tuple[str, str | None]] = []
63
+ for component_name, component in self.components.items():
64
+ # Recursively compute health for each component
65
+ component.compute_health_from_components()
66
+ if component.status == HealthStatus.DOWN:
67
+ down_components.append((component_name, component.reason))
68
+ elif component.status == HealthStatus.DEGRADED:
69
+ degraded_components.append((component_name, component.reason))
70
+
71
+ # If any components are DOWN, mark the parent as DOWN
72
+ if down_components:
73
+ self.status = HealthStatus.DOWN
74
+ if len(down_components) == 1:
75
+ component_name, component_reason = down_components[0]
76
+ self.reason = f"Component '{component_name}' is DOWN ({component_reason})"
77
+ else:
78
+ component_list = ", ".join(f"'{name}' ({reason})" for name, reason in down_components)
79
+ self.reason = f"Components {component_list} are DOWN"
80
+ # If no components are DOWN but any are DEGRADED, mark parent as DEGRADED
81
+ elif degraded_components:
82
+ self.status = HealthStatus.DEGRADED
83
+ if len(degraded_components) == 1:
84
+ component_name, component_reason = degraded_components[0]
85
+ self.reason = f"Component '{component_name}' is DEGRADED ({component_reason})"
86
+ else:
87
+ component_list = ", ".join(f"'{name}' ({reason})" for name, reason in degraded_components)
88
+ self.reason = f"Components {component_list} are DEGRADED"
89
+
90
+ return self
91
+
92
+ @model_validator(mode="after")
93
+ def validate_health_state(self) -> Self:
94
+ """Validate the health state and ensure consistency.
95
+
96
+ - Compute overall health based on component health
97
+ - Ensure UP status has no associated reason
98
+ - Ensure DOWN and DEGRADED status always have a reason
99
+
100
+ Returns:
101
+ Self: The validated model instance with correct health status.
102
+
103
+ Raises:
104
+ ValueError: If validation fails due to inconsistency.
105
+ """
106
+ # First compute health from components
107
+ self.compute_health_from_components()
108
+
109
+ # Validate that UP status has no reason
110
+ if (self.status == HealthStatus.UP) and self.reason:
111
+ msg = f"Health {self.status} must not have reason"
112
+ raise ValueError(msg)
113
+
114
+ # Validate that DOWN and DEGRADED status always have a reason
115
+ if (self.status in {HealthStatus.DOWN, HealthStatus.DEGRADED}) and not self.reason:
116
+ msg = f"Health {self.status} must have a reason"
117
+ raise ValueError(msg)
118
+
119
+ return self
120
+
121
+ def __str__(self) -> str:
122
+ """Return string representation of health status with optional reason for DOWN/DEGRADED state.
123
+
124
+ Returns:
125
+ str: The health status value, with reason appended if status is DOWN or DEGRADED.
126
+ """
127
+ if self.status in {HealthStatus.DOWN, HealthStatus.DEGRADED} and self.reason:
128
+ return f"{self.status.value}: {self.reason}"
129
+ return self.status.value
130
+
131
+ def __bool__(self) -> bool:
132
+ """Convert health status to a boolean value.
133
+
134
+ Returns:
135
+ bool: True if the status is UP, False otherwise.
136
+ """
137
+ return self.status == HealthStatus.UP
@@ -0,0 +1,122 @@
1
+ """Utilities around Pydantic settings."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import TypeVar
6
+
7
+ from pydantic import FieldSerializationInfo, SecretStr, ValidationError
8
+ from pydantic_settings import BaseSettings
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+ from aignostics_foundry_core.console import console
13
+
14
+ _T = TypeVar("_T", bound=BaseSettings)
15
+
16
+ UNHIDE_SENSITIVE_INFO = "unhide_sensitive_info"
17
+
18
+
19
+ def strip_to_none_before_validator(v: str | None) -> str | None:
20
+ """Strip whitespace and return None for empty strings.
21
+
22
+ Args:
23
+ v: The string to process, or None.
24
+
25
+ Returns:
26
+ None if the input is None or whitespace-only, otherwise the stripped string.
27
+ """
28
+ if v is None:
29
+ return None
30
+ v = v.strip()
31
+ if not v:
32
+ return None
33
+ return v
34
+
35
+
36
+ class OpaqueSettings(BaseSettings):
37
+ """Base settings class with secret masking and path resolution serializers."""
38
+
39
+ @staticmethod
40
+ def serialize_sensitive_info(input_value: SecretStr | None, info: FieldSerializationInfo) -> str | None:
41
+ """Serialize a SecretStr, masking it unless context requests unhiding.
42
+
43
+ Args:
44
+ input_value: The secret value to serialize.
45
+ info: Pydantic serialization info, may carry context.
46
+
47
+ Returns:
48
+ None for empty secrets, the secret value if unhide is requested,
49
+ otherwise the masked representation.
50
+ """
51
+ if not input_value:
52
+ return None
53
+ if info.context and info.context.get(UNHIDE_SENSITIVE_INFO, False):
54
+ return input_value.get_secret_value()
55
+ return str(input_value)
56
+
57
+ @staticmethod
58
+ def serialize_path_resolve(input_value: Path | None, _info: FieldSerializationInfo) -> str | None:
59
+ """Serialize a Path by resolving it to an absolute string.
60
+
61
+ Args:
62
+ input_value: The path to resolve.
63
+ _info: Pydantic serialization info (unused).
64
+
65
+ Returns:
66
+ None if input is `None` or has no path components (e.g. empty string),
67
+ otherwise the resolved absolute path string.
68
+ """
69
+ if input_value is None or not input_value.parts:
70
+ return None
71
+ return str(input_value.resolve())
72
+
73
+
74
+ def load_settings(settings_class: type[_T]) -> _T:
75
+ """Load settings with error handling and nice formatting.
76
+
77
+ Args:
78
+ settings_class: The Pydantic settings class to instantiate.
79
+
80
+ Returns:
81
+ Instance of the settings class.
82
+
83
+ Raises:
84
+ SystemExit: If settings validation fails (exit code 78).
85
+ """
86
+ try:
87
+ return settings_class()
88
+ except ValidationError as e:
89
+ errors = e.errors()
90
+ text = Text()
91
+ text.append(
92
+ "Validation error(s): \n\n",
93
+ style="debug",
94
+ )
95
+
96
+ prefix = settings_class.model_config.get("env_prefix", "")
97
+ for error in errors:
98
+ if error["loc"] and isinstance(error["loc"][0], str):
99
+ env_var = f"{prefix}{error['loc'][0]}".upper()
100
+ else:
101
+ env_var = prefix.rstrip("_").upper()
102
+ text.append(f"• {env_var}", style="yellow bold")
103
+ text.append(f": {error['msg']}\n")
104
+
105
+ text.append(
106
+ "\nCheck settings defined in the process environment and in file ",
107
+ style="info",
108
+ )
109
+ env_file = str(settings_class.model_config.get("env_file", ".env") or ".env")
110
+ text.append(
111
+ str(Path.cwd() / env_file),
112
+ style="bold blue underline",
113
+ )
114
+
115
+ console.print(
116
+ Panel(
117
+ text,
118
+ title="Configuration invalid!",
119
+ border_style="error",
120
+ ),
121
+ )
122
+ sys.exit(78)