nthlayer-core 1.0.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.
@@ -0,0 +1 @@
1
+ """NthLayer core — reliability-critical verdict store, case management, and HTTP API."""
@@ -0,0 +1,183 @@
1
+ """Manifest catalogue.
2
+
3
+ Loads OpenSRM manifests from a directory, caches them in memory, and
4
+ detects changes via polling. Workers read manifests from core's API
5
+ instead of loading YAML files directly.
6
+
7
+ Usage:
8
+ catalogue = ManifestCatalogue("/path/to/specs")
9
+ catalogue.load() # initial load
10
+ changed = catalogue.poll() # check for changes, returns list of changed service names
11
+ manifest = catalogue.get("fraud-detect")
12
+ all_manifests = catalogue.list_all()
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import structlog
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from nthlayer_common.manifest import (
23
+ ManifestLoadError,
24
+ load_manifest,
25
+ )
26
+ from nthlayer_common.manifest.models import ReliabilityManifest
27
+
28
+ logger = structlog.get_logger()
29
+
30
+
31
+ class ManifestCatalogue:
32
+ """In-memory manifest catalogue with file-system change detection.
33
+
34
+ Loads all .yaml/.yml files from a directory, parses them as manifests,
35
+ and caches the parsed results keyed by service name. Polling checks
36
+ file mtimes and reloads changed files.
37
+ """
38
+
39
+ def __init__(self, manifests_dir: str | Path | None = None):
40
+ self._dir: Path | None = None
41
+ if manifests_dir is not None:
42
+ self._dir = Path(manifests_dir).resolve()
43
+ self._manifests: dict[str, ReliabilityManifest] = {}
44
+ self._mtimes: dict[str, float] = {} # path → mtime
45
+
46
+ @property
47
+ def directory(self) -> Path | None:
48
+ return self._dir
49
+
50
+ def load(self) -> list[str]:
51
+ """Load all manifests from the directory. Returns list of service names loaded."""
52
+ if self._dir is None or not self._dir.is_dir():
53
+ return []
54
+
55
+ loaded = []
56
+ for yaml_file in sorted(self._dir.glob("*.yaml")) + sorted(self._dir.glob("*.yml")):
57
+ name = self._load_file(yaml_file)
58
+ if name:
59
+ loaded.append(name)
60
+
61
+ logger.info("catalogue_loaded", count=len(loaded), directory=str(self._dir))
62
+ return loaded
63
+
64
+ def poll(self) -> list[str]:
65
+ """Check for changed/new/deleted manifest files. Returns list of changed service names."""
66
+ if self._dir is None or not self._dir.is_dir():
67
+ return []
68
+
69
+ changed: list[str] = []
70
+ current_files: set[str] = set()
71
+
72
+ for yaml_file in sorted(self._dir.glob("*.yaml")) + sorted(self._dir.glob("*.yml")):
73
+ file_key = str(yaml_file)
74
+ current_files.add(file_key)
75
+
76
+ try:
77
+ mtime = yaml_file.stat().st_mtime
78
+ except OSError:
79
+ continue
80
+
81
+ if file_key not in self._mtimes or self._mtimes[file_key] < mtime:
82
+ name = self._load_file(yaml_file)
83
+ if name:
84
+ changed.append(name)
85
+
86
+ # Detect deleted files
87
+ deleted_files = set(self._mtimes.keys()) - current_files
88
+ for file_key in deleted_files:
89
+ # Find which service this file belonged to
90
+ for svc_name, manifest in list(self._manifests.items()):
91
+ if manifest.source_file == file_key:
92
+ del self._manifests[svc_name]
93
+ changed.append(svc_name)
94
+ break
95
+ del self._mtimes[file_key]
96
+
97
+ if changed:
98
+ logger.info("catalogue_changed", changed=changed)
99
+
100
+ return changed
101
+
102
+ def get(self, service_name: str) -> ReliabilityManifest | None:
103
+ """Get a manifest by service name."""
104
+ return self._manifests.get(service_name)
105
+
106
+ def list_all(self) -> list[ReliabilityManifest]:
107
+ """List all loaded manifests."""
108
+ return list(self._manifests.values())
109
+
110
+ def to_dict_list(self) -> list[dict[str, Any]]:
111
+ """Serialise all manifests to a list of dicts for API responses."""
112
+ return [manifest_to_dict(m) for m in self._manifests.values()]
113
+
114
+ def _load_file(self, yaml_file: Path) -> str | None:
115
+ """Load a single manifest file. Returns service name or None on failure."""
116
+ try:
117
+ mtime = yaml_file.stat().st_mtime # capture before parse to avoid TOCTOU
118
+ manifest = load_manifest(yaml_file, suppress_deprecation_warning=True)
119
+ self._manifests[manifest.name] = manifest
120
+ self._mtimes[str(yaml_file)] = mtime
121
+ return manifest.name
122
+ except (ManifestLoadError, FileNotFoundError, ValueError, OSError) as e:
123
+ logger.warning("catalogue_load_failed", file=str(yaml_file), error=str(e))
124
+ return None
125
+
126
+
127
+ def manifest_to_dict(m: ReliabilityManifest) -> dict[str, Any]:
128
+ """Convert a ReliabilityManifest to a JSON-serialisable dict for the API."""
129
+ result: dict[str, Any] = {
130
+ "name": m.name,
131
+ "team": m.team,
132
+ "tier": m.tier,
133
+ "type": m.type,
134
+ "namespace": m.namespace,
135
+ "source_format": m.source_format.value,
136
+ }
137
+
138
+ if m.description:
139
+ result["description"] = m.description
140
+ if m.labels:
141
+ result["labels"] = m.labels
142
+
143
+ # SLOs
144
+ result["slos"] = []
145
+ for slo in m.slos:
146
+ slo_dict: dict[str, Any] = {
147
+ "name": slo.name,
148
+ "target": slo.target,
149
+ "slo_type": slo.slo_type,
150
+ "window": slo.window,
151
+ }
152
+ if slo.judgment_type:
153
+ slo_dict["judgment_type"] = slo.judgment_type
154
+ if slo.indicator_query:
155
+ slo_dict["indicator_query"] = slo.indicator_query
156
+ if slo.description:
157
+ slo_dict["description"] = slo.description
158
+ result["slos"].append(slo_dict)
159
+
160
+ # Dependencies
161
+ result["dependencies"] = [
162
+ {
163
+ "name": d.name,
164
+ "type": d.type,
165
+ "critical": d.critical,
166
+ }
167
+ for d in m.dependencies
168
+ ]
169
+
170
+ # Contracts
171
+ if m.contracts:
172
+ result["contracts"] = [
173
+ {
174
+ "name": c.name,
175
+ "promise": {
176
+ "availability": c.promise.availability,
177
+ "latency_p99": c.promise.latency_p99,
178
+ },
179
+ }
180
+ for c in m.contracts
181
+ ]
182
+
183
+ return result
nthlayer_core/cli.py ADDED
@@ -0,0 +1,27 @@
1
+ """NthLayer core CLI."""
2
+ import argparse
3
+ import sys
4
+
5
+
6
+ def main():
7
+ parser = argparse.ArgumentParser(description="NthLayer core")
8
+ parser.add_argument("-V", "--version", action="version", version="%(prog)s 1.5.0a1")
9
+ sub = parser.add_subparsers(dest="command")
10
+
11
+ serve_parser = sub.add_parser("serve", help="Start the core HTTP server")
12
+ serve_parser.add_argument("--host", default="0.0.0.0")
13
+ serve_parser.add_argument("--port", type=int, default=8000)
14
+
15
+ args = parser.parse_args()
16
+
17
+ if args.command == "serve":
18
+ from nthlayer_core.server import run_server
19
+
20
+ run_server(host=args.host, port=args.port)
21
+ else:
22
+ parser.print_help()
23
+ sys.exit(1)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()