p4n4-lib 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """p4n4-lib: shared library between P4N4 stacks and clients."""
2
+
3
+ __version__ = "0.2.0"
p4n4_lib/compose.py ADDED
@@ -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)
p4n4_lib/env.py ADDED
@@ -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
+ )
p4n4_lib/layers.py ADDED
@@ -0,0 +1,114 @@
1
+ """Layer registry: per-layer stack metadata shared by all clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Layer:
13
+ name: str
14
+ repo_url: str
15
+ # Files/dirs copied from the stack repo into a new project
16
+ copy_paths: tuple[str, ...] = ()
17
+ # Files a valid project must contain
18
+ required_files: tuple[str, ...] = ()
19
+ # Keys a valid project .env must define
20
+ required_env_keys: tuple[str, ...] = ()
21
+
22
+ @property
23
+ def clone_prefix(self) -> str:
24
+ return f"p4n4-{self.name}-"
25
+
26
+
27
+ _sources: dict = yaml.safe_load(
28
+ (Path(__file__).parent / "sources.yaml").read_text(encoding="utf-8")
29
+ )
30
+
31
+ LAYERS: dict[str, Layer] = {
32
+ "iot": Layer(
33
+ name="iot",
34
+ repo_url=_sources["iot_repo_url"],
35
+ copy_paths=("docker-compose.yml", "config", "scripts"),
36
+ required_files=(
37
+ "docker-compose.yml",
38
+ ".env",
39
+ "config/mosquitto/mosquitto.conf",
40
+ "config/mosquitto/bridge.sh",
41
+ "config/node-red/settings.js",
42
+ "config/node-red/flows/flows.json",
43
+ "config/grafana/provisioning/datasources/datasources.yml",
44
+ "scripts/init-buckets.sh",
45
+ ),
46
+ required_env_keys=(
47
+ "TZ",
48
+ "INFLUXDB_USERNAME",
49
+ "INFLUXDB_PASSWORD",
50
+ "INFLUXDB_ORG",
51
+ "INFLUXDB_TOKEN",
52
+ "INFLUXDB_BUCKET",
53
+ "GRAFANA_USER",
54
+ "GRAFANA_PASSWORD",
55
+ "NODE_RED_USER",
56
+ "NODE_RED_PASSWORD",
57
+ ),
58
+ ),
59
+ "ai": Layer(
60
+ name="ai",
61
+ repo_url=_sources["ai_repo_url"],
62
+ copy_paths=("docker-compose.yml", "config", "scripts"),
63
+ required_files=(
64
+ "docker-compose.yml",
65
+ ".env",
66
+ "config/letta/letta.conf",
67
+ "scripts/pull-models.sh",
68
+ ),
69
+ required_env_keys=(
70
+ "LETTA_SERVER_PASSWORD",
71
+ "N8N_BASIC_AUTH_USER",
72
+ "N8N_BASIC_AUTH_PASSWORD",
73
+ "N8N_ENCRYPTION_KEY",
74
+ "N8N_HOST",
75
+ "INFLUXDB_TOKEN",
76
+ "INFLUXDB_ORG",
77
+ "INFLUXDB_BUCKET",
78
+ ),
79
+ ),
80
+ "edge": Layer(
81
+ name="edge",
82
+ repo_url=_sources["edge_repo_url"],
83
+ copy_paths=("docker-compose.yml", "runner", "edge-impulse", "onnx"),
84
+ required_files=(
85
+ "docker-compose.yml",
86
+ ".env",
87
+ "runner/Dockerfile",
88
+ "runner/runner.py",
89
+ "edge-impulse/models",
90
+ "onnx/models",
91
+ ),
92
+ required_env_keys=(
93
+ "MODEL_BACKEND",
94
+ "MQTT_HOST",
95
+ "MQTT_PORT",
96
+ "INFLUXDB_TOKEN",
97
+ "INFLUXDB_ORG",
98
+ "INFLUXDB_BUCKET_AI_EVENTS",
99
+ "TZ",
100
+ ),
101
+ ),
102
+ # The web UI (p4n4-dashboard): one container from a released image, so
103
+ # only its compose file is copied, not the Flutter sources. Last in the
104
+ # order, so `up` starts it after the stacks it shows and `down` stops it first.
105
+ "dashboard": Layer(
106
+ name="dashboard",
107
+ repo_url=_sources["dashboard_repo_url"],
108
+ copy_paths=("docker-compose.yml",),
109
+ required_files=("docker-compose.yml", ".env"),
110
+ required_env_keys=("DASHBOARD_VERSION", "DASHBOARD_PORT"),
111
+ ),
112
+ }
113
+
114
+ LAYER_NAMES: tuple[str, ...] = tuple(LAYERS)
p4n4_lib/layout.py ADDED
@@ -0,0 +1,55 @@
1
+ """Project layout: where each layer's stack files live inside a project.
2
+
3
+ Single-layer projects keep stack files at the project root. Multi-layer
4
+ projects give each layer its own subdirectory so the stacks run as separate
5
+ Compose projects (p4n4-ai attaches to the p4n4-net network that p4n4-iot
6
+ creates, so their compose files must not be merged).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Iterable, Sequence
13
+ from pathlib import Path
14
+
15
+ from p4n4_lib.layers import LAYERS
16
+
17
+ COMPOSE_FILE = "docker-compose.yml"
18
+
19
+
20
+ def ordered(names: Iterable[str]) -> list[str]:
21
+ """Sort layer names into dependency order (iot before ai before edge)."""
22
+ wanted = set(names)
23
+ return [name for name in LAYERS if name in wanted]
24
+
25
+
26
+ def layer_dir(project_dir: Path, layers: Sequence[str], name: str) -> Path:
27
+ """Directory a layer's stack files live in."""
28
+ return project_dir / name if len(layers) > 1 else project_dir
29
+
30
+
31
+ def compose_project_name(project: str, layers: Sequence[str], name: str) -> str:
32
+ """
33
+ Compose project name for a layer: the project name for single-layer projects,
34
+ `<project>-<layer>` for multi-layer ones. Without it, Compose names a layer after
35
+ its directory (`iot`, `ai`, ...), so every multi-layer project on a host would
36
+ share the same volumes. Lowercased, with characters Compose rejects replaced by `-`.
37
+ """
38
+ raw = project if len(layers) <= 1 else f"{project}-{name}"
39
+ cleaned = re.sub(r"[^a-z0-9_-]+", "-", raw.lower()).lstrip("-_")
40
+ return cleaned or "p4n4"
41
+
42
+
43
+ def compose_dirs(project_dir: Path, layers: Sequence[str]) -> list[tuple[str, Path]]:
44
+ """
45
+ Return (layer, directory) pairs that contain a compose file, in
46
+ dependency order. Single-layer projects resolve to the project root.
47
+ """
48
+ if (project_dir / COMPOSE_FILE).exists():
49
+ names = ordered(layers)
50
+ return [(names[0] if names else "", project_dir)]
51
+ return [
52
+ (name, project_dir / name)
53
+ for name in ordered(layers)
54
+ if (project_dir / name / COMPOSE_FILE).exists()
55
+ ]
p4n4_lib/manifest.py ADDED
@@ -0,0 +1,93 @@
1
+ """Manifest (.p4n4.json) read/write utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ MANIFEST_FILE = ".p4n4.json"
10
+ SCHEMA_VERSION = 1
11
+
12
+ # Tabs p4n4-dashboard can show; the optional "dashboard" block narrows them
13
+ DASHBOARD_TABS = ("services", "edge", "agent", "grafana", "video")
14
+
15
+
16
+ def find(start: Path | None = None) -> Path | None:
17
+ """Walk up from start (default cwd) to find .p4n4.json."""
18
+ current = (start or Path.cwd()).resolve()
19
+ for directory in [current, *current.parents]:
20
+ candidate = directory / MANIFEST_FILE
21
+ if candidate.exists():
22
+ return candidate
23
+ return None
24
+
25
+
26
+ def load(path: Path) -> dict:
27
+ return json.loads(path.read_text(encoding="utf-8"))
28
+
29
+
30
+ def save(path: Path, data: dict) -> None:
31
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8", newline="\n")
32
+
33
+
34
+ def create(project: str, layers: list[str]) -> dict:
35
+ return {
36
+ "schema_version": SCHEMA_VERSION,
37
+ "project": project,
38
+ "layers": layers,
39
+ "created_at": datetime.now(UTC).isoformat(),
40
+ }
41
+
42
+
43
+ def dashboard_errors(data: dict) -> list[str]:
44
+ """
45
+ Problems with the optional ``dashboard`` block, which tells p4n4-dashboard
46
+ how to present the project:
47
+
48
+ - ``grafana_path``: Grafana page the Grafana tab opens, e.g.
49
+ ``/d/<uid>/<slug>``.
50
+ - ``tabs``: the dashboard tabs this project can serve (see
51
+ ``DASHBOARD_TABS``); the dashboard hides the others while connected.
52
+ - ``theme``: directory, relative to the project, holding the white-label
53
+ theme the project's dashboard is built with (``brand.json``, icon,
54
+ fonts). Installed with ``dart run tool/brand.dart install <project>``.
55
+
56
+ Only the block's shape is checked here; ``validate.validate_project``
57
+ checks that the theme directory exists.
58
+ """
59
+ if "dashboard" not in data:
60
+ return []
61
+ block = data["dashboard"]
62
+ if not isinstance(block, dict):
63
+ return [".p4n4.json: dashboard must be an object"]
64
+
65
+ errors = []
66
+ for key in sorted(set(block) - {"grafana_path", "tabs", "theme"}):
67
+ errors.append(f".p4n4.json: dashboard.{key} is not a known setting")
68
+
69
+ path = block.get("grafana_path")
70
+ if path is not None and not (isinstance(path, str) and path.startswith("/")):
71
+ errors.append('.p4n4.json: dashboard.grafana_path must be a path starting with "/"')
72
+
73
+ theme = block.get("theme")
74
+ if theme is not None and not (
75
+ isinstance(theme, str)
76
+ and theme.strip()
77
+ and not PurePosixPath(theme).is_absolute()
78
+ and ".." not in PurePosixPath(theme).parts
79
+ ):
80
+ errors.append(".p4n4.json: dashboard.theme must be a directory inside the project")
81
+
82
+ tabs = block.get("tabs")
83
+ if tabs is not None:
84
+ if not isinstance(tabs, list) or not tabs:
85
+ errors.append(".p4n4.json: dashboard.tabs must be a non-empty list")
86
+ else:
87
+ unknown = [t for t in tabs if t not in DASHBOARD_TABS]
88
+ if unknown:
89
+ errors.append(
90
+ f".p4n4.json: unknown dashboard.tabs {unknown} "
91
+ f"(expected any of {', '.join(DASHBOARD_TABS)})"
92
+ )
93
+ return errors
p4n4_lib/scaffold.py ADDED
@@ -0,0 +1,91 @@
1
+ """Project scaffolding: fetch stack sources and copy them into a project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from p4n4_lib import env as envutil
11
+ from p4n4_lib.layers import Layer
12
+
13
+
14
+ class ScaffoldError(Exception):
15
+ """Raised when fetching or copying a stack source fails."""
16
+
17
+
18
+ def fetch_source(layer: Layer, source: str | Path | None = None) -> tuple[Path, str | None]:
19
+ """
20
+ Return a (Path, tmpdir_or_None) to a stack source directory.
21
+
22
+ If `source` is a local path it is used directly; otherwise the layer repo is
23
+ cloned at shallow depth into a temp dir that the caller must clean up.
24
+ """
25
+ if source:
26
+ path = Path(source).expanduser().resolve()
27
+ if not path.exists():
28
+ raise ScaffoldError(f"source path does not exist: {path}")
29
+ return path, None
30
+
31
+ tmp = tempfile.mkdtemp(prefix=layer.clone_prefix)
32
+ # core.autocrlf=false: Git for Windows defaults to CRLF checkouts, which
33
+ # break shell scripts run inside the Linux containers ('bash\r').
34
+ result = subprocess.run(
35
+ ["git", "clone", "--depth", "1", "--config", "core.autocrlf=false", layer.repo_url, tmp],
36
+ capture_output=True,
37
+ text=True,
38
+ )
39
+ if result.returncode != 0:
40
+ shutil.rmtree(tmp, ignore_errors=True)
41
+ raise ScaffoldError(
42
+ f"git clone failed:\n{result.stderr.strip()}\n\n"
43
+ f"Tip: pass a local checkout path to scaffold offline."
44
+ )
45
+ return Path(tmp), tmp
46
+
47
+
48
+ def scaffold_layer(
49
+ project_dir: Path,
50
+ layer: Layer,
51
+ env_values: dict[str, str],
52
+ source: str | Path | None = None,
53
+ compose_project_name: str | None = None,
54
+ ) -> None:
55
+ """Copy a layer's files into project_dir and write its .env.
56
+
57
+ With `compose_project_name`, the .env also sets COMPOSE_PROJECT_NAME, so the
58
+ layer's containers and volumes are namespaced per project (see
59
+ `layout.compose_project_name`)."""
60
+ src, tmpdir = fetch_source(layer, source)
61
+ try:
62
+ for name in layer.copy_paths:
63
+ s = src / name
64
+ if not s.exists():
65
+ raise ScaffoldError(f"Expected '{name}' in source repo but it was not found.")
66
+ d = project_dir / name
67
+ if s.is_dir():
68
+ shutil.copytree(s, d, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
69
+ else:
70
+ shutil.copy2(s, d)
71
+
72
+ # Make shell scripts executable
73
+ scripts_dir = project_dir / "scripts"
74
+ if scripts_dir.is_dir():
75
+ for script in scripts_dir.glob("*.sh"):
76
+ script.chmod(script.stat().st_mode | 0o111)
77
+
78
+ # Write .env: values override the .env.example template from the repo
79
+ envutil.write(project_dir / ".env", env_values, template_path=src / ".env.example")
80
+ if compose_project_name:
81
+ envutil.set_value(
82
+ project_dir / ".env",
83
+ "COMPOSE_PROJECT_NAME",
84
+ compose_project_name,
85
+ comment="Compose project name, set by p4n4 init: keeps this project's\n"
86
+ "containers' volumes apart from other projects'. Changing it on an\n"
87
+ "existing project switches to new, empty volumes.",
88
+ )
89
+ finally:
90
+ if tmpdir:
91
+ shutil.rmtree(tmpdir, ignore_errors=True)
p4n4_lib/secrets.py ADDED
@@ -0,0 +1,35 @@
1
+ """Secret generation and rotation policy shared by all clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets as _secrets
6
+
7
+ ROTATABLE_KEYS = (
8
+ # IoT layer
9
+ "INFLUXDB_PASSWORD",
10
+ "INFLUXDB_TOKEN",
11
+ "GRAFANA_PASSWORD",
12
+ "NODE_RED_PASSWORD",
13
+ # AI layer
14
+ "LETTA_SERVER_PASSWORD",
15
+ "N8N_BASIC_AUTH_PASSWORD",
16
+ "N8N_ENCRYPTION_KEY",
17
+ )
18
+
19
+ # Secrets issued by someone else (an external broker's credentials): shown
20
+ # masked alongside the others, never rotated or generated, since a new value
21
+ # would no longer match the remote side
22
+ EXTERNAL_KEYS = (
23
+ # IoT layer: bridge to an external MQTT broker
24
+ "MQTT_REMOTE_PASSWORD",
25
+ )
26
+
27
+
28
+ def token(n: int = 32) -> str:
29
+ """Return a hex token from n random bytes (2n characters)."""
30
+ return _secrets.token_hex(n)
31
+
32
+
33
+ def rotation_value(key: str) -> str:
34
+ """Generate a replacement value sized for the given rotatable key."""
35
+ return token(16 if "PASSWORD" in key else 32)
p4n4_lib/sources.yaml ADDED
@@ -0,0 +1,4 @@
1
+ iot_repo_url: https://github.com/raisga/p4n4-iot.git
2
+ ai_repo_url: https://github.com/raisga/p4n4-ai.git
3
+ edge_repo_url: https://github.com/raisga/p4n4-edge.git
4
+ dashboard_repo_url: https://github.com/raisga/p4n4-dashboard.git
p4n4_lib/validate.py ADDED
@@ -0,0 +1,105 @@
1
+ """Project validation checks shared by all clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from p4n4_lib import env as envutil
9
+ from p4n4_lib import layout
10
+ from p4n4_lib import manifest as mf
11
+ from p4n4_lib.layers import LAYERS
12
+
13
+
14
+ def validate_project(project_dir: Path, data: dict) -> tuple[list[str], list[str]]:
15
+ """
16
+ Validate a loaded manifest against the project tree.
17
+
18
+ Returns (passed, errors) as human-readable check labels.
19
+ """
20
+ passed: list[str] = []
21
+ errors: list[str] = []
22
+
23
+ if data.get("schema_version") != mf.SCHEMA_VERSION:
24
+ errors.append(
25
+ f".p4n4.json schema_version mismatch "
26
+ f"(got {data.get('schema_version')}, expected {mf.SCHEMA_VERSION})"
27
+ )
28
+ else:
29
+ passed.append(".p4n4.json: valid")
30
+
31
+ dashboard_errors = mf.dashboard_errors(data)
32
+ errors.extend(dashboard_errors)
33
+ if "dashboard" in data and not dashboard_errors:
34
+ passed.append(".p4n4.json: dashboard settings valid")
35
+ if theme := data["dashboard"].get("theme"):
36
+ errors_before = len(errors)
37
+ _check_theme(project_dir / theme, theme, errors)
38
+ if len(errors) == errors_before:
39
+ passed.append(f"{theme}/brand.json: dashboard theme")
40
+
41
+ layer_names: list[str] = data.get("layers", [])
42
+ multi = len(layer_names) > 1
43
+ # A template ships its own variant of a stack (e.g. MQTT → Telegraf instead
44
+ # of Node-RED), so the base stack's file list doesn't apply. The template
45
+ # registry validates and smoke-tests the template's own files; here only
46
+ # its compose file and the variables its .env.example documents are checked.
47
+ from_template = isinstance(data.get("template"), dict)
48
+
49
+ for name in layout.ordered(n for n in layer_names if n in LAYERS):
50
+ layer = LAYERS[name]
51
+ base = layout.layer_dir(project_dir, layer_names, name)
52
+ prefix = f"{name}/" if multi else ""
53
+
54
+ if from_template:
55
+ required_files: tuple[str, ...] = (layout.COMPOSE_FILE, envutil.ENV_FILE)
56
+ example = base / f"{envutil.ENV_FILE}.example"
57
+ required_keys = tuple(envutil.load(example)) if example.exists() else ()
58
+ else:
59
+ required_files, required_keys = layer.required_files, layer.required_env_keys
60
+
61
+ for rel in required_files:
62
+ if (base / rel).exists():
63
+ passed.append(f"{prefix}{rel}")
64
+ else:
65
+ errors.append(f"Missing file: {prefix}{rel}")
66
+
67
+ if not required_keys:
68
+ continue
69
+
70
+ env_path = base / envutil.ENV_FILE
71
+ if env_path.exists():
72
+ env = envutil.load(env_path)
73
+ # Template variables may be deliberately empty (COMPOSE_PROFILES=)
74
+ missing = [
75
+ key
76
+ for key in required_keys
77
+ if (key not in env if from_template else not env.get(key))
78
+ ]
79
+ if missing:
80
+ errors.extend(f"{prefix}.env missing required key: {key}" for key in missing)
81
+ else:
82
+ passed.append(f"{prefix}.env: all required keys present")
83
+ else:
84
+ errors.append(f"{prefix}.env file not found")
85
+
86
+ return passed, errors
87
+
88
+
89
+ def _check_theme(theme_dir: Path, label: str, errors: list[str]) -> None:
90
+ """
91
+ The theme must be a directory with a parseable brand.json naming its id.
92
+ Full checks (colors, contrast, native IDs, fonts) are the dashboard's
93
+ `tool/brand.dart check`; this only catches a missing or broken theme.
94
+ """
95
+ brand = theme_dir / "brand.json"
96
+ if not brand.is_file():
97
+ errors.append(f"Missing file: {label}/brand.json (.p4n4.json dashboard.theme)")
98
+ return
99
+ try:
100
+ data = json.loads(brand.read_text(encoding="utf-8"))
101
+ except json.JSONDecodeError as exc:
102
+ errors.append(f"{label}/brand.json is not valid JSON: {exc}")
103
+ return
104
+ if not isinstance(data, dict) or not isinstance(data.get("id"), str) or not data["id"]:
105
+ errors.append(f'{label}/brand.json must have an "id"')
@@ -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,14 @@
1
+ p4n4_lib/__init__.py,sha256=c8otB6Z1Yt3sL_1Cn8gCqr-gephg9mrmi6Hg_zBr5Tk,87
2
+ p4n4_lib/compose.py,sha256=FYb8mK6F03Gap5kEIcX7aGHSmix4S9St3N5NGA7Vb8s,5036
3
+ p4n4_lib/env.py,sha256=1EG0JeKmkql_z_ofs_SKs-DwZMSgEzgIis686s1jfTU,3647
4
+ p4n4_lib/layers.py,sha256=ZUVQ1XQE2sccSxD0yxffW7H1739-RUCevZJENK71IKU,3400
5
+ p4n4_lib/layout.py,sha256=meWZtc9iao7er72_HnSZyBJq-jjvoB2VBO0AKP5yClg,2073
6
+ p4n4_lib/manifest.py,sha256=ACgg4ogppERaMFkksYH1xBdMjhT8gAaIXC81fD-34mU,3272
7
+ p4n4_lib/scaffold.py,sha256=VXeELSYJhNwG5egvfudhQJsKuURsvs5KVIFogtvDb-8,3360
8
+ p4n4_lib/secrets.py,sha256=1RIGdWFPwbZ54Qmupm1trZ1qyBileyERd6MbX7QhqaA,953
9
+ p4n4_lib/sources.yaml,sha256=QCI_UEUAanpazBJx0cHQeEBcLMkMLGzM98kTvMJvc5o,224
10
+ p4n4_lib/validate.py,sha256=FMwVIXYXydubaO4ogx1rzXZtZqJ1r0aoVsN7KdrHEfQ,4083
11
+ p4n4_lib-0.2.0.dist-info/METADATA,sha256=0E_vbdUf95YoWw5mOqR4KWRuCALeGR2DbIcjOwpuElg,2574
12
+ p4n4_lib-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
13
+ p4n4_lib-0.2.0.dist-info/licenses/LICENSE,sha256=80M4HKFVcFt1FZ-LCbuUyD2RyaFG4sk0iCdIt7KD0BE,1063
14
+ p4n4_lib-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.