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.
@@ -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.
@@ -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
@@ -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,3 @@
1
+ """p4n4-lib: shared library between P4N4 stacks and clients."""
2
+
3
+ __version__ = "0.2.0"
@@ -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
+ )