p4n4-lib 0.2.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.
- p4n4_lib-0.2.0/.gitattributes +21 -0
- p4n4_lib-0.2.0/.github/workflows/ci.yml +35 -0
- p4n4_lib-0.2.0/.github/workflows/publish.yml +37 -0
- p4n4_lib-0.2.0/.gitignore +44 -0
- p4n4_lib-0.2.0/CHANGELOG.md +59 -0
- p4n4_lib-0.2.0/LICENSE +21 -0
- p4n4_lib-0.2.0/PKG-INFO +70 -0
- p4n4_lib-0.2.0/README.md +43 -0
- p4n4_lib-0.2.0/p4n4_lib/__init__.py +3 -0
- p4n4_lib-0.2.0/p4n4_lib/compose.py +167 -0
- p4n4_lib-0.2.0/p4n4_lib/env.py +96 -0
- p4n4_lib-0.2.0/p4n4_lib/layers.py +114 -0
- p4n4_lib-0.2.0/p4n4_lib/layout.py +55 -0
- p4n4_lib-0.2.0/p4n4_lib/manifest.py +93 -0
- p4n4_lib-0.2.0/p4n4_lib/scaffold.py +91 -0
- p4n4_lib-0.2.0/p4n4_lib/secrets.py +35 -0
- p4n4_lib-0.2.0/p4n4_lib/sources.yaml +4 -0
- p4n4_lib-0.2.0/p4n4_lib/validate.py +105 -0
- p4n4_lib-0.2.0/pyproject.toml +51 -0
- p4n4_lib-0.2.0/tests/__init__.py +0 -0
- p4n4_lib-0.2.0/tests/test_compose.py +105 -0
- p4n4_lib-0.2.0/tests/test_lib.py +451 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Normalize line endings to LF in the repository and working tree.
|
|
2
|
+
* text=auto eol=lf
|
|
3
|
+
|
|
4
|
+
# Binary files: no line-ending conversion or text diffs.
|
|
5
|
+
*.png binary
|
|
6
|
+
*.jpg binary
|
|
7
|
+
*.jpeg binary
|
|
8
|
+
*.gif binary
|
|
9
|
+
*.ico binary
|
|
10
|
+
*.webp binary
|
|
11
|
+
*.mp4 binary
|
|
12
|
+
*.webm binary
|
|
13
|
+
*.pdf binary
|
|
14
|
+
*.woff binary
|
|
15
|
+
*.woff2 binary
|
|
16
|
+
*.ttf binary
|
|
17
|
+
*.otf binary
|
|
18
|
+
*.zip binary
|
|
19
|
+
*.gz binary
|
|
20
|
+
*.onnx binary
|
|
21
|
+
*.eim binary
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
lint:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
cache: pip
|
|
18
|
+
- run: pip install ruff
|
|
19
|
+
- run: ruff check .
|
|
20
|
+
- run: ruff format --check .
|
|
21
|
+
|
|
22
|
+
test:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
strategy:
|
|
25
|
+
fail-fast: false
|
|
26
|
+
matrix:
|
|
27
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
- uses: actions/setup-python@v5
|
|
31
|
+
with:
|
|
32
|
+
python-version: ${{ matrix.python-version }}
|
|
33
|
+
cache: pip
|
|
34
|
+
- run: pip install -e ".[dev]"
|
|
35
|
+
- run: pytest --cov=p4n4_lib --cov-report=term-missing
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
cache: pip
|
|
19
|
+
- run: pip install build
|
|
20
|
+
- run: python -m build
|
|
21
|
+
- uses: actions/upload-artifact@v4
|
|
22
|
+
with:
|
|
23
|
+
name: dist
|
|
24
|
+
path: dist/
|
|
25
|
+
|
|
26
|
+
publish:
|
|
27
|
+
needs: build
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
environment: pypi
|
|
30
|
+
permissions:
|
|
31
|
+
id-token: write
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/download-artifact@v4
|
|
34
|
+
with:
|
|
35
|
+
name: dist
|
|
36
|
+
path: dist/
|
|
37
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
*.so
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
wheels/
|
|
13
|
+
|
|
14
|
+
# Virtual environments
|
|
15
|
+
.venv/
|
|
16
|
+
venv/
|
|
17
|
+
env/
|
|
18
|
+
|
|
19
|
+
# Testing
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
.coverage
|
|
22
|
+
coverage.xml
|
|
23
|
+
htmlcov/
|
|
24
|
+
|
|
25
|
+
# Ruff / linters
|
|
26
|
+
.ruff_cache/
|
|
27
|
+
|
|
28
|
+
# Distribution / packaging
|
|
29
|
+
*.whl
|
|
30
|
+
*.tar.gz
|
|
31
|
+
MANIFEST
|
|
32
|
+
|
|
33
|
+
# Environment files
|
|
34
|
+
.env
|
|
35
|
+
.env.*
|
|
36
|
+
!.env.example
|
|
37
|
+
|
|
38
|
+
# Editor / OS
|
|
39
|
+
.DS_Store
|
|
40
|
+
Thumbs.db
|
|
41
|
+
.idea/
|
|
42
|
+
.vscode/
|
|
43
|
+
*.swp
|
|
44
|
+
*.swo
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `p4n4-lib` are documented here.
|
|
4
|
+
|
|
5
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [0.2.0] - 2026-10-03
|
|
13
|
+
|
|
14
|
+
First release on PyPI. There is no 0.1.0: the package was versioned 0.1.0 while it lived
|
|
15
|
+
only on GitHub, and the first published version is 0.2.0, released together with
|
|
16
|
+
`p4n4` (CLI) 0.2.0.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Shared library extracted from `p4n4-cli`, used by `p4n4-cli` and `p4n4-api`:
|
|
21
|
+
- `manifest`: find (walk-up), load, save and create `.p4n4.json`
|
|
22
|
+
- `env`: dotenv read/write that keeps the `.env.example` template's comments and layout
|
|
23
|
+
- `layers`: layer registry (repo URLs, copy paths, required files and env keys)
|
|
24
|
+
- `layout`: flat single-layer projects, per-layer subdirectories for multi-layer projects
|
|
25
|
+
- `scaffold`: fetch stack sources (local path or shallow clone) and copy them into a project
|
|
26
|
+
- `validate`: pure project checks returning `(passed, errors)`
|
|
27
|
+
- `secrets`: token generation and the rotatable-key policy
|
|
28
|
+
- `compose`: Docker Compose wrappers (`up`, `down`, `ps`, `logs`)
|
|
29
|
+
- `edge` layer: compose file, runner, model directories and `.env`.
|
|
30
|
+
- `dashboard` layer (p4n4-dashboard web service), and checks for the optional `.p4n4.json`
|
|
31
|
+
`dashboard` block (`grafana_path`, `tabs`, `theme`, and a `brand.json` in a named theme).
|
|
32
|
+
- Template projects (a `template` block in `.p4n4.json`) are validated against the
|
|
33
|
+
template's own `.env.example` instead of the base stack's file list.
|
|
34
|
+
- `layout.compose_project_name()`: a Compose project name per layer (`<project>` or
|
|
35
|
+
`<project>-<layer>`, sanitized), and `scaffold_layer(..., compose_project_name=...)`, which writes
|
|
36
|
+
it to the layer's `.env` as `COMPOSE_PROJECT_NAME`. Without it, every multi-layer project on a
|
|
37
|
+
host shared the `iot`, `ai`, … Compose projects and so their volumes.
|
|
38
|
+
- `env.set_value()`: set one key in an existing `.env` (replace, or append with a comment).
|
|
39
|
+
- `MQTT_REMOTE_PASSWORD` is an external secret: shown fully masked, never rotated.
|
|
40
|
+
- The IoT layer requires `NODE_RED_USER` and `NODE_RED_PASSWORD`; `NODE_RED_PASSWORD` is
|
|
41
|
+
rotatable. Node-RED flows are copied from `config/node-red/flows/`.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- `compose` falls back to the standalone `docker-compose` (v1) when the Docker Compose v2
|
|
46
|
+
plugin is missing.
|
|
47
|
+
- `compose.down` enables every profile (`--profile "*"`), so services started by name, or
|
|
48
|
+
dropped from `COMPOSE_PROFILES` since, are stopped too. Not available on v1.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- `.env` values containing `$`, `#`, spaces or quotes are quoted, so Docker Compose reads
|
|
53
|
+
them literally.
|
|
54
|
+
- Windows: stacks are cloned with `core.autocrlf=false`, so scaffolded shell scripts keep LF
|
|
55
|
+
line endings and run inside the Linux containers. `.env` and `.p4n4.json` are read and
|
|
56
|
+
written as UTF-8 with LF line endings instead of the platform defaults.
|
|
57
|
+
|
|
58
|
+
[Unreleased]: https://github.com/raisga/p4n4-lib/compare/v0.2.0...HEAD
|
|
59
|
+
[0.2.0]: https://github.com/raisga/p4n4-lib/releases/tag/v0.2.0
|
p4n4_lib-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raisga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
p4n4_lib-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: p4n4-lib
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shared library between P4N4 stacks and clients
|
|
5
|
+
Project-URL: Homepage, https://github.com/raisga/p4n4-lib
|
|
6
|
+
Project-URL: Documentation, https://github.com/raisga/p4n4-docs
|
|
7
|
+
Project-URL: Issues, https://github.com/raisga/p4n4-lib/issues
|
|
8
|
+
Author-email: RAISGA <jraleman@raisga.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: docker,edge-ai,iot,library
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: pyyaml>=6
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# p4n4-lib
|
|
29
|
+
|
|
30
|
+
Shared Python library mediating between P4N4 stacks and clients
|
|
31
|
+
(`p4n4-cli`, `p4n4-api`, `p4n4-dashboard`).
|
|
32
|
+
|
|
33
|
+
Framework-free by design: no CLI/TUI/web dependencies, only `pyyaml`.
|
|
34
|
+
Presentation (prompts, tables, colors) stays in the clients.
|
|
35
|
+
|
|
36
|
+
## Modules
|
|
37
|
+
|
|
38
|
+
| Module | Purpose |
|
|
39
|
+
|--------|---------|
|
|
40
|
+
| `p4n4_lib.manifest` | `.p4n4.json` manifest: find (walk-up), load, save, create, and checks for the optional `dashboard` block |
|
|
41
|
+
| `p4n4_lib.env` | Dotenv read/write, template-preserving writes |
|
|
42
|
+
| `p4n4_lib.layers` | Layer registry: repo URLs, copy paths, required files/env keys per layer (`iot`, `ai`, `edge`, `dashboard`) |
|
|
43
|
+
| `p4n4_lib.layout` | Project layout: flat for single-layer projects, per-layer subdirectories for multi-layer |
|
|
44
|
+
| `p4n4_lib.scaffold` | Fetch stack sources (local path or shallow clone) and copy into a project |
|
|
45
|
+
| `p4n4_lib.validate` | Pure project validation returning (passed, errors) check lists; template projects are checked against their `.env.example` instead of the base stack |
|
|
46
|
+
| `p4n4_lib.secrets` | Token generation and the rotatable-key policy |
|
|
47
|
+
| `p4n4_lib.compose` | Docker Compose subprocess wrappers (`up`, `down`, `ps`, `logs`) |
|
|
48
|
+
|
|
49
|
+
Stack repo URLs live in `p4n4_lib/sources.yaml`.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install p4n4-lib
|
|
55
|
+
pip install -e path/to/core/lib # monorepo development
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
See [CHANGELOG.md](https://github.com/raisga/p4n4-lib/blob/main/CHANGELOG.md) for release notes.
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install -e ".[dev]"
|
|
64
|
+
pytest
|
|
65
|
+
ruff check .
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
p4n4_lib-0.2.0/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# p4n4-lib
|
|
2
|
+
|
|
3
|
+
Shared Python library mediating between P4N4 stacks and clients
|
|
4
|
+
(`p4n4-cli`, `p4n4-api`, `p4n4-dashboard`).
|
|
5
|
+
|
|
6
|
+
Framework-free by design: no CLI/TUI/web dependencies, only `pyyaml`.
|
|
7
|
+
Presentation (prompts, tables, colors) stays in the clients.
|
|
8
|
+
|
|
9
|
+
## Modules
|
|
10
|
+
|
|
11
|
+
| Module | Purpose |
|
|
12
|
+
|--------|---------|
|
|
13
|
+
| `p4n4_lib.manifest` | `.p4n4.json` manifest: find (walk-up), load, save, create, and checks for the optional `dashboard` block |
|
|
14
|
+
| `p4n4_lib.env` | Dotenv read/write, template-preserving writes |
|
|
15
|
+
| `p4n4_lib.layers` | Layer registry: repo URLs, copy paths, required files/env keys per layer (`iot`, `ai`, `edge`, `dashboard`) |
|
|
16
|
+
| `p4n4_lib.layout` | Project layout: flat for single-layer projects, per-layer subdirectories for multi-layer |
|
|
17
|
+
| `p4n4_lib.scaffold` | Fetch stack sources (local path or shallow clone) and copy into a project |
|
|
18
|
+
| `p4n4_lib.validate` | Pure project validation returning (passed, errors) check lists; template projects are checked against their `.env.example` instead of the base stack |
|
|
19
|
+
| `p4n4_lib.secrets` | Token generation and the rotatable-key policy |
|
|
20
|
+
| `p4n4_lib.compose` | Docker Compose subprocess wrappers (`up`, `down`, `ps`, `logs`) |
|
|
21
|
+
|
|
22
|
+
Stack repo URLs live in `p4n4_lib/sources.yaml`.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install p4n4-lib
|
|
28
|
+
pip install -e path/to/core/lib # monorepo development
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
See [CHANGELOG.md](https://github.com/raisga/p4n4-lib/blob/main/CHANGELOG.md) for release notes.
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e ".[dev]"
|
|
37
|
+
pytest
|
|
38
|
+
ruff check .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Docker Compose subprocess wrappers.
|
|
2
|
+
|
|
3
|
+
Prefers the Compose v2 plugin (``docker compose``) and falls back to the
|
|
4
|
+
standalone ``docker-compose`` binary. Hosts such as NVIDIA Jetson (JetPack
|
|
5
|
+
ships Ubuntu's ``docker.io`` without the plugin) often only have the latter;
|
|
6
|
+
there ``docker compose up -d`` fails with "unknown shorthand flag: 'd' in -d".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ComposeNotFoundError(RuntimeError):
|
|
19
|
+
"""Neither `docker compose` nor `docker-compose` is available."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@functools.cache
|
|
23
|
+
def compose_cmd() -> tuple[str, ...]:
|
|
24
|
+
"""Return the Compose invocation prefix for this host."""
|
|
25
|
+
if shutil.which("docker"):
|
|
26
|
+
probe = subprocess.run(
|
|
27
|
+
["docker", "compose", "version"],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
check=False,
|
|
30
|
+
)
|
|
31
|
+
if probe.returncode == 0:
|
|
32
|
+
return ("docker", "compose")
|
|
33
|
+
if shutil.which("docker-compose"):
|
|
34
|
+
return ("docker-compose",)
|
|
35
|
+
raise ComposeNotFoundError(
|
|
36
|
+
"Docker Compose not found. Install the Compose v2 plugin "
|
|
37
|
+
"(e.g. `sudo apt install docker-compose-plugin`) or the standalone "
|
|
38
|
+
"`docker-compose` binary."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_legacy() -> bool:
|
|
43
|
+
return compose_cmd() == ("docker-compose",)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _base(args: list[str], cwd: Path) -> int:
|
|
47
|
+
result = subprocess.run([*compose_cmd(), *args], cwd=cwd, check=False)
|
|
48
|
+
return result.returncode
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def up(cwd: Path, build: bool = False, pull: bool = False, detach: bool = True) -> int:
|
|
52
|
+
args = ["up"]
|
|
53
|
+
if detach:
|
|
54
|
+
args.append("-d")
|
|
55
|
+
if build:
|
|
56
|
+
args.append("--build")
|
|
57
|
+
if pull:
|
|
58
|
+
if _is_legacy():
|
|
59
|
+
# docker-compose v1 has no `up --pull`; pull explicitly first
|
|
60
|
+
rc = _base(["pull"], cwd)
|
|
61
|
+
if rc != 0:
|
|
62
|
+
return rc
|
|
63
|
+
else:
|
|
64
|
+
args.append("--pull=always")
|
|
65
|
+
return _base(args, cwd)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def down(cwd: Path, volumes: bool = False) -> int:
|
|
69
|
+
# Stack services sit in Compose profiles, and `down` only stops the ones
|
|
70
|
+
# in active profiles; enable them all so services started by name (or
|
|
71
|
+
# dropped from COMPOSE_PROFILES since) are stopped too. docker-compose v1
|
|
72
|
+
# has no "*" profile.
|
|
73
|
+
args = ["down"] if _is_legacy() else ["--profile", "*", "down"]
|
|
74
|
+
if volumes:
|
|
75
|
+
args.append("-v")
|
|
76
|
+
return _base(args, cwd)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ps(cwd: Path) -> list[dict]:
|
|
80
|
+
"""Return parsed service list from `docker compose ps --format json`."""
|
|
81
|
+
if _is_legacy():
|
|
82
|
+
return _ps_legacy(cwd)
|
|
83
|
+
result = subprocess.run(
|
|
84
|
+
[*compose_cmd(), "ps", "--format", "json"],
|
|
85
|
+
cwd=cwd,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
check=False,
|
|
89
|
+
)
|
|
90
|
+
services = []
|
|
91
|
+
for line in result.stdout.splitlines():
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if not line:
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
obj = json.loads(line)
|
|
97
|
+
if isinstance(obj, list):
|
|
98
|
+
services.extend(obj)
|
|
99
|
+
else:
|
|
100
|
+
services.append(obj)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
continue
|
|
103
|
+
return services
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ps_legacy(cwd: Path) -> list[dict]:
|
|
107
|
+
"""docker-compose v1 has no JSON output; build v2-shaped dicts via `docker inspect`."""
|
|
108
|
+
ids = subprocess.run(
|
|
109
|
+
["docker-compose", "ps", "-q"],
|
|
110
|
+
cwd=cwd,
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
check=False,
|
|
114
|
+
).stdout.split()
|
|
115
|
+
if not ids:
|
|
116
|
+
return []
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
["docker", "inspect", *ids],
|
|
119
|
+
capture_output=True,
|
|
120
|
+
text=True,
|
|
121
|
+
check=False,
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
containers = json.loads(result.stdout or "[]")
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
return []
|
|
127
|
+
services = []
|
|
128
|
+
for c in containers:
|
|
129
|
+
state = c.get("State") or {}
|
|
130
|
+
labels = (c.get("Config") or {}).get("Labels") or {}
|
|
131
|
+
publishers = []
|
|
132
|
+
for key, bindings in ((c.get("NetworkSettings") or {}).get("Ports") or {}).items():
|
|
133
|
+
target, _, proto = key.partition("/")
|
|
134
|
+
for b in bindings or []:
|
|
135
|
+
publishers.append(
|
|
136
|
+
{
|
|
137
|
+
"PublishedPort": int(b.get("HostPort") or 0),
|
|
138
|
+
"TargetPort": int(target),
|
|
139
|
+
"Protocol": proto or "tcp",
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
services.append(
|
|
143
|
+
{
|
|
144
|
+
"Name": c.get("Name", "").lstrip("/"),
|
|
145
|
+
"Service": labels.get("com.docker.compose.service", ""),
|
|
146
|
+
"State": state.get("Status", ""),
|
|
147
|
+
"Health": (state.get("Health") or {}).get("Status", ""),
|
|
148
|
+
"Publishers": publishers,
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
return services
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def logs(
|
|
155
|
+
cwd: Path,
|
|
156
|
+
service: str | None = None,
|
|
157
|
+
tail: int | None = None,
|
|
158
|
+
follow: bool = True,
|
|
159
|
+
) -> int:
|
|
160
|
+
args = ["logs"]
|
|
161
|
+
if follow:
|
|
162
|
+
args.append("-f")
|
|
163
|
+
if tail is not None:
|
|
164
|
+
args.extend(["--tail", str(tail)])
|
|
165
|
+
if service:
|
|
166
|
+
args.append(service)
|
|
167
|
+
return _base(args, cwd)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Dotenv read/write utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
ENV_FILE = ".env"
|
|
9
|
+
|
|
10
|
+
# Characters Compose would interpolate, strip or split on in an unquoted value
|
|
11
|
+
_NEEDS_QUOTES = set(" \t#$'\"\\")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def quote(value: str) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Quote a value for a Compose .env file when it needs it. Generated secrets
|
|
17
|
+
are plain hex and stay bare; user-supplied ones (an external broker
|
|
18
|
+
password) may hold anything. Single quotes are literal in Compose; a value
|
|
19
|
+
containing one is double-quoted, with backslash, double quote and $ escaped.
|
|
20
|
+
"""
|
|
21
|
+
if not _NEEDS_QUOTES.intersection(value):
|
|
22
|
+
return value
|
|
23
|
+
if "'" not in value:
|
|
24
|
+
return f"'{value}'"
|
|
25
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
|
|
26
|
+
return f'"{escaped}"'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unquote(value: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Reverse quote(): strip matching quotes and undo double-quote escapes. As
|
|
32
|
+
in Compose, an unquoted value ends at an inline " #" comment.
|
|
33
|
+
"""
|
|
34
|
+
if len(value) >= 2 and value[0] == value[-1] == "'":
|
|
35
|
+
return value[1:-1]
|
|
36
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
37
|
+
out, chars = [], iter(value[1:-1])
|
|
38
|
+
for ch in chars:
|
|
39
|
+
out.append(next(chars, "") if ch == "\\" else ch)
|
|
40
|
+
return "".join(out)
|
|
41
|
+
return re.split(r"\s#", value, maxsplit=1)[0].rstrip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load(path: Path) -> dict[str, str]:
|
|
45
|
+
"""Parse .env file into a dict, skipping comments and blank lines."""
|
|
46
|
+
env: dict[str, str] = {}
|
|
47
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
48
|
+
line = line.strip()
|
|
49
|
+
if not line or line.startswith("#"):
|
|
50
|
+
continue
|
|
51
|
+
if "=" in line:
|
|
52
|
+
key, _, val = line.partition("=")
|
|
53
|
+
env[key.strip()] = unquote(val.strip())
|
|
54
|
+
return env
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_value(path: Path, key: str, value: str, comment: str | None = None) -> None:
|
|
58
|
+
"""Set one key in an existing .env: replace its first assignment, or append it
|
|
59
|
+
(after `comment`, if given) when the file doesn't define it."""
|
|
60
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
61
|
+
entry = f"{key}={quote(value)}"
|
|
62
|
+
for i, line in enumerate(lines):
|
|
63
|
+
if line.strip().partition("=")[0].strip() == key and "=" in line:
|
|
64
|
+
lines[i] = entry
|
|
65
|
+
break
|
|
66
|
+
else:
|
|
67
|
+
if lines and lines[-1].strip():
|
|
68
|
+
lines.append("")
|
|
69
|
+
if comment:
|
|
70
|
+
lines.extend(f"# {c}" for c in comment.splitlines())
|
|
71
|
+
lines.append(entry)
|
|
72
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write(path: Path, values: dict[str, str], template_path: Path | None = None) -> None:
|
|
76
|
+
"""Write values to .env, preserving template comments/structure when a template is given."""
|
|
77
|
+
if template_path and template_path.exists():
|
|
78
|
+
lines = template_path.read_text(encoding="utf-8").splitlines()
|
|
79
|
+
out = []
|
|
80
|
+
for line in lines:
|
|
81
|
+
stripped = line.strip()
|
|
82
|
+
if stripped and not stripped.startswith("#") and "=" in stripped:
|
|
83
|
+
key, _, _ = stripped.partition("=")
|
|
84
|
+
key = key.strip()
|
|
85
|
+
if key in values:
|
|
86
|
+
# Strip inline comments from template line before writing value
|
|
87
|
+
out.append(f"{key}={quote(values[key])}")
|
|
88
|
+
continue
|
|
89
|
+
out.append(line)
|
|
90
|
+
path.write_text("\n".join(out) + "\n", encoding="utf-8", newline="\n")
|
|
91
|
+
else:
|
|
92
|
+
path.write_text(
|
|
93
|
+
"\n".join(f"{k}={quote(v)}" for k, v in values.items()) + "\n",
|
|
94
|
+
encoding="utf-8",
|
|
95
|
+
newline="\n",
|
|
96
|
+
)
|