enkindler 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,41 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ name: ${{ matrix.os }} / Python ${{ matrix.python-version }}
11
+ runs-on: ${{ matrix.os }}
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ os: [ubuntu-latest, macos-latest, windows-latest]
16
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ cache: pip
24
+
25
+ # Windows runners ship a stub `make`; install GNU make via choco.
26
+ - name: Install GNU make (Windows)
27
+ if: runner.os == 'Windows'
28
+ run: choco install make -y --no-progress
29
+
30
+ - name: Install
31
+ run: make install
32
+ # The Makefile wraps venv setup so it's identical across OSes.
33
+
34
+ - name: Lint
35
+ run: make lint
36
+
37
+ - name: Test
38
+ run: make test
39
+
40
+ - name: Smoke
41
+ run: make smoke
@@ -0,0 +1,42 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ .venv/
8
+ venv/
9
+ env/
10
+ .eggs/
11
+ *.egg-info/
12
+ *.egg
13
+ build/
14
+ dist/
15
+ wheels/
16
+ .installed.cfg
17
+
18
+ # Generated SDK (regenerated from openapi spec at build time; never commit)
19
+ src/enkindler_cli/_generated/
20
+
21
+ # Test/coverage
22
+ .pytest_cache/
23
+ .coverage
24
+ .coverage.*
25
+ htmlcov/
26
+ .tox/
27
+
28
+ # Linters
29
+ .ruff_cache/
30
+ .mypy_cache/
31
+
32
+ # IDE
33
+ .idea/
34
+ .vscode/
35
+ *.swp
36
+
37
+ # OS
38
+ .DS_Store
39
+ Thumbs.db
40
+
41
+ # Local config that might be created during dev runs
42
+ *.local.toml
@@ -0,0 +1,142 @@
1
+ # CLAUDE.md
2
+
3
+ Operating manual for Claude Code (and human contributors) working in this repo.
4
+
5
+ ## Project Overview
6
+
7
+ `enkindler-cli` is the Python command-line client for the [Enkindler](https://enkindler.enkindl.com) AI operations dashboard. Distributed on PyPI as `pip install enkindler`. Patterned after `az` and `databricks`.
8
+
9
+ The CLI is **also exposed as a tool to AI agents**, so command behaviour, output, and error messages must be unambiguous and machine-parseable. When in doubt, optimize for "an LLM should be able to call this without trial and error."
10
+
11
+ The companion backend repo is at `../enkindler/` (sibling). The backend exposes an OpenAPI 3 spec at `/api/openapi.json`; the CLI consumes that spec to generate a typed SDK and surface commands.
12
+
13
+ ## Repo layout
14
+
15
+ ```
16
+ enkindler-cli/
17
+ ├── Makefile # Source of truth for all dev/build/release commands
18
+ ├── pyproject.toml # PEP 621 metadata; deps; ruff/pytest config
19
+ ├── README.md # End-user docs (install, basic usage)
20
+ ├── CLAUDE.md # ← this file
21
+ ├── docs/ # Long-form procedures (release, sdk-sync, etc.)
22
+ ├── .github/workflows/ci.yml # OS × Python matrix
23
+ ├── src/enkindler_cli/
24
+ │ ├── __init__.py # __version__
25
+ │ ├── main.py # Typer app + global error handler (_entry)
26
+ │ ├── client.py # httpx wrapper + AuthError/APIError
27
+ │ ├── config.py # platformdirs-based TOML config
28
+ │ ├── commands/
29
+ │ │ ├── auth.py # login (device-code), logout
30
+ │ │ ├── users.py # whoami
31
+ │ │ └── deployments.py # list, get, ...
32
+ │ └── _generated/ # ← gitignored; created by `make regen-sdk`
33
+ └── tests/
34
+ ```
35
+
36
+ ## Always use `make`
37
+
38
+ Every common task has a make target. Use them. Never invoke `pytest` / `ruff` / `python -m build` directly — CI uses make, so should you. This is the only way command lines stay in sync across humans, CI, and Claude.
39
+
40
+ ```bash
41
+ make help # list everything
42
+ make install # set up venv + install with [dev] extras
43
+ make test # run pytest
44
+ make lint # ruff check (no fix)
45
+ make format # ruff fix + format
46
+ make smoke # `enkindler --version` + `--help` smoke check
47
+ make build # sdist + wheel into ./dist
48
+ make regen-sdk # pull /api/openapi.json from backend, regenerate _generated/
49
+ make publish # twine upload to PyPI (needs TWINE_PASSWORD)
50
+ make clean # rm caches and build artifacts
51
+ ```
52
+
53
+ If you need a new shell command frequently, add it as a make target — don't put it in CLAUDE.md prose.
54
+
55
+ ## Conventions
56
+
57
+ ### Commands
58
+ - One file per noun under `src/enkindler_cli/commands/` (e.g. `deployments.py`, `helm.py`, `integrations.py`).
59
+ - A noun with subcommands (verbs) uses a Typer sub-app: see `commands/deployments.py` — exports `app = typer.Typer()` and uses `@app.command("list")`.
60
+ - A noun with one verb (`whoami`, `login`) is a plain function: see `commands/users.py` — register at the top level in `main.py`.
61
+ - All commands accept `--api-url` (override env / config). Resolution: flag > `ENKINDLER_API_URL` > config file > default. Use `effective_api_url()` from `config.py`.
62
+
63
+ ### Errors
64
+ - Never `raise` raw `httpx.HTTPStatusError`. Use `Client` from `client.py` — it converts non-2xx to `AuthError` (401/403) or `APIError` (other). The top-level `_entry()` in `main.py` formats both as friendly red messages with a recovery hint.
65
+ - For business-logic exits, `raise typer.Exit(code=N)` — never `sys.exit()`.
66
+
67
+ ### Output
68
+ - Human output → `rich.Console()` to stdout.
69
+ - Errors → `rich.Console(stderr=True)`.
70
+ - Tables are the default for list commands. Use `rich.table.Table`.
71
+ - Don't print debug info to stdout — that breaks `--output json` (when we add it).
72
+
73
+ ### HTTP
74
+ - All HTTP via `Client` from `client.py`. Always use it as a context manager.
75
+ - One `Client` per command invocation. Don't share connection pools across commands.
76
+
77
+ ### Config
78
+ - Use `platformdirs` for paths. Never hardcode `~/.enkindler` — Windows uses `%APPDATA%`.
79
+ - TOML, not JSON or YAML.
80
+ - Token cached in `config.toml` with 0o600 perms on POSIX. Don't put tokens in env vars or CLI args; the only sanctioned write path is `enkindler login` → `Config.save()`.
81
+
82
+ ### Cross-platform
83
+ We support macOS, Linux, and Windows on Python 3.10–3.13. CI runs the full matrix. Avoid:
84
+ - `os.path.join('/etc/...')`, hardcoded slashes — use `pathlib`
85
+ - Shelling out to Unix-only tools (`curl`, `grep`, `sed`)
86
+ - `subprocess` with `shell=True` — pass an arg list
87
+ - Symlinks
88
+ - ANSI escape codes in non-TTY output (Rich handles this if you let it)
89
+
90
+ ## Common Tasks
91
+
92
+ ### Add a new command
93
+ 1. Create or edit a file under `src/enkindler_cli/commands/`.
94
+ 2. If it's a new noun, add a sub-`Typer` and register it in `main.py` via `app.add_typer(... name="noun")`.
95
+ 3. Add a smoke test in `tests/test_smoke.py` that the new command appears in `--help`.
96
+ 4. Run `make lint test` before committing.
97
+
98
+ ### Add a new endpoint to call
99
+ Once the backend annotates a new route (see backend `CLAUDE.md` → "Annotating routes for OpenAPI/CLI"):
100
+ 1. Run `make regen-sdk` (or `make regen-sdk OPENAPI_URL=http://localhost:5179/api/openapi.json` while iterating locally).
101
+ 2. Use the generated client from `_generated/` in your command. Until the SDK lands, hand-call via `Client.get` / `.post`.
102
+
103
+ ### Cut a release
104
+ See `docs/release.md` (TBD — write when we cut v0.1.0). Short version:
105
+ 1. Bump `__version__` in `src/enkindler_cli/__init__.py` AND `version` in `pyproject.toml`.
106
+ 2. `make clean build`
107
+ 3. `make publish-test` → install from TestPyPI in a fresh venv, smoke-test
108
+ 4. `make publish`
109
+ 5. `git tag vX.Y.Z && git push --tags`
110
+
111
+ ### Backend out of sync with CLI
112
+ If `make regen-sdk` fails or the generated SDK doesn't compile, the backend OpenAPI spec is malformed. Fix it in `../enkindler/backend/src/server.js` (the JSDoc `@openapi` block above the offending route) — see backend `CLAUDE.md` for the annotation pattern.
113
+
114
+ ## Testing the CLI against a real backend
115
+
116
+ ```bash
117
+ make install
118
+ make smoke # offline smoke
119
+ .venv/bin/enkindler login # device-code flow against prod backend
120
+ .venv/bin/enkindler whoami
121
+ .venv/bin/enkindler deployment list
122
+ ```
123
+
124
+ Override the backend for local dev:
125
+ ```bash
126
+ ENKINDLER_API_URL=http://localhost:5179 .venv/bin/enkindler login
127
+ ```
128
+
129
+ ## What NOT to do
130
+
131
+ - Don't add a `requirements.txt`. Deps live in `pyproject.toml`.
132
+ - Don't pin sub-dependencies in `pyproject.toml` — only direct deps.
133
+ - Don't commit `src/enkindler_cli/_generated/` — gitignored, regenerated on demand.
134
+ - Don't add Click decorators directly. Use Typer (which wraps Click) — it gives us free `--help` from type hints.
135
+ - Don't break Python 3.10. We support it until end-of-life.
136
+ - Don't import optional deps at the top of a module — gate behind `if TYPE_CHECKING:` or import inside the function. Keeps `enkindler --help` snappy.
137
+
138
+ ## Pointers to backend repo
139
+
140
+ - Backend endpoints, auth model, RBAC, deployment infrastructure → `../enkindler/CLAUDE.md`
141
+ - Backend OpenAPI annotation pattern → `../enkindler/CLAUDE.md` → "Annotating routes for OpenAPI/CLI"
142
+ - Device-code auth implementation → `../enkindler/backend/src/auth/deviceCode.js`
@@ -0,0 +1,108 @@
1
+ # enkindler-cli — canonical command set.
2
+ #
3
+ # Both humans and CI use these targets so there is one source of truth.
4
+ # `make help` lists everything.
5
+
6
+ PYTHON ?= python3
7
+ VENV ?= .venv
8
+
9
+ # venv layout differs on Windows (Scripts/) vs POSIX (bin/).
10
+ ifeq ($(OS),Windows_NT)
11
+ VENV_BIN := $(VENV)/Scripts
12
+ PYTHON := python
13
+ else
14
+ VENV_BIN := $(VENV)/bin
15
+ endif
16
+ PIP := $(VENV_BIN)/pip
17
+ PY := $(VENV_BIN)/python
18
+ ENK := $(VENV_BIN)/enkindler
19
+
20
+ # Backend OpenAPI spec — used by `make regen-sdk`. Override with
21
+ # `make regen-sdk OPENAPI_URL=http://localhost:5179/api/openapi.json`
22
+ # when iterating on the backend locally.
23
+ OPENAPI_URL ?= https://enkindler.enkindl.com/api/openapi.json
24
+
25
+ # Where the auto-generated openapi-python-client output lives.
26
+ # Gitignored — regenerated from the backend spec on demand.
27
+ SDK_DIR := src/enkindler_cli/_generated
28
+
29
+ .DEFAULT_GOAL := help
30
+ .PHONY: help setup install dev test lint format typecheck build clean publish-test publish regen-sdk smoke version
31
+
32
+ help: ## Show this help.
33
+ @awk 'BEGIN { FS = ":.*?## " } /^[a-zA-Z0-9_-]+:.*?## / { printf " \033[36m%-16s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
34
+
35
+ # ─── Local dev setup ─────────────────────────────────────────────────────────
36
+
37
+ $(VENV_BIN)/python:
38
+ $(PYTHON) -m venv $(VENV)
39
+ $(PIP) install --upgrade pip
40
+
41
+ setup: $(VENV_BIN)/python ## Create virtualenv and install runtime deps only.
42
+ $(PIP) install -e .
43
+
44
+ install: $(VENV_BIN)/python ## Alias for `setup` + dev extras.
45
+ $(PIP) install -e '.[dev]'
46
+
47
+ dev: install ## Same as `install` — for muscle memory.
48
+
49
+ # ─── Quality gates ───────────────────────────────────────────────────────────
50
+
51
+ test: install ## Run the test suite.
52
+ $(VENV_BIN)/pytest
53
+
54
+ lint: install ## Lint with ruff (no auto-fix).
55
+ $(VENV_BIN)/ruff check .
56
+
57
+ format: install ## Auto-format and apply safe ruff fixes.
58
+ $(VENV_BIN)/ruff check --fix .
59
+ $(VENV_BIN)/ruff format .
60
+
61
+ typecheck: ## (Stub) — add when we adopt a type checker.
62
+ @echo "No type checker configured yet. Add mypy or pyright when ready."
63
+
64
+ smoke: install ## Cheap end-to-end check that the script entrypoint works.
65
+ $(ENK) --version
66
+ $(ENK) --help > /dev/null
67
+
68
+ version: install ## Print the package version.
69
+ @$(ENK) --version
70
+
71
+ # ─── Build & publish ─────────────────────────────────────────────────────────
72
+
73
+ build: install ## Build sdist + wheel into ./dist/
74
+ $(PIP) install --quiet build
75
+ $(PY) -m build
76
+
77
+ publish-test: build ## Upload to TestPyPI (requires TWINE_PASSWORD env).
78
+ $(PIP) install --quiet twine
79
+ $(VENV_BIN)/twine upload --repository testpypi dist/*
80
+
81
+ publish: build ## Upload to PyPI (requires TWINE_PASSWORD env).
82
+ $(PIP) install --quiet twine
83
+ $(VENV_BIN)/twine upload dist/*
84
+
85
+ # ─── SDK regen from backend OpenAPI spec ─────────────────────────────────────
86
+
87
+ regen-sdk: install ## Regenerate the typed SDK from the backend OpenAPI spec.
88
+ $(PIP) install --quiet openapi-python-client
89
+ rm -rf $(SDK_DIR)
90
+ mkdir -p $(SDK_DIR)
91
+ $(VENV_BIN)/openapi-python-client generate \
92
+ --url $(OPENAPI_URL) \
93
+ --output-path $(SDK_DIR)/_tmp \
94
+ --meta none
95
+ # openapi-python-client puts files in <project_name>/, flatten one level
96
+ mv $(SDK_DIR)/_tmp/*/* $(SDK_DIR)/
97
+ rm -rf $(SDK_DIR)/_tmp
98
+ @echo "SDK regenerated into $(SDK_DIR) from $(OPENAPI_URL)"
99
+
100
+ # ─── Housekeeping ────────────────────────────────────────────────────────────
101
+
102
+ clean: ## Remove build artifacts and caches (keeps the venv).
103
+ rm -rf build dist *.egg-info
104
+ rm -rf .pytest_cache .ruff_cache .mypy_cache
105
+ find . -type d -name __pycache__ -prune -exec rm -rf {} +
106
+
107
+ clean-all: clean ## Also delete the virtualenv.
108
+ rm -rf $(VENV)
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: enkindler
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for the Enkindler AI operations dashboard.
5
+ Project-URL: Homepage, https://enkindler.enkindl.com
6
+ Project-URL: Repository, https://bitbucket.org/enkindl/enkindler-cli
7
+ Project-URL: Issues, https://bitbucket.org/enkindl/enkindler-cli/issues
8
+ Author: Enkindler Platform
9
+ License: MIT
10
+ Keywords: aks,azure,cli,enkindler,kubernetes
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: System :: Systems Administration
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: httpx>=0.27.0
27
+ Requires-Dist: platformdirs>=4.2.0
28
+ Requires-Dist: rich>=13.7.0
29
+ Requires-Dist: tomli-w>=1.0.0
30
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
31
+ Requires-Dist: typer>=0.12.0
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
34
+ Requires-Dist: pytest>=8.0; extra == 'dev'
35
+ Requires-Dist: ruff>=0.5.0; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # enkindler CLI
39
+
40
+ Command-line interface for the [Enkindler](https://enkindler.enkindl.com) AI operations dashboard. Manages AKS deployments, Helm platforms, integrations, and cluster resources from your terminal.
41
+
42
+ Patterned after `az` and `databricks` — designed to be ergonomic for both humans and AI agents.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install enkindler
48
+ ```
49
+
50
+ Works on macOS, Linux, and Windows (Python 3.10+).
51
+
52
+ ## Quick start
53
+
54
+ ```bash
55
+ # Sign in via Microsoft device-code flow (opens browser)
56
+ enkindler login
57
+
58
+ # Verify the session
59
+ enkindler whoami
60
+
61
+ # List your deployments
62
+ enkindler deployment list
63
+ ```
64
+
65
+ ## Configuration
66
+
67
+ Config and the cached JWT live at:
68
+
69
+ | OS | Path |
70
+ |---|---|
71
+ | macOS | `~/Library/Application Support/enkindler/config.toml` |
72
+ | Linux | `~/.config/enkindler/config.toml` |
73
+ | Windows | `%APPDATA%\enkindler\config.toml` |
74
+
75
+ Override the backend URL with `--api-url https://...` on any command, or set `ENKINDLER_API_URL` in the environment.
76
+
77
+ ## Architecture
78
+
79
+ - Backend exposes an OpenAPI spec at `/api/openapi.json` (the contract).
80
+ - This CLI consumes that spec to drive command behaviour.
81
+ - Auth uses the Azure AD device-code flow — no browser required on the same machine, no token-pasting.
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ python -m venv .venv && source .venv/bin/activate # or `.venv\Scripts\activate` on Windows
87
+ pip install -e '.[dev]'
88
+ pytest
89
+ ruff check .
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,57 @@
1
+ # enkindler CLI
2
+
3
+ Command-line interface for the [Enkindler](https://enkindler.enkindl.com) AI operations dashboard. Manages AKS deployments, Helm platforms, integrations, and cluster resources from your terminal.
4
+
5
+ Patterned after `az` and `databricks` — designed to be ergonomic for both humans and AI agents.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install enkindler
11
+ ```
12
+
13
+ Works on macOS, Linux, and Windows (Python 3.10+).
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ # Sign in via Microsoft device-code flow (opens browser)
19
+ enkindler login
20
+
21
+ # Verify the session
22
+ enkindler whoami
23
+
24
+ # List your deployments
25
+ enkindler deployment list
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ Config and the cached JWT live at:
31
+
32
+ | OS | Path |
33
+ |---|---|
34
+ | macOS | `~/Library/Application Support/enkindler/config.toml` |
35
+ | Linux | `~/.config/enkindler/config.toml` |
36
+ | Windows | `%APPDATA%\enkindler\config.toml` |
37
+
38
+ Override the backend URL with `--api-url https://...` on any command, or set `ENKINDLER_API_URL` in the environment.
39
+
40
+ ## Architecture
41
+
42
+ - Backend exposes an OpenAPI spec at `/api/openapi.json` (the contract).
43
+ - This CLI consumes that spec to drive command behaviour.
44
+ - Auth uses the Azure AD device-code flow — no browser required on the same machine, no token-pasting.
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ python -m venv .venv && source .venv/bin/activate # or `.venv\Scripts\activate` on Windows
50
+ pip install -e '.[dev]'
51
+ pytest
52
+ ruff check .
53
+ ```
54
+
55
+ ## License
56
+
57
+ MIT
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "enkindler"
7
+ version = "0.1.0"
8
+ description = "Command-line interface for the Enkindler AI operations dashboard."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Enkindler Platform" }]
13
+ keywords = ["enkindler", "cli", "aks", "kubernetes", "azure"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: MacOS",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Operating System :: Microsoft :: Windows",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: System :: Systems Administration",
29
+ ]
30
+ dependencies = [
31
+ "typer>=0.12.0",
32
+ "rich>=13.7.0",
33
+ "httpx>=0.27.0",
34
+ "platformdirs>=4.2.0",
35
+ "tomli>=2.0.1; python_version<'3.11'",
36
+ "tomli-w>=1.0.0",
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=8.0",
42
+ "pytest-httpx>=0.30",
43
+ "ruff>=0.5.0",
44
+ ]
45
+
46
+ [project.scripts]
47
+ enkindler = "enkindler_cli.main:_entry"
48
+
49
+ [project.urls]
50
+ Homepage = "https://enkindler.enkindl.com"
51
+ Repository = "https://bitbucket.org/enkindl/enkindler-cli"
52
+ Issues = "https://bitbucket.org/enkindl/enkindler-cli/issues"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/enkindler_cli"]
56
+
57
+ [tool.ruff]
58
+ line-length = 100
59
+ target-version = "py310"
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
63
+ ignore = ["E501"] # line length handled by formatter
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
67
+ addopts = "-ra -q"
@@ -0,0 +1,3 @@
1
+ """Enkindler command-line interface."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,114 @@
1
+ """Thin httpx wrapper that injects the cached JWT and raises typed errors.
2
+
3
+ Eventually most calls will go through the openapi-python-client generated SDK
4
+ in `_generated/`. This wrapper handles the bootstrap concerns:
5
+
6
+ * the device-code login flow (no token yet)
7
+ * sanity calls like /api/health and /api/instance that don't require auth
8
+ * a uniform AuthError that the CLI can intercept with a "run `enkindler login`"
9
+ hint instead of a stack trace
10
+
11
+ URL resolution is now driven by the multi-cluster Config — Client.from_cluster()
12
+ takes a resolved Cluster object. Per-command --api-url and ENKINDLER_API_URL
13
+ override the cluster's stored URL but reuse its token.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from .config import Cluster, Config, ConfigError, effective_url_override
23
+
24
+ USER_AGENT = "enkindler-cli/0.0.1"
25
+
26
+
27
+ class APIError(Exception):
28
+ """Backend returned a non-2xx status with an error envelope."""
29
+
30
+ def __init__(self, status: int, message: str):
31
+ super().__init__(f"{status}: {message}")
32
+ self.status = status
33
+ self.message = message
34
+
35
+
36
+ class AuthError(APIError):
37
+ """401/403 — token missing, invalid, or insufficient privileges."""
38
+
39
+
40
+ def _raise(resp: httpx.Response) -> None:
41
+ if resp.is_success:
42
+ return
43
+ try:
44
+ body: dict[str, Any] = resp.json()
45
+ msg = body.get("error") or body.get("message") or resp.text
46
+ except (ValueError, httpx.DecodingError):
47
+ msg = resp.text or resp.reason_phrase
48
+ if resp.status_code in (401, 403):
49
+ raise AuthError(resp.status_code, msg)
50
+ raise APIError(resp.status_code, msg)
51
+
52
+
53
+ class Client:
54
+ """Synchronous httpx client scoped to one Enkindler backend."""
55
+
56
+ def __init__(self, base_url: str, token: str | None = None, timeout: float = 30.0):
57
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
58
+ if token:
59
+ headers["Authorization"] = f"Bearer {token}"
60
+ self._http = httpx.Client(base_url=base_url, headers=headers, timeout=timeout)
61
+
62
+ @classmethod
63
+ def from_cluster(cls, cluster: Cluster, *, api_url_override: str | None = None) -> Client:
64
+ url = api_url_override or effective_url_override() or cluster.url
65
+ return cls(base_url=url, token=cluster.token)
66
+
67
+ @classmethod
68
+ def for_url(cls, url: str, *, token: str | None = None) -> Client:
69
+ """Bare client for endpoints that don't have a configured cluster yet
70
+ (e.g. `enkindler config add` verifying a fresh URL)."""
71
+ return cls(base_url=url.rstrip("/"), token=token)
72
+
73
+ @classmethod
74
+ def from_default(cls, cluster_name: str | None = None, api_url_override: str | None = None) -> Client:
75
+ """Convenience: resolve via Config.load() and the optional --cluster name.
76
+ Raises ConfigError if no cluster is configured / resolvable."""
77
+ cfg = Config.load()
78
+ cluster = cfg.resolve(cluster_name)
79
+ return cls.from_cluster(cluster, api_url_override=api_url_override)
80
+
81
+ def get(self, path: str, **kwargs: Any) -> Any:
82
+ r = self._http.get(path, **kwargs)
83
+ _raise(r)
84
+ return r.json()
85
+
86
+ def post(self, path: str, json: Any | None = None, **kwargs: Any) -> Any:
87
+ r = self._http.post(path, json=json, **kwargs)
88
+ _raise(r)
89
+ if r.status_code == 204 or not r.content:
90
+ return None
91
+ return r.json()
92
+
93
+ def patch(self, path: str, json: Any | None = None, **kwargs: Any) -> Any:
94
+ r = self._http.patch(path, json=json, **kwargs)
95
+ _raise(r)
96
+ return r.json()
97
+
98
+ def delete(self, path: str, **kwargs: Any) -> Any:
99
+ r = self._http.delete(path, **kwargs)
100
+ _raise(r)
101
+ return r.json() if r.content else None
102
+
103
+ def close(self) -> None:
104
+ self._http.close()
105
+
106
+ def __enter__(self) -> Client:
107
+ return self
108
+
109
+ def __exit__(self, *exc: object) -> None:
110
+ self.close()
111
+
112
+
113
+ # Re-export for command modules.
114
+ __all__ = ["APIError", "AuthError", "Client", "Cluster", "Config", "ConfigError"]
File without changes