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 +3 -0
- p4n4_lib/compose.py +167 -0
- p4n4_lib/env.py +96 -0
- p4n4_lib/layers.py +114 -0
- p4n4_lib/layout.py +55 -0
- p4n4_lib/manifest.py +93 -0
- p4n4_lib/scaffold.py +91 -0
- p4n4_lib/secrets.py +35 -0
- p4n4_lib/sources.yaml +4 -0
- p4n4_lib/validate.py +105 -0
- p4n4_lib-0.2.0.dist-info/METADATA +70 -0
- p4n4_lib-0.2.0.dist-info/RECORD +14 -0
- p4n4_lib-0.2.0.dist-info/WHEEL +4 -0
- p4n4_lib-0.2.0.dist-info/licenses/LICENSE +21 -0
p4n4_lib/__init__.py
ADDED
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
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,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.
|