pysae-cli-tools 0.1.2__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,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysae-cli-tools
3
+ Version: 0.1.2
4
+ Summary: Reusable utilities for Pysae Python CLIs (k8s pod dispatch, …).
5
+ License: MIT
6
+ Author: Rémi Alvergnat
7
+ Author-email: remi.alvergnat@pysae.com
8
+ Requires-Python: >=3.11,<4
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: typer (>=0.25.1,<0.26.0)
16
+ Project-URL: Homepage, https://gitlab.com/pysae/tools/cli-tools
17
+ Project-URL: Repository, https://gitlab.com/pysae/tools/cli-tools
18
+ Description-Content-Type: text/markdown
19
+
20
+ # pysae-cli-tools
21
+
22
+ Reusable utilities for Pysae Python CLIs.
23
+
24
+ [![PyPI](https://img.shields.io/pypi/v/pysae-cli-tools.svg)](https://pypi.org/project/pysae-cli-tools/)
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install pysae-cli-tools
30
+ # or
31
+ poetry add pysae-cli-tools
32
+ ```
33
+
34
+ ## What's included
35
+
36
+ ### `pysae_cli_tools.k8s` — run any Typer command in an ephemeral pod
37
+
38
+ The `@k8s_support` decorator injects three flags into a Typer command —
39
+ `--k8s`, `--k8s-environment {dev|prod}`, `--k8s-from-local-sources` — and
40
+ dispatches the call into a freshly-spawned Kubernetes pod when `--k8s` is set.
41
+
42
+ It is meant for CLIs that need to run inside the same network as their target
43
+ infrastructure (private-link databases, VPC-only APIs, …) without rewriting the
44
+ command for `kubectl run`.
45
+
46
+ #### Usage with `build_k8s_support` (recommended)
47
+
48
+ Most projects share the same `K8sConfig` across every decorated command —
49
+ declare it once and reuse the bound decorator everywhere:
50
+
51
+ ```python
52
+ from pathlib import Path
53
+
54
+ from typer import Typer
55
+
56
+ from pysae_cli_tools.k8s import K8sConfig, build_k8s_support
57
+
58
+ K8S_CONFIG = K8sConfig(
59
+ default_image="<registry>/<project>:latest",
60
+ project_root=Path(__file__).resolve().parents[1],
61
+ copy_paths=("my_pkg", "pyproject.toml", "poetry.lock"),
62
+ install_script=(
63
+ "apt-get update -qq && pip install poetry && "
64
+ "poetry config virtualenvs.create false && "
65
+ "poetry install --only main --no-interaction"
66
+ ),
67
+ forwarded_envvars=("MY_API_KEY", "MY_DB_URI"),
68
+ redacted_options=("--api-key", "--password"),
69
+ )
70
+
71
+ k8s_support = build_k8s_support(K8S_CONFIG)
72
+ app = Typer()
73
+
74
+
75
+ @app.command()
76
+ @k8s_support()
77
+ def my_command() -> None:
78
+ ...
79
+
80
+
81
+ @app.command()
82
+ @k8s_support(pod_name_prefix="my-second-command") # override per-command
83
+ def my_second_command() -> None:
84
+ ...
85
+ ```
86
+
87
+ #### Usage with the explicit form
88
+
89
+ When you want to use a different config per command, pass it directly:
90
+
91
+ ```python
92
+ from pysae_cli_tools.k8s import K8sConfig, k8s_support
93
+
94
+ @app.command()
95
+ @k8s_support(config=K8S_CONFIG)
96
+ def my_command() -> None:
97
+ ...
98
+ ```
99
+
100
+ #### What happens at runtime
101
+
102
+ When `--k8s` is set on the command line, the decorator:
103
+
104
+ 1. Spawns an ephemeral pod using `K8sConfig.default_image` (or the Dockerfile
105
+ base image when `--k8s-from-local-sources` is also set).
106
+ 2. Forwards every envvar listed in `K8sConfig.forwarded_envvars` from your
107
+ local shell into the pod's `env` block.
108
+ 3. Runs `python -m <your.cli.module> <subcommand> <filtered argv>` inside the
109
+ pod, with values matching `K8sConfig.redacted_options` masked in the
110
+ `[K8S] Running:` log line.
111
+ 4. Streams stdout/stderr back to your terminal and deletes the pod on exit.
112
+
113
+ See [`pysae_cli_tools/k8s/config.py`](pysae_cli_tools/k8s/config.py) for the
114
+ complete `K8sConfig` reference.
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ poetry install
120
+ poetry run pre-commit install
121
+ poetry run pytest
122
+ ```
123
+
124
+ CI publishes a new version to PyPI on every push to `main` — see
125
+ [`.gitlab-ci.yml`](.gitlab-ci.yml). The version is computed from
126
+ `git describe` via `pysae_cli_tools.compute_version`.
127
+
@@ -0,0 +1,107 @@
1
+ # pysae-cli-tools
2
+
3
+ Reusable utilities for Pysae Python CLIs.
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/pysae-cli-tools.svg)](https://pypi.org/project/pysae-cli-tools/)
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install pysae-cli-tools
11
+ # or
12
+ poetry add pysae-cli-tools
13
+ ```
14
+
15
+ ## What's included
16
+
17
+ ### `pysae_cli_tools.k8s` — run any Typer command in an ephemeral pod
18
+
19
+ The `@k8s_support` decorator injects three flags into a Typer command —
20
+ `--k8s`, `--k8s-environment {dev|prod}`, `--k8s-from-local-sources` — and
21
+ dispatches the call into a freshly-spawned Kubernetes pod when `--k8s` is set.
22
+
23
+ It is meant for CLIs that need to run inside the same network as their target
24
+ infrastructure (private-link databases, VPC-only APIs, …) without rewriting the
25
+ command for `kubectl run`.
26
+
27
+ #### Usage with `build_k8s_support` (recommended)
28
+
29
+ Most projects share the same `K8sConfig` across every decorated command —
30
+ declare it once and reuse the bound decorator everywhere:
31
+
32
+ ```python
33
+ from pathlib import Path
34
+
35
+ from typer import Typer
36
+
37
+ from pysae_cli_tools.k8s import K8sConfig, build_k8s_support
38
+
39
+ K8S_CONFIG = K8sConfig(
40
+ default_image="<registry>/<project>:latest",
41
+ project_root=Path(__file__).resolve().parents[1],
42
+ copy_paths=("my_pkg", "pyproject.toml", "poetry.lock"),
43
+ install_script=(
44
+ "apt-get update -qq && pip install poetry && "
45
+ "poetry config virtualenvs.create false && "
46
+ "poetry install --only main --no-interaction"
47
+ ),
48
+ forwarded_envvars=("MY_API_KEY", "MY_DB_URI"),
49
+ redacted_options=("--api-key", "--password"),
50
+ )
51
+
52
+ k8s_support = build_k8s_support(K8S_CONFIG)
53
+ app = Typer()
54
+
55
+
56
+ @app.command()
57
+ @k8s_support()
58
+ def my_command() -> None:
59
+ ...
60
+
61
+
62
+ @app.command()
63
+ @k8s_support(pod_name_prefix="my-second-command") # override per-command
64
+ def my_second_command() -> None:
65
+ ...
66
+ ```
67
+
68
+ #### Usage with the explicit form
69
+
70
+ When you want to use a different config per command, pass it directly:
71
+
72
+ ```python
73
+ from pysae_cli_tools.k8s import K8sConfig, k8s_support
74
+
75
+ @app.command()
76
+ @k8s_support(config=K8S_CONFIG)
77
+ def my_command() -> None:
78
+ ...
79
+ ```
80
+
81
+ #### What happens at runtime
82
+
83
+ When `--k8s` is set on the command line, the decorator:
84
+
85
+ 1. Spawns an ephemeral pod using `K8sConfig.default_image` (or the Dockerfile
86
+ base image when `--k8s-from-local-sources` is also set).
87
+ 2. Forwards every envvar listed in `K8sConfig.forwarded_envvars` from your
88
+ local shell into the pod's `env` block.
89
+ 3. Runs `python -m <your.cli.module> <subcommand> <filtered argv>` inside the
90
+ pod, with values matching `K8sConfig.redacted_options` masked in the
91
+ `[K8S] Running:` log line.
92
+ 4. Streams stdout/stderr back to your terminal and deletes the pod on exit.
93
+
94
+ See [`pysae_cli_tools/k8s/config.py`](pysae_cli_tools/k8s/config.py) for the
95
+ complete `K8sConfig` reference.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ poetry install
101
+ poetry run pre-commit install
102
+ poetry run pytest
103
+ ```
104
+
105
+ CI publishes a new version to PyPI on every push to `main` — see
106
+ [`.gitlab-ci.yml`](.gitlab-ci.yml). The version is computed from
107
+ `git describe` via `pysae_cli_tools.compute_version`.
@@ -0,0 +1,54 @@
1
+ [tool.poetry]
2
+ name = "pysae-cli-tools"
3
+ version = "0.1.2"
4
+ description = "Reusable utilities for Pysae Python CLIs (k8s pod dispatch, …)."
5
+ authors = ["Rémi Alvergnat <remi.alvergnat@pysae.com>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ homepage = "https://gitlab.com/pysae/tools/cli-tools"
9
+ repository = "https://gitlab.com/pysae/tools/cli-tools"
10
+ packages = [{include = "pysae_cli_tools"}]
11
+
12
+ [tool.poetry.dependencies]
13
+ python = ">=3.11,<4"
14
+ typer = "^0.25.1"
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ pre-commit = "^4.6.0"
18
+ pytest = "^9.0.3"
19
+ pytest-mock = "^3.15.1"
20
+ mypy = "^2.1.0"
21
+ ruff = "^0.15.14"
22
+ black = "^26.5.1"
23
+
24
+ [tool.ruff]
25
+ line-length = 120
26
+ target-version = "py311"
27
+
28
+ [tool.ruff.lint]
29
+ ignore = [
30
+ "C901",
31
+ ]
32
+ select = [
33
+ "B", # flake8-bugbear
34
+ "C", # flake8-comprehensions
35
+ "E", # pycodestyle errors
36
+ "F", # pyflakes
37
+ "I", # isort
38
+ "UP", # pyupgrade
39
+ "W", # pycodestyle warnings
40
+ ]
41
+
42
+ [tool.mypy]
43
+ packages = "pysae_cli_tools,pysae_cli_tools_tests"
44
+ strict = true
45
+ implicit_reexport = true
46
+ ignore_missing_imports = true
47
+ follow_imports = "silent"
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["pysae_cli_tools_tests"]
51
+
52
+ [build-system]
53
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
54
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,76 @@
1
+ """Pysae shared utilities for Python CLIs."""
2
+
3
+ import re
4
+ import subprocess
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as _pkg_version
7
+ from pathlib import Path
8
+
9
+
10
+ def compute_version(build: bool = False) -> str:
11
+ """Compute the current version from git describe.
12
+
13
+ Args:
14
+ build: If True, return a clean version without .dev suffix (for CI publishing).
15
+ If False (default), append .dev+g<sha> for non-tagged commits.
16
+
17
+ Returns:
18
+ Version string like "0.2.17" (build) or "0.2.17.dev+gabcdef" (dev).
19
+ """
20
+ pkg_dir = Path(__file__).resolve().parent
21
+ cwd = str(pkg_dir)
22
+
23
+ try:
24
+ desc = subprocess.run(
25
+ ["git", "describe", "--tags", "--long", "--match", "v*"],
26
+ capture_output=True,
27
+ text=True,
28
+ encoding="utf-8",
29
+ errors="replace",
30
+ timeout=5,
31
+ cwd=cwd,
32
+ )
33
+ if desc.returncode == 0 and desc.stdout.strip():
34
+ m = re.match(r"^v?(\d+\.\d+\.\d+)-(\d+)-g([0-9a-f]+)$", desc.stdout.strip())
35
+ if m:
36
+ base, dist, sha = m.group(1), int(m.group(2)), m.group(3)
37
+ major, minor, patch = base.split(".")
38
+ if dist == 0:
39
+ return base
40
+ ver = f"{major}.{minor}.{int(patch) + dist}"
41
+ return ver if build else f"{ver}.dev+g{sha}"
42
+
43
+ count = subprocess.run(
44
+ ["git", "rev-list", "--count", "HEAD"],
45
+ capture_output=True,
46
+ text=True,
47
+ encoding="utf-8",
48
+ errors="replace",
49
+ timeout=5,
50
+ cwd=cwd,
51
+ )
52
+ sha_short = subprocess.run(
53
+ ["git", "rev-parse", "--short", "HEAD"],
54
+ capture_output=True,
55
+ text=True,
56
+ encoding="utf-8",
57
+ errors="replace",
58
+ timeout=5,
59
+ cwd=cwd,
60
+ )
61
+ c = count.stdout.strip() if count.returncode == 0 else "0"
62
+ s = sha_short.stdout.strip() if sha_short.returncode == 0 else ""
63
+ ver = f"0.1.{c}"
64
+ if build or not s:
65
+ return ver
66
+ return f"{ver}.dev+g{s}"
67
+ except (FileNotFoundError, subprocess.TimeoutExpired):
68
+ return "0.1.0" if build else "0.1.0.dev"
69
+
70
+
71
+ try:
72
+ __version__ = _pkg_version("pysae-cli-tools")
73
+ if __version__ == "0.1.0":
74
+ __version__ = compute_version(build=False)
75
+ except PackageNotFoundError:
76
+ __version__ = compute_version(build=False)
@@ -0,0 +1,5 @@
1
+ from pysae_cli_tools.k8s.config import K8sConfig as K8sConfig
2
+ from pysae_cli_tools.k8s.config import K8sEnvironment as K8sEnvironment
3
+ from pysae_cli_tools.k8s.runner import build_k8s_support as build_k8s_support
4
+ from pysae_cli_tools.k8s.runner import k8s_support as k8s_support
5
+ from pysae_cli_tools.k8s.runner import run_on_k8s as run_on_k8s
@@ -0,0 +1,83 @@
1
+ """Configuration object for the ``@k8s_support`` decorator.
2
+
3
+ Each project consuming :mod:`pysae_cli_tools.k8s` declares a single
4
+ :class:`K8sConfig` describing its image, source layout and secret-
5
+ handling conventions, then either:
6
+
7
+ - passes it explicitly: ``@k8s_support(config=CONFIG)``, or
8
+ - pre-binds it once with :func:`build_k8s_support` and uses the
9
+ returned decorator everywhere: ``@k8s_support()``.
10
+
11
+ The defaults match the Pysae convention (``dev``/``prod`` envs whose
12
+ value doubles as the kubectl context and namespace). Pass a custom
13
+ ``StrEnum`` to :attr:`K8sConfig.environments` to model a different
14
+ layout.
15
+ """
16
+
17
+ import enum
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+
22
+ class K8sEnvironment(enum.StrEnum):
23
+ """Default Pysae Kubernetes environments. Value == context == namespace."""
24
+
25
+ dev = "dev"
26
+ prod = "prod"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class K8sConfig:
31
+ """Project-level configuration for the k8s dispatch decorator."""
32
+
33
+ default_image: str
34
+ """Container image used by default to spawn the ephemeral pod."""
35
+
36
+ project_root: Path
37
+ """Absolute path to the project root. ``copy_paths`` are resolved relative to it."""
38
+
39
+ copy_paths: tuple[str, ...] = ()
40
+ """Paths under ``project_root`` to copy into the pod when
41
+ ``--k8s-from-local-sources`` is set. Empty disables that mode."""
42
+
43
+ install_script: str | None = None
44
+ """Bash script to run inside the pod after copying sources
45
+ (``--k8s-from-local-sources`` mode). Typically installs Poetry, the
46
+ project, and any extra binaries the deployed image already carries.
47
+ When ``None``, no bootstrap is performed (the base image must be
48
+ self-contained)."""
49
+
50
+ forwarded_envvars: tuple[str, ...] = ()
51
+ """Envvar names whose value is propagated from the operator's shell
52
+ into the pod's ``env`` block (treated as sensitive — redacted in logs)."""
53
+
54
+ redacted_options: tuple[str, ...] = ()
55
+ """CLI option names whose value is masked in the ``[K8S] Running:`` log line."""
56
+
57
+ environments: type[enum.Enum] = field(default=K8sEnvironment)
58
+ """Enum of supported environments. Each member's value is used as
59
+ both the kubectl context and the namespace."""
60
+
61
+ default_environment_value: str = "dev"
62
+ """Default value used for ``--k8s-environment`` when the operator
63
+ omits the flag. Must match one of ``environments``' values."""
64
+
65
+ default_pod_name_prefix: str = "pysae-cli"
66
+ """Pod name prefix. Override per-command via ``@k8s_support(pod_name_prefix=...)``."""
67
+
68
+ dockerfile_path: Path | None = None
69
+ """Path to the Dockerfile used to extract the base image in
70
+ ``--k8s-from-local-sources`` mode. Defaults to ``project_root / "Dockerfile"``."""
71
+
72
+ workdir: str = "/app"
73
+ """Working directory inside the pod. Sources are extracted here in
74
+ ``--k8s-from-local-sources`` mode."""
75
+
76
+ @property
77
+ def resolved_dockerfile(self) -> Path:
78
+ return self.dockerfile_path or self.project_root / "Dockerfile"
79
+
80
+ @property
81
+ def default_environment(self) -> enum.Enum:
82
+ """Resolve ``default_environment_value`` against ``environments``."""
83
+ return self.environments(self.default_environment_value)
@@ -0,0 +1,820 @@
1
+ """Run any Pysae CLI command in an ephemeral Kubernetes pod.
2
+
3
+ Two entry points:
4
+
5
+ - :func:`run_on_k8s` to dispatch programmatically.
6
+ - :func:`k8s_support` (or :func:`build_k8s_support` for a config-bound
7
+ variant) — a decorator on a Typer command that injects
8
+ ``--k8s`` / ``--k8s-environment`` / ``--k8s-from-local-sources``
9
+ options into its signature so they show up in ``<cmd> --help``
10
+ exactly as if they had been declared by hand. When ``--k8s`` is
11
+ set, the decorator intercepts the run and dispatches to k8s
12
+ instead of executing the local body.
13
+
14
+ Every project-specific knob (image, source layout, forwarded envvars,
15
+ redacted options, …) lives in :class:`K8sConfig` — see its docstring
16
+ for the full contract.
17
+ """
18
+
19
+ import atexit
20
+ import enum
21
+ import functools
22
+ import inspect
23
+ import json
24
+ import os
25
+ import re
26
+ import signal
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ import uuid
31
+ from collections.abc import Callable
32
+ from pathlib import Path
33
+ from typing import Annotated, Any, NoReturn, TypeVar
34
+
35
+ from typer import Option
36
+
37
+ from pysae_cli_tools.k8s.config import K8sConfig
38
+
39
+ _EXCLUDE_PATTERNS = [
40
+ "__pycache__",
41
+ "*.pyc",
42
+ ".git",
43
+ "node_modules",
44
+ ".mypy_cache",
45
+ ".pytest_cache",
46
+ ".ruff_cache",
47
+ "dist",
48
+ ".venv",
49
+ ]
50
+
51
+
52
+ def _collect_forwarded_envvars(config: K8sConfig) -> dict[str, str]:
53
+ """Return ``KEY -> VALUE`` for every forwarded envvar set locally."""
54
+ return {
55
+ key: os.environ[key] for key in config.forwarded_envvars if key in os.environ
56
+ }
57
+
58
+
59
+ # ---------- kubectl helpers --------------------------------------------------
60
+
61
+
62
+ def _kubectl(
63
+ *args: str,
64
+ context: str | None = None,
65
+ namespace: str | None = None,
66
+ ) -> list[str]:
67
+ """Build a kubectl command, injecting ``--context``/``--namespace`` when set."""
68
+ cmd = ["kubectl"]
69
+ if context is not None:
70
+ cmd.append(f"--context={context}")
71
+ if namespace is not None:
72
+ cmd.append(f"--namespace={namespace}")
73
+ cmd.extend(args)
74
+ return cmd
75
+
76
+
77
+ def _run(
78
+ cmd: list[str],
79
+ *,
80
+ check: bool = True,
81
+ capture: bool = False,
82
+ stream: bool = False,
83
+ inherit_stdin: bool = False,
84
+ ) -> subprocess.CompletedProcess[str]:
85
+ """Run a subprocess command with consistent defaults.
86
+
87
+ ``inherit_stdin=True`` keeps the parent's stdin attached to the
88
+ child instead of redirecting to ``/dev/null``. Required when the
89
+ command is ``kubectl exec -i -t …`` — kubectl needs a real stdin
90
+ to negotiate the TTY allocation; ``DEVNULL`` would trip the
91
+ ``Unable to use a TTY - input is not a terminal`` error.
92
+ """
93
+ if capture:
94
+ return subprocess.run( # noqa: S603
95
+ cmd, check=check, text=True, capture_output=True
96
+ )
97
+ if stream:
98
+ return subprocess.run( # noqa: S603
99
+ cmd,
100
+ check=check,
101
+ text=True,
102
+ stdin=None if inherit_stdin else subprocess.DEVNULL,
103
+ stdout=sys.stdout,
104
+ stderr=sys.stderr,
105
+ )
106
+ return subprocess.run( # noqa: S603
107
+ cmd,
108
+ check=check,
109
+ text=True,
110
+ stdin=None if inherit_stdin else subprocess.DEVNULL,
111
+ )
112
+
113
+
114
+ # ---------- image resolution ------------------------------------------------
115
+
116
+
117
+ def _extract_base_image(dockerfile: Path) -> str:
118
+ """Extract the production base image from the Dockerfile (``FROM <image> AS prod``).
119
+
120
+ Falls back to the first ``FROM`` directive when no ``AS prod`` is found —
121
+ matches the common single-stage layout.
122
+ """
123
+ text = dockerfile.read_text()
124
+ prod_pattern = re.compile(r"^FROM\s+(\S+).*\bAS\s+prod", re.IGNORECASE)
125
+ for line in text.splitlines():
126
+ m = prod_pattern.match(line.strip())
127
+ if m:
128
+ return m.group(1)
129
+ first_pattern = re.compile(r"^FROM\s+(\S+)", re.IGNORECASE)
130
+ for line in text.splitlines():
131
+ m = first_pattern.match(line.strip())
132
+ if m:
133
+ return m.group(1)
134
+ msg = f"Could not find any 'FROM ...' directive in {dockerfile}"
135
+ raise RuntimeError(msg)
136
+
137
+
138
+ # ---------- pod lifecycle ---------------------------------------------------
139
+
140
+
141
+ def _generate_pod_name(prefix: str) -> str:
142
+ short_id = uuid.uuid4().hex[:8]
143
+ return f"{prefix}-{short_id}"
144
+
145
+
146
+ def _create_pod(
147
+ pod_name: str,
148
+ image: str,
149
+ *,
150
+ workdir: str,
151
+ namespace: str | None = None,
152
+ context: str | None = None,
153
+ ) -> None:
154
+ """Create an ephemeral pod with ``sleep infinity`` and wait for Ready.
155
+
156
+ The ``cluster-autoscaler.kubernetes.io/safe-to-evict: "false"`` annotation
157
+ prevents the autoscaler from evicting the pod mid-run — restarting on a
158
+ different node would lose any in-flight state (source uploads, dump streams,
159
+ etc.) and force the operator to rerun from scratch.
160
+ """
161
+ overrides: dict[str, object] = {
162
+ "apiVersion": "v1",
163
+ "metadata": {
164
+ "annotations": {
165
+ "cluster-autoscaler.kubernetes.io/safe-to-evict": "false",
166
+ },
167
+ },
168
+ "spec": {
169
+ "containers": [
170
+ {
171
+ "name": "worker",
172
+ "image": image,
173
+ "command": ["sleep", "infinity"],
174
+ "workingDir": workdir,
175
+ }
176
+ ],
177
+ "restartPolicy": "Never",
178
+ },
179
+ }
180
+
181
+ _run(
182
+ _kubectl(
183
+ "run",
184
+ pod_name,
185
+ f"--image={image}",
186
+ "--restart=Never",
187
+ f"--overrides={json.dumps(overrides)}",
188
+ context=context,
189
+ namespace=namespace,
190
+ ),
191
+ )
192
+ print(f" Pod {pod_name} created, waiting for Ready...")
193
+ _run(
194
+ _kubectl(
195
+ "wait",
196
+ f"pod/{pod_name}",
197
+ "--for=condition=Ready",
198
+ "--timeout=120s",
199
+ context=context,
200
+ namespace=namespace,
201
+ ),
202
+ )
203
+ print(f" Pod {pod_name} is Ready.")
204
+
205
+
206
+ def _copy_to_pod(
207
+ pod_name: str,
208
+ paths: list[str],
209
+ *,
210
+ project_root: Path,
211
+ workdir: str,
212
+ namespace: str | None = None,
213
+ context: str | None = None,
214
+ ) -> None:
215
+ """Copy local paths into the pod via tar + ``kubectl cp``."""
216
+ exclude_args: list[str] = []
217
+ for pat in _EXCLUDE_PATTERNS:
218
+ exclude_args.extend(["--exclude", pat])
219
+
220
+ paths_to_copy = [p for p in paths if (project_root / p).exists()]
221
+ if not paths_to_copy:
222
+ return
223
+
224
+ with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=True) as tmp:
225
+ _run(
226
+ [
227
+ "tar",
228
+ "czf",
229
+ tmp.name,
230
+ *exclude_args,
231
+ "-C",
232
+ str(project_root),
233
+ *paths_to_copy,
234
+ ],
235
+ )
236
+ _run(
237
+ _kubectl(
238
+ "cp",
239
+ tmp.name,
240
+ f"{pod_name}:{workdir}/_sources.tar.gz",
241
+ "-c",
242
+ "worker",
243
+ context=context,
244
+ namespace=namespace,
245
+ ),
246
+ )
247
+
248
+ _run(
249
+ _kubectl(
250
+ "exec",
251
+ pod_name,
252
+ "-c",
253
+ "worker",
254
+ "--",
255
+ "tar",
256
+ "xzf",
257
+ f"{workdir}/_sources.tar.gz",
258
+ "-C",
259
+ workdir,
260
+ context=context,
261
+ namespace=namespace,
262
+ ),
263
+ )
264
+ _run(
265
+ _kubectl(
266
+ "exec",
267
+ pod_name,
268
+ "-c",
269
+ "worker",
270
+ "--",
271
+ "rm",
272
+ f"{workdir}/_sources.tar.gz",
273
+ context=context,
274
+ namespace=namespace,
275
+ ),
276
+ )
277
+
278
+
279
+ def _install_dependencies(
280
+ pod_name: str,
281
+ install_script: str,
282
+ *,
283
+ namespace: str | None = None,
284
+ context: str | None = None,
285
+ ) -> None:
286
+ """Run the project's bootstrap script inside the pod."""
287
+ print(" Installing dependencies in pod...")
288
+ _run(
289
+ _kubectl(
290
+ "exec",
291
+ pod_name,
292
+ "-c",
293
+ "worker",
294
+ "--",
295
+ "bash",
296
+ "-c",
297
+ install_script,
298
+ context=context,
299
+ namespace=namespace,
300
+ ),
301
+ stream=True,
302
+ )
303
+ print(" Dependencies installed.")
304
+
305
+
306
+ def _exec_command(
307
+ pod_name: str,
308
+ command: list[str],
309
+ *,
310
+ stream: bool = True,
311
+ namespace: str | None = None,
312
+ context: str | None = None,
313
+ tty: bool = False,
314
+ ) -> subprocess.CompletedProcess[str]:
315
+ """Execute a command inside the pod (blocking).
316
+
317
+ ``tty=True`` allocates a pseudo-TTY in the pod (``kubectl exec -i -t``)
318
+ so the command sees an interactive terminal — required for live progress
319
+ bars and color output. Callers must resolve auto-detection upstream; when
320
+ ``tty=True`` is passed with a non-TTY local stdin, kubectl fails with
321
+ ``Unable to use a TTY`` (that surface is intentional).
322
+ """
323
+ tty_flags = ["-i", "-t"] if tty else []
324
+ return _run(
325
+ _kubectl(
326
+ "exec",
327
+ pod_name,
328
+ *tty_flags,
329
+ "-c",
330
+ "worker",
331
+ "--",
332
+ *command,
333
+ context=context,
334
+ namespace=namespace,
335
+ ),
336
+ stream=stream,
337
+ check=False,
338
+ inherit_stdin=tty,
339
+ )
340
+
341
+
342
+ def _delete_pod(
343
+ pod_name: str,
344
+ *,
345
+ namespace: str | None = None,
346
+ context: str | None = None,
347
+ ) -> None:
348
+ """Delete the ephemeral pod."""
349
+ print(f"\n Cleaning up pod {pod_name}...")
350
+ _run(
351
+ _kubectl(
352
+ "delete",
353
+ "pod",
354
+ pod_name,
355
+ "--grace-period=5",
356
+ "--wait=false",
357
+ context=context,
358
+ namespace=namespace,
359
+ ),
360
+ check=False,
361
+ )
362
+ print(f" Pod {pod_name} deleted.")
363
+
364
+
365
+ # ---------- remote command + redaction --------------------------------------
366
+
367
+
368
+ def _build_remote_command(
369
+ cli_module: str,
370
+ cli_subcommand: str | None,
371
+ args: list[str],
372
+ workdir: str,
373
+ env_overrides: dict[str, str] | None = None,
374
+ ) -> list[str]:
375
+ """Build the ``env … python -m <module> [subcommand] <args>`` command.
376
+
377
+ ``PYTHONPATH=<workdir>`` makes the copied project package importable
378
+ in ``--k8s-from-local-sources`` mode (already on ``sys.path`` in the
379
+ deployed image where the wheel sits in site-packages, but the explicit
380
+ prefix is inoffensive there).
381
+ """
382
+ cmd = [
383
+ "env",
384
+ f"PYTHONPATH={workdir}",
385
+ "PYTHONUNBUFFERED=1",
386
+ ]
387
+ if env_overrides:
388
+ cmd.extend(f"{key}={value}" for key, value in env_overrides.items())
389
+ cmd.extend(["python", "-m", cli_module])
390
+ if cli_subcommand is not None:
391
+ cmd.append(cli_subcommand)
392
+ cmd.extend(args)
393
+ return cmd
394
+
395
+
396
+ def _redact_command(
397
+ cmd: list[str],
398
+ redacted_options: tuple[str, ...],
399
+ redacted_env_keys: frozenset[str] = frozenset(),
400
+ ) -> list[str]:
401
+ """Redact sensitive values in a printable form of the command.
402
+
403
+ Two sources of sensitivity are masked:
404
+
405
+ - Values right after a known sensitive option
406
+ (``--public-api-key X`` → ``--public-api-key ***``)
407
+ or as ``--public-api-key=X``.
408
+ - Env-var assignments whose key is in ``redacted_env_keys``
409
+ (``MONGO_URI=mongodb+srv://...`` → ``MONGO_URI=***``).
410
+ """
411
+ redacted: list[str] = []
412
+ skip_next = False
413
+ for arg in cmd:
414
+ if skip_next:
415
+ redacted.append("***")
416
+ skip_next = False
417
+ continue
418
+ if arg in redacted_options:
419
+ redacted.append(arg)
420
+ skip_next = True
421
+ continue
422
+ if any(arg.startswith(f"{opt}=") for opt in redacted_options):
423
+ prefix = arg.split("=", 1)[0]
424
+ redacted.append(f"{prefix}=***")
425
+ continue
426
+ if "=" in arg and arg.split("=", 1)[0] in redacted_env_keys:
427
+ prefix = arg.split("=", 1)[0]
428
+ redacted.append(f"{prefix}=***")
429
+ continue
430
+ redacted.append(arg)
431
+ return redacted
432
+
433
+
434
+ # ---------- public dispatch -------------------------------------------------
435
+
436
+
437
+ def run_on_k8s(
438
+ cli_args: list[str],
439
+ *,
440
+ config: K8sConfig,
441
+ cli_module: str,
442
+ cli_subcommand: str | None = None,
443
+ environment: str,
444
+ namespace: str | None = None,
445
+ context: str | None = None,
446
+ pod_name_prefix: str | None = None,
447
+ from_local_sources: bool = False,
448
+ env_overrides: dict[str, str] | None = None,
449
+ tty: bool | None = None,
450
+ ) -> NoReturn:
451
+ """Run ``python -m <cli_module> [<cli_subcommand>] <cli_args>`` in an ephemeral pod.
452
+
453
+ ``cli_args`` are forwarded verbatim (the caller is responsible for
454
+ removing any k8s-only flags, see :func:`_filter_k8s_argv`).
455
+
456
+ ``tty``: ``None`` (default) inherits from the local stdin —
457
+ interactive shell → TTY allocated in the pod, CI/pipe → not.
458
+
459
+ Exits the current process with the remote command's return code —
460
+ this function never returns.
461
+ """
462
+ resolved_tty = tty if tty is not None else sys.stdin.isatty()
463
+ if from_local_sources:
464
+ image = _extract_base_image(config.resolved_dockerfile)
465
+ else:
466
+ image = config.default_image
467
+ resolved_pod_prefix = pod_name_prefix or config.default_pod_name_prefix
468
+ pod_name = _generate_pod_name(resolved_pod_prefix)
469
+
470
+ print(f"[K8S] Using image: {image}")
471
+ if context:
472
+ print(f"[K8S] Using context: {context}")
473
+ if namespace:
474
+ print(f"[K8S] Using namespace: {namespace}")
475
+ print(f"[K8S] Creating ephemeral pod: {pod_name}")
476
+
477
+ def _cleanup() -> None:
478
+ _delete_pod(pod_name, namespace=namespace, context=context)
479
+
480
+ atexit.register(_cleanup)
481
+
482
+ def _signal_handler(signum: int, frame: object) -> None:
483
+ _cleanup()
484
+ raise SystemExit(130)
485
+
486
+ signal.signal(signal.SIGINT, _signal_handler)
487
+
488
+ try:
489
+ _create_pod(
490
+ pod_name,
491
+ image,
492
+ workdir=config.workdir,
493
+ namespace=namespace,
494
+ context=context,
495
+ )
496
+
497
+ if from_local_sources:
498
+ if not config.copy_paths:
499
+ msg = (
500
+ "--k8s-from-local-sources requires K8sConfig.copy_paths to be set."
501
+ )
502
+ raise RuntimeError(msg)
503
+ print(" Copying sources into pod...")
504
+ _copy_to_pod(
505
+ pod_name,
506
+ list(config.copy_paths),
507
+ project_root=config.project_root,
508
+ workdir=config.workdir,
509
+ namespace=namespace,
510
+ context=context,
511
+ )
512
+ print(" Sources copied.")
513
+ if config.install_script:
514
+ _install_dependencies(
515
+ pod_name,
516
+ config.install_script,
517
+ namespace=namespace,
518
+ context=context,
519
+ )
520
+
521
+ remote_cmd = _build_remote_command(
522
+ cli_module,
523
+ cli_subcommand,
524
+ cli_args,
525
+ workdir=config.workdir,
526
+ env_overrides=env_overrides,
527
+ )
528
+ redacted_env_keys = frozenset(env_overrides) if env_overrides else frozenset()
529
+ print(
530
+ f"[K8S] Running: "
531
+ f"{' '.join(_redact_command(remote_cmd, config.redacted_options, redacted_env_keys))}"
532
+ )
533
+
534
+ result = _exec_command(
535
+ pod_name,
536
+ remote_cmd,
537
+ namespace=namespace,
538
+ context=context,
539
+ tty=resolved_tty,
540
+ )
541
+ result_rc = result.returncode
542
+
543
+ finally:
544
+ _delete_pod(pod_name, namespace=namespace, context=context)
545
+ atexit.unregister(_cleanup)
546
+
547
+ raise SystemExit(result_rc)
548
+
549
+
550
+ # ---------- @k8s_support decorator ------------------------------------------
551
+
552
+
553
+ F = TypeVar("F", bound=Callable[..., Any])
554
+
555
+ # Sentinel used to distinguish "auto-detect the subcommand from the
556
+ # enclosing Typer app at runtime" from the explicit ``None`` ("force
557
+ # no subcommand on the remote call") and from a string ("force this
558
+ # subcommand name").
559
+ _AUTO_SUBCOMMAND: Any = object()
560
+
561
+
562
+ def _detect_subcommand_for(wrapper: Callable[..., Any], module_name: str) -> str | None:
563
+ """Return the Typer subcommand name to forward to the pod.
564
+
565
+ Walks every ``typer.Typer`` instance defined in ``module_name`` and
566
+ locates the registered command whose underlying callback is the
567
+ ``wrapper`` produced by :func:`k8s_support`. Returns ``None`` for
568
+ a single-command app (Typer auto-executes it remotely too), the
569
+ Typer command name otherwise, with a function-name fallback when
570
+ no enclosing app is found.
571
+ """
572
+ import typer
573
+
574
+ fallback = wrapper.__name__.replace("_", "-")
575
+ module = sys.modules.get(module_name)
576
+ if module is None:
577
+ return fallback
578
+ typer_apps = [obj for obj in vars(module).values() if isinstance(obj, typer.Typer)]
579
+ for app in typer_apps:
580
+ for cmd in app.registered_commands:
581
+ if cmd.callback is wrapper:
582
+ if len(app.registered_commands) == 1:
583
+ return None
584
+ return cmd.name or fallback
585
+ return fallback
586
+
587
+
588
+ def _filter_k8s_argv(argv: list[str]) -> list[str]:
589
+ """Strip ``--k8s``, ``--k8s-environment <v>``, ``--k8s-from-local-sources`` from ``argv``.
590
+
591
+ Also handles the ``--flag=value`` variants. Used to forward the
592
+ original CLI args to the pod without the k8s-specific options the
593
+ pod would not understand.
594
+ """
595
+ out: list[str] = []
596
+ i = 0
597
+ while i < len(argv):
598
+ a = argv[i]
599
+ if a in ("--k8s", "--k8s-from-local-sources"):
600
+ i += 1
601
+ elif a == "--k8s-environment":
602
+ i += 2
603
+ elif (
604
+ a.startswith("--k8s=")
605
+ or a.startswith("--k8s-from-local-sources=")
606
+ or a.startswith("--k8s-environment=")
607
+ ):
608
+ i += 1
609
+ else:
610
+ out.append(a)
611
+ i += 1
612
+ return out
613
+
614
+
615
+ def k8s_support(
616
+ *,
617
+ config: K8sConfig,
618
+ cli_module: str | None = None,
619
+ cli_subcommand: Any = _AUTO_SUBCOMMAND,
620
+ pod_name_prefix: str | None = None,
621
+ tty: bool | None = None,
622
+ ) -> Callable[[F], F]:
623
+ """Inject k8s flags into a Typer command and dispatch when ``--k8s`` is set.
624
+
625
+ Applied to a function used as a Typer command body, this decorator
626
+ rewrites the function's signature to append three keyword-only
627
+ parameters:
628
+
629
+ - ``--k8s`` (bool, default False) — run on Kubernetes instead of locally.
630
+ - ``--k8s-environment`` (one of ``config.environments``) — target env.
631
+ - ``--k8s-from-local-sources`` (bool, default False) — copy local
632
+ sources and reinstall deps instead of using the deployed image
633
+ (slower, useful when developing).
634
+
635
+ Typer reads ``__signature__`` to generate ``--help``, so the three
636
+ flags appear under the command's own help just like manually
637
+ declared options. At runtime, if ``--k8s`` is set, the rest of
638
+ ``sys.argv`` (with the k8s flags filtered out) is forwarded to
639
+ :func:`run_on_k8s` and the original function body is **not**
640
+ executed locally.
641
+
642
+ Pre-binding ``config`` once
643
+ --------------------------
644
+ When most commands in a project share the same config, use
645
+ :func:`build_k8s_support` to obtain a decorator with ``config``
646
+ already bound — the per-command application becomes
647
+ ``@k8s_support()`` with no boilerplate.
648
+
649
+ Parameters
650
+ ----------
651
+ config:
652
+ Project configuration. Drives image, source layout, secret
653
+ forwarding, redaction, and the set of supported environments.
654
+ cli_module:
655
+ Dotted path of the module to run inside the pod. Defaults to
656
+ ``f.__module__`` (with the ``__main__`` fallback).
657
+ cli_subcommand:
658
+ Typer subcommand name. Auto-detected at runtime from the
659
+ enclosing Typer app by default.
660
+ pod_name_prefix:
661
+ Override ``config.default_pod_name_prefix`` for this command.
662
+ Helps disambiguate pods in ``kubectl get pods`` when several
663
+ commands run concurrently.
664
+ tty:
665
+ ``None`` (default) inherits from the local stdin. Set
666
+ ``True`` / ``False`` to force the behaviour.
667
+ """
668
+ environments = config.environments
669
+ default_environment = config.default_environment
670
+
671
+ def decorator(f: F) -> F:
672
+ resolved_module = cli_module if cli_module is not None else f.__module__
673
+ # ``python -m foo.bar`` runs the module as ``__main__`` so
674
+ # ``f.__module__`` reads ``"__main__"`` — but the real dotted
675
+ # path lives in ``sys.modules["__main__"].__spec__.name``,
676
+ # populated by the runtime before the module body executes.
677
+ if resolved_module == "__main__":
678
+ main_mod = sys.modules.get("__main__")
679
+ spec = getattr(main_mod, "__spec__", None)
680
+ if spec is not None and spec.name:
681
+ resolved_module = spec.name
682
+ else:
683
+ msg = (
684
+ f"@k8s_support cannot auto-detect cli_module for {f.__qualname__} "
685
+ "(no __spec__.name on __main__ — script run directly via "
686
+ "``python script.py`` rather than ``python -m <module>``). "
687
+ "Pass cli_module=<dotted.path> explicitly."
688
+ )
689
+ raise RuntimeError(msg)
690
+ original_sig = inspect.signature(f)
691
+
692
+ k8s_param = inspect.Parameter(
693
+ "k8s",
694
+ kind=inspect.Parameter.KEYWORD_ONLY,
695
+ default=False,
696
+ annotation=Annotated[
697
+ bool,
698
+ Option(
699
+ "--k8s",
700
+ help=(
701
+ "Run the command in an ephemeral pod on the Kubernetes cluster instead of locally."
702
+ ),
703
+ ),
704
+ ],
705
+ )
706
+ k8s_env_param = inspect.Parameter(
707
+ "k8s_environment",
708
+ kind=inspect.Parameter.KEYWORD_ONLY,
709
+ default=default_environment,
710
+ annotation=Annotated[
711
+ environments,
712
+ Option(
713
+ "--k8s-environment",
714
+ case_sensitive=False,
715
+ help="Target Kubernetes environment when --k8s is set.",
716
+ ),
717
+ ],
718
+ )
719
+ k8s_local_param = inspect.Parameter(
720
+ "k8s_from_local_sources",
721
+ kind=inspect.Parameter.KEYWORD_ONLY,
722
+ default=False,
723
+ annotation=Annotated[
724
+ bool,
725
+ Option(
726
+ "--k8s-from-local-sources",
727
+ help=(
728
+ "When --k8s is set, copy local sources and "
729
+ "install deps in the pod instead of using the "
730
+ "deployed image (slower, useful when developing)."
731
+ ),
732
+ ),
733
+ ],
734
+ )
735
+ new_params = list(original_sig.parameters.values()) + [
736
+ k8s_param,
737
+ k8s_env_param,
738
+ k8s_local_param,
739
+ ]
740
+ new_sig = original_sig.replace(parameters=new_params)
741
+
742
+ @functools.wraps(f)
743
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
744
+ k8s = kwargs.pop("k8s", False)
745
+ k8s_environment: enum.Enum = kwargs.pop(
746
+ "k8s_environment", default_environment
747
+ )
748
+ k8s_from_local_sources = kwargs.pop("k8s_from_local_sources", False)
749
+ if not k8s:
750
+ return f(*args, **kwargs)
751
+
752
+ if cli_subcommand is _AUTO_SUBCOMMAND:
753
+ resolved_subcommand = _detect_subcommand_for(wrapper, f.__module__)
754
+ else:
755
+ resolved_subcommand = cli_subcommand
756
+
757
+ env_value: str = k8s_environment.value
758
+ env_overrides = _collect_forwarded_envvars(config)
759
+ run_on_k8s(
760
+ _filter_k8s_argv(sys.argv[1:]),
761
+ config=config,
762
+ cli_module=resolved_module,
763
+ cli_subcommand=resolved_subcommand,
764
+ environment=env_value,
765
+ namespace=env_value,
766
+ context=env_value,
767
+ pod_name_prefix=pod_name_prefix,
768
+ from_local_sources=k8s_from_local_sources,
769
+ env_overrides=env_overrides,
770
+ tty=tty,
771
+ )
772
+
773
+ wrapper.__signature__ = new_sig # type: ignore[attr-defined]
774
+ return wrapper # type: ignore[return-value]
775
+
776
+ return decorator
777
+
778
+
779
+ def build_k8s_support(config: K8sConfig) -> Callable[..., Callable[[F], F]]:
780
+ """Return a :func:`k8s_support` decorator pre-bound to ``config``.
781
+
782
+ Typical setup at the top of a project's ``cli.py``::
783
+
784
+ from pysae_cli_tools.k8s import K8sConfig, build_k8s_support
785
+
786
+ K8S_CONFIG = K8sConfig(
787
+ default_image="...:latest",
788
+ project_root=Path(__file__).resolve().parents[1],
789
+ copy_paths=("src", "pyproject.toml", "poetry.lock"),
790
+ forwarded_envvars=("MY_API_KEY",),
791
+ redacted_options=("--api-key",),
792
+ )
793
+
794
+ k8s_support = build_k8s_support(K8S_CONFIG)
795
+
796
+ @app.command()
797
+ @k8s_support()
798
+ def my_command(...): ...
799
+
800
+ @app.command()
801
+ @k8s_support(pod_name_prefix="my-command-2") # override per-command
802
+ def my_other_command(...): ...
803
+ """
804
+
805
+ def k8s_support_bound(
806
+ *,
807
+ cli_module: str | None = None,
808
+ cli_subcommand: Any = _AUTO_SUBCOMMAND,
809
+ pod_name_prefix: str | None = None,
810
+ tty: bool | None = None,
811
+ ) -> Callable[[F], F]:
812
+ return k8s_support(
813
+ config=config,
814
+ cli_module=cli_module,
815
+ cli_subcommand=cli_subcommand,
816
+ pod_name_prefix=pod_name_prefix,
817
+ tty=tty,
818
+ )
819
+
820
+ return k8s_support_bound