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.
- aignostics_foundry_core-0.0.0/.gitignore +90 -0
- aignostics_foundry_core-0.0.0/LICENSE +8 -0
- aignostics_foundry_core-0.0.0/PKG-INFO +88 -0
- aignostics_foundry_core-0.0.0/README.md +48 -0
- aignostics_foundry_core-0.0.0/pyproject.toml +282 -0
- aignostics_foundry_core-0.0.0/src/aignostics_foundry_core/AGENTS.md +146 -0
- aignostics_foundry_core-0.0.0/src/aignostics_foundry_core/CLAUDE.md +1 -0
- aignostics_foundry_core-0.0.0/src/aignostics_foundry_core/__init__.py +4 -0
- aignostics_foundry_core-0.0.0/src/aignostics_foundry_core/console.py +31 -0
- aignostics_foundry_core-0.0.0/src/aignostics_foundry_core/di.py +200 -0
- aignostics_foundry_core-0.0.0/src/aignostics_foundry_core/health.py +137 -0
- aignostics_foundry_core-0.0.0/src/aignostics_foundry_core/settings.py +122 -0
|
@@ -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,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
|
+
[](https://github.com/aignostics/foundry-python-core/blob/main/LICENSE)
|
|
44
|
+
[](https://github.com/aignostics/foundry-python-core/actions/workflows/ci-cd.yml)
|
|
45
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
46
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
47
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
48
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
49
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
50
|
+
[](https://github.com/aignostics/foundry-python-core/security/dependabot)
|
|
51
|
+
[](https://github.com/aignostics/foundry-python-core/issues?q=is%3Aissue%20state%3Aopen%20Dependency%20Dashboard)
|
|
52
|
+
[](https://codecov.io/gh/aignostics/foundry-python-core)
|
|
53
|
+
[](https://github.com/aignostics/foundry-python-core/blob/main/noxfile.py)
|
|
54
|
+
[](https://microsoft.github.io/pyright/)
|
|
55
|
+
[](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
|
+
[](https://github.com/aignostics/foundry-python-core/blob/main/LICENSE)
|
|
4
|
+
[](https://github.com/aignostics/foundry-python-core/actions/workflows/ci-cd.yml)
|
|
5
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
6
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
7
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
8
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
9
|
+
[](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core)
|
|
10
|
+
[](https://github.com/aignostics/foundry-python-core/security/dependabot)
|
|
11
|
+
[](https://github.com/aignostics/foundry-python-core/issues?q=is%3Aissue%20state%3Aopen%20Dependency%20Dashboard)
|
|
12
|
+
[](https://codecov.io/gh/aignostics/foundry-python-core)
|
|
13
|
+
[](https://github.com/aignostics/foundry-python-core/blob/main/noxfile.py)
|
|
14
|
+
[](https://microsoft.github.io/pyright/)
|
|
15
|
+
[](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 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -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)
|