menda-bootstrap 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+ push:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ ci:
12
+ uses: mendaml/menda-gh-actions/.github/workflows/python-ci.yml@main
@@ -0,0 +1,207 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: menda-bootstrap
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author-email: jpm2617 <jose.moreno@mendaml.com>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: keyring>=25.7.0
8
+ Requires-Dist: requests>=2.32.5
9
+ Description-Content-Type: text/markdown
10
+
11
+ # menda-bootstrap
12
+ A python package used to bootstrap menda projects
@@ -0,0 +1,2 @@
1
+ # menda-bootstrap
2
+ A python package used to bootstrap menda projects
@@ -0,0 +1,152 @@
1
+ [project]
2
+ name = "menda-bootstrap"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "jpm2617", email = "jose.moreno@mendaml.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "keyring>=25.7.0",
12
+ "requests>=2.32.5",
13
+ ]
14
+
15
+ [build-system]
16
+ build-backend = "hatchling.build"
17
+ requires = ["hatchling"]
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/menda"]
21
+
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "coverage>=7.13.5",
26
+ "poethepoet>=0.42.1",
27
+ "pytest>=9.0.2",
28
+ "ruff>=0.15.7",
29
+ "ty>=0.0.24",
30
+ ]
31
+
32
+ [project.entry-points."keyring.backends"]
33
+ menda-keyring = "menda.keyring.backend:MendaCodeArtifactKeyring"
34
+
35
+ # ── Coverage ─────────────────────────────────────────────────────────────
36
+ [tool.coverage.run]
37
+ source = ["src"]
38
+ data_file = ".coverage"
39
+ branch = true
40
+ parallel = true
41
+ relative_files = true
42
+ omit = [
43
+ "*/tests/*",
44
+ "*/site-packages/*",
45
+ "*.egg-info/*"
46
+ ]
47
+
48
+ # ── Ruff ─────────────────────────────────────────────────────────────
49
+ [tool.ruff]
50
+ fix = true
51
+ indent-width = 4
52
+ line-length = 120
53
+ target-version = "py312"
54
+
55
+ [tool.ruff.lint]
56
+ ignore = [
57
+ "TRY002", # Create your own exceptions
58
+ "TRY003", # Avoid specifying long messages outside the exception class
59
+ "TRY301", # Avoid raise exceptions from err
60
+ "TRY400", # Avoid logger suggestion of exception instead of error
61
+ "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
62
+ "E501", # LineTooLong
63
+ "E731", # DoNotAssignLambda
64
+ "S104", # Possible binding to all interfaces (HOST = 0.0.0.0)
65
+ "D100", # Missing docstring in public module
66
+ "D101", # Missing docstring in public class
67
+ "D104", # Missing docstring in public package
68
+ "D107", # Missing docstring in __init__
69
+ "D203", # 1 blank line required before class docstring
70
+ "D213", # Multi-line docstring summary should start at the second line
71
+ ]
72
+ select = [
73
+ "YTT", # flake8-2020
74
+ "S", # flake8-bandit
75
+ "B", # flake8-bugbear
76
+ "A", # flake8-builtins
77
+ "C4", # flake8-comprehensions
78
+ "T10", # flake8-debugger
79
+ "SIM", # flake8-simplify
80
+ "I", # isort
81
+ "C90", # mccabe
82
+ "E", # pycodestyle
83
+ "W", # pycodestyle
84
+ "F", # pyflakes
85
+ "PGH", # pygrep-hooks
86
+ "UP", # pyupgrade
87
+ "RUF", # ruff
88
+ "TRY", # tryceratops
89
+ "D", # flake8-docstrings
90
+ "ANN", # flake8-annotations — enforce type annotations
91
+ ]
92
+
93
+ [tool.ruff.lint.per-file-ignores]
94
+ "**/tests/*" = ["S101", "S108", "D102", "ANN"]
95
+
96
+ [tool.ruff.format]
97
+ preview = true
98
+ # Like Black, use double quotes for strings.
99
+ quote-style = "double"
100
+ # Like Black, indent with spaces, rather than tabs.
101
+ indent-style = "space"
102
+ # Like Black, respect magic trailing commas.
103
+ skip-magic-trailing-comma = false
104
+ # Like Black, automatically detect the appropriate line ending.
105
+ line-ending = "auto"
106
+ # Enable auto-formatting of code examples in docstrings. Markdown,
107
+ # reStructuredText code/literal blocks and doctests are all supported.
108
+ #
109
+ # This is currently disabled by default, but it is planned for this
110
+ # to be opt-out in the future.
111
+ docstring-code-format = true
112
+ # Set the line length limit used when formatting code snippets in
113
+ # docstrings.
114
+ #
115
+ # This only has an effect when the `docstring-code-format` setting is
116
+ # enabled.
117
+ docstring-code-line-length = "dynamic"
118
+
119
+ # ── ty (type checker) ────────────────────────────────────────────────
120
+ [tool.ty.environment]
121
+ python-version = "3.12"
122
+ python = ".venv"
123
+ root = ["./src"]
124
+
125
+ [tool.ty.src]
126
+ include = ["src", "tests"]
127
+
128
+ # ── tasks (Using poe) ────────────────────────────────────────────────
129
+ [tool.poe.tasks]
130
+ lint-typing = "ty check"
131
+ lint-style = "ruff check ."
132
+ lint-format = [
133
+ { cmd = "ruff format" },
134
+ { cmd = "ruff check --fix" },
135
+ ]
136
+ lint-all = ["lint-format", "lint-style", "lint-typing"]
137
+ test-cov = [
138
+ { cmd = "coverage run -m pytest tests -x -s --capture=tee-sys -v --junitxml=test_coverage.xml" },
139
+ { cmd = "coverage combine" },
140
+ { cmd = "coverage xml" },
141
+ { cmd = "coverage html" },
142
+ ]
143
+
144
+ [tool.poe.tasks.test]
145
+ shell = """
146
+ if [ -n "$PYTEST_MARKERS" ]; then
147
+ pytest tests -x -s --capture=tee-sys -v -m "$PYTEST_MARKERS"
148
+ else
149
+ pytest tests -x -s --capture=tee-sys -v
150
+ fi
151
+ """
152
+ interpreter = "bash"
@@ -0,0 +1,3 @@
1
+ from pkgutil import extend_path
2
+
3
+ __path__ = extend_path(__path__, __name__)
@@ -0,0 +1 @@
1
+ """Error types and handling for menda bootstrap package."""
@@ -0,0 +1,18 @@
1
+ class ErrorMixIn(Exception):
2
+ """MixIn for all exceptions in menda-bootstrap package."""
3
+
4
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
5
+ """Initialize the exception with a message and optional HTTP status code.
6
+
7
+ Args:
8
+ message: The error message to display.
9
+ status_code: Optional HTTP status code associated with the error.
10
+
11
+ """
12
+ self.status_code = status_code
13
+ message = f"{message} (status_code: {status_code})" if status_code else message
14
+ super().__init__(message)
15
+
16
+
17
+ class MendaKeyRingError(ErrorMixIn):
18
+ """Failed to obtain credentials from menda keyring backend."""
File without changes
@@ -0,0 +1,159 @@
1
+ """Lightweight authentication for menda-cloud APIs.
2
+
3
+ This module uses only `requests` — no dependency on menda-core.
4
+ Mirrors the resolution logic in menda.auth.task.resolve_auth_config().
5
+
6
+ Three auth paths:
7
+ 1. M2M via env vars: MENDA_CLIENT_ID + MENDA_CLIENT_SECRET + MENDA_HOST
8
+ 2. M2M via profile: mendacfg profile with auth_type=m2m, client_id, client_secret
9
+ 3. U2M via profile: mendacfg profile with auth_type=u2m, cached device-flow token
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import configparser
15
+ import os
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import requests
20
+
21
+ MENDA_CONFIG_FOLDER = Path.home() / ".menda"
22
+ TOKENS_CACHE_PATH = MENDA_CONFIG_FOLDER / "tokens" / "cache" / ".token"
23
+
24
+
25
+ def get_menda_access_token(
26
+ host: str,
27
+ client_id: str,
28
+ client_secret: str,
29
+ ) -> str:
30
+ """Exchange M2M client credentials for a menda access token."""
31
+ url = f"{host.rstrip('/')}/api/v1/auth/oauth2/token"
32
+ resp = requests.post(
33
+ url,
34
+ params={
35
+ "grant_type": "client_credentials",
36
+ "client_id": client_id,
37
+ "client_secret": client_secret,
38
+ },
39
+ timeout=30,
40
+ )
41
+ if not resp.ok:
42
+ raise RuntimeError(f"Failed to authenticate to menda-cloud (HTTP {resp.status_code}): {resp.text}")
43
+ return resp.json()["access_token"]
44
+
45
+
46
+ def _load_profile(profile_id: str) -> dict[str, str]:
47
+ """Load a profile section from ~/.menda/mendacfg."""
48
+ mendacfg_path = MENDA_CONFIG_FOLDER / "mendacfg"
49
+ if not mendacfg_path.exists():
50
+ raise FileNotFoundError(f"Menda config file not found at {mendacfg_path}. Run 'menda auth configure' first.")
51
+
52
+ config = configparser.ConfigParser()
53
+ config.read(mendacfg_path)
54
+
55
+ if profile_id not in config:
56
+ raise RuntimeError(f"Profile '{profile_id}' not found in {mendacfg_path}")
57
+
58
+ return dict(config[profile_id])
59
+
60
+
61
+ def _load_cached_token(cache_key: str) -> str | None:
62
+ """Load a non-expired cached token from the token cache file.
63
+
64
+ The cache file is a configparser file at ~/.menda/tokens/cache/.token.
65
+ Each section is keyed by profile_id (or client_id for env-var M2M).
66
+
67
+ Returns:
68
+ The access token string, or None if not cached or expired.
69
+
70
+ """
71
+ if not TOKENS_CACHE_PATH.exists():
72
+ return None
73
+
74
+ parser = configparser.ConfigParser()
75
+ parser.read(TOKENS_CACHE_PATH)
76
+
77
+ if cache_key not in parser:
78
+ return None
79
+
80
+ section = parser[cache_key]
81
+ cached_at = section.get("cached_at", "")
82
+ expires_in = section.get("expires_in", "")
83
+
84
+ if cached_at and expires_in:
85
+ try:
86
+ elapsed = time.time() - float(cached_at)
87
+ if elapsed >= (float(expires_in) - 60):
88
+ return None
89
+ except (ValueError, TypeError):
90
+ return None
91
+
92
+ token = section.get("id_token") or section.get("access_token")
93
+ return token if token else None
94
+
95
+
96
+ def exchange_for_registry_token(host: str, access_token: str) -> str:
97
+ """Exchange a menda access token for a CodeArtifact registry token."""
98
+ url = f"{host.rstrip('/')}/api/v1/auth/registry/token"
99
+ resp = requests.post(
100
+ url,
101
+ headers={
102
+ "authorizationToken": f"Bearer {access_token}",
103
+ "Content-Type": "application/json",
104
+ },
105
+ timeout=30,
106
+ )
107
+ if not resp.ok:
108
+ raise RuntimeError(f"Failed to get registry token (HTTP {resp.status_code}): {resp.text}")
109
+ return resp.json()["token"]
110
+
111
+
112
+ def resolve_and_get_registry_token() -> str:
113
+ """Resolve auth method and return a CodeArtifact token.
114
+
115
+ Resolution order:
116
+ 1. If MENDA_CLIENT_ID, MENDA_CLIENT_SECRET, MENDA_HOST env vars all set → M2M via env vars.
117
+ 2. Load profile from mendacfg using MENDA_PROFILE_ID (default: "default"):
118
+ - auth_type=m2m → M2M via profile (on-demand token exchange, with cache).
119
+ - auth_type=u2m → U2M via cached device-flow token.
120
+ """
121
+ client_id = os.environ.get("MENDA_CLIENT_ID")
122
+ client_secret = os.environ.get("MENDA_CLIENT_SECRET")
123
+ host = os.environ.get("MENDA_HOST")
124
+
125
+ # Path 1: M2M via env vars
126
+ if client_id and client_secret and host:
127
+ cached = _load_cached_token(client_id)
128
+ if cached:
129
+ return exchange_for_registry_token(host, cached)
130
+ access_token = get_menda_access_token(host, client_id, client_secret)
131
+ return exchange_for_registry_token(host, access_token)
132
+
133
+ # Load profile
134
+ profile_id = os.environ.get("MENDA_PROFILE_ID", "default")
135
+ profile = _load_profile(profile_id)
136
+
137
+ profile_host = profile.get("host")
138
+ if not profile_host:
139
+ raise RuntimeError(f"No 'host' found in profile '{profile_id}'")
140
+
141
+ auth_type = profile.get("auth_type", "u2m")
142
+
143
+ # Path 2: M2M via profile
144
+ if auth_type == "m2m":
145
+ profile_client_id = profile.get("client_id")
146
+ profile_client_secret = profile.get("client_secret")
147
+ if not profile_client_id or not profile_client_secret:
148
+ raise RuntimeError(f"Profile '{profile_id}' has auth_type=m2m but missing client_id/client_secret")
149
+ cached = _load_cached_token(profile_id)
150
+ if cached:
151
+ return exchange_for_registry_token(profile_host, cached)
152
+ access_token = get_menda_access_token(profile_host, profile_client_id, profile_client_secret)
153
+ return exchange_for_registry_token(profile_host, access_token)
154
+
155
+ # Path 3: U2M via cached token
156
+ cached = _load_cached_token(profile_id)
157
+ if not cached:
158
+ raise RuntimeError(f"No valid cached token for profile '{profile_id}'. Run 'menda auth login' to authenticate.")
159
+ return exchange_for_registry_token(profile_host, cached)
@@ -0,0 +1,47 @@
1
+ """Menda CodeArtifact keyring backend.
2
+
3
+ Discovered by the `keyring` library via the entry point in pyproject.toml.
4
+ When `uv` needs credentials for a CodeArtifact URL, it calls
5
+ `keyring get <url> <username>` which invokes this backend.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from keyring.backend import KeyringBackend
13
+
14
+ from menda.error.exceptions import MendaKeyRingError
15
+ from menda.keyring.auth import resolve_and_get_registry_token
16
+
17
+ _CODEARTIFACT_URL_PATTERN = re.compile(r"\.d\.codeartifact\.[a-z0-9-]+\.amazonaws\.com")
18
+
19
+
20
+ class MendaCodeArtifactKeyring(KeyringBackend):
21
+ """Keyring backend that vends CodeArtifact tokens via menda-cloud."""
22
+
23
+ priority = 10
24
+
25
+ @staticmethod
26
+ def _is_codeartifact_url(url: str) -> bool:
27
+ """Check if the URL is an AWS CodeArtifact URL."""
28
+ return bool(_CODEARTIFACT_URL_PATTERN.search(url))
29
+
30
+ def get_password(self, service: str, username: str) -> str | None:
31
+ """Return a CodeArtifact token for the given URL, or None."""
32
+ if not self._is_codeartifact_url(service):
33
+ return None
34
+
35
+ try:
36
+ return resolve_and_get_registry_token()
37
+ except Exception as exc:
38
+ raise MendaKeyRingError(
39
+ f"Failed to obtain token from menda keyring backend: {exc}",
40
+ status_code=500,
41
+ ) from exc
42
+
43
+ def set_password(self, service: str, username: str, password: str) -> None:
44
+ """No-op. We never write credentials."""
45
+
46
+ def delete_password(self, service: str, username: str) -> None:
47
+ """No-op. We never delete credentials."""