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.
- menda_bootstrap-0.1.0/.github/workflows/ci.yml +12 -0
- menda_bootstrap-0.1.0/.gitignore +207 -0
- menda_bootstrap-0.1.0/.python-version +1 -0
- menda_bootstrap-0.1.0/PKG-INFO +12 -0
- menda_bootstrap-0.1.0/README.md +2 -0
- menda_bootstrap-0.1.0/pyproject.toml +152 -0
- menda_bootstrap-0.1.0/src/menda/__init__.py +3 -0
- menda_bootstrap-0.1.0/src/menda/error/__init__.py +1 -0
- menda_bootstrap-0.1.0/src/menda/error/exceptions.py +18 -0
- menda_bootstrap-0.1.0/src/menda/keyring/__init__.py +0 -0
- menda_bootstrap-0.1.0/src/menda/keyring/auth.py +159 -0
- menda_bootstrap-0.1.0/src/menda/keyring/backend.py +47 -0
- menda_bootstrap-0.1.0/tests/keyring/test_auth.py +253 -0
- menda_bootstrap-0.1.0/tests/keyring/test_backend.py +87 -0
- menda_bootstrap-0.1.0/uv.lock +593 -0
|
@@ -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,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 @@
|
|
|
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."""
|