PTSIP 0.3.1__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.
Files changed (66) hide show
  1. ptsip/__init__.py +3 -0
  2. ptsip/__main__.py +4 -0
  3. ptsip/app/__init__.py +5 -0
  4. ptsip/app/client.py +53 -0
  5. ptsip/app/github_client.py +200 -0
  6. ptsip/app/server.py +130 -0
  7. ptsip/app/service.py +285 -0
  8. ptsip/app/store.py +233 -0
  9. ptsip/artifact_evidence.py +150 -0
  10. ptsip/build_resolution.py +298 -0
  11. ptsip/clarification/__init__.py +6 -0
  12. ptsip/clarification/generator.py +120 -0
  13. ptsip/clarification/generator_core.py +98 -0
  14. ptsip/clarification/i18n.py +106 -0
  15. ptsip/clarification/model.py +33 -0
  16. ptsip/clarification/render.py +78 -0
  17. ptsip/clarification/resolution/__init__.py +29 -0
  18. ptsip/clarification/resolution/model.py +33 -0
  19. ptsip/clarification/resolution/parser.py +56 -0
  20. ptsip/clarification/resolution/profile_projection.py +198 -0
  21. ptsip/clarification/resolution/resolver.py +30 -0
  22. ptsip/clarification/transports/__init__.py +1 -0
  23. ptsip/clarification/transports/github_issue.py +162 -0
  24. ptsip/cli.py +402 -0
  25. ptsip/conformance.py +531 -0
  26. ptsip/conformance_audit.py +143 -0
  27. ptsip/conformance_engine.py +396 -0
  28. ptsip/constants.py +9 -0
  29. ptsip/doctor.py +33 -0
  30. ptsip/inspection/__init__.py +1 -0
  31. ptsip/inspection/components.py +74 -0
  32. ptsip/inspection/dependencies.py +496 -0
  33. ptsip/inspection/dependencies_030.py +69 -0
  34. ptsip/inspection/dotnet.py +188 -0
  35. ptsip/inspection/go.py +157 -0
  36. ptsip/inspection/inventory.py +161 -0
  37. ptsip/inspection/javascript.py +376 -0
  38. ptsip/inspection/lexing.py +121 -0
  39. ptsip/inspection/source_adapters.py +60 -0
  40. ptsip/lifecycle_evidence.py +245 -0
  41. ptsip/model.py +105 -0
  42. ptsip/pilot/__init__.py +1 -0
  43. ptsip/pilot/runner.py +170 -0
  44. ptsip/repository/__init__.py +1 -0
  45. ptsip/repository/discover.py +68 -0
  46. ptsip/repository/remote.py +54 -0
  47. ptsip/repository/snapshot.py +173 -0
  48. ptsip/review_evidence.py +406 -0
  49. ptsip/spec_identity.py +22 -0
  50. ptsip/specdata/ptsip-agent-classification.schema.json +35 -0
  51. ptsip/specdata/ptsip-artifact-evidence.schema.json +68 -0
  52. ptsip/specdata/ptsip-diagnostic.schema.json +29 -0
  53. ptsip/specdata/ptsip-profile.schema.json +310 -0
  54. ptsip/specdata/ptsip-registry.yaml +189 -0
  55. ptsip/storage/__init__.py +1 -0
  56. ptsip/storage/local_state.py +32 -0
  57. ptsip/validation/__init__.py +1 -0
  58. ptsip/validation/components.py +164 -0
  59. ptsip/validation/profile.py +172 -0
  60. ptsip/validation/rules.py +163 -0
  61. ptsip-0.3.1.dist-info/METADATA +338 -0
  62. ptsip-0.3.1.dist-info/RECORD +66 -0
  63. ptsip-0.3.1.dist-info/WHEEL +5 -0
  64. ptsip-0.3.1.dist-info/entry_points.txt +3 -0
  65. ptsip-0.3.1.dist-info/licenses/LICENSE +201 -0
  66. ptsip-0.3.1.dist-info/top_level.txt +1 -0
ptsip/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PTSIP reference development tooling."""
2
+
3
+ __version__ = "0.2.0"
ptsip/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
ptsip/app/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .client import ControlPlaneClient, ControlPlaneError
2
+ from .service import DecisionService
3
+ from .store import DecisionRecord, DecisionStore
4
+
5
+ __all__ = ["ControlPlaneClient", "ControlPlaneError", "DecisionRecord", "DecisionService", "DecisionStore"]
ptsip/app/client.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import Any
8
+
9
+
10
+ class ControlPlaneError(RuntimeError):
11
+ pass
12
+
13
+
14
+ class ControlPlaneClient:
15
+ def __init__(self, base_url: str | None = None, token: str | None = None):
16
+ self.base_url = (base_url or os.environ.get("PTSIP_CONTROL_PLANE_URL") or "").rstrip("/")
17
+ self.token = token or os.environ.get("PTSIP_CONTROL_PLANE_TOKEN")
18
+ if not self.base_url:
19
+ raise ControlPlaneError("PTSIP control plane URL is not configured; use --control-plane or PTSIP_CONTROL_PLANE_URL")
20
+ if not self.token:
21
+ raise ControlPlaneError("PTSIP_CONTROL_PLANE_TOKEN is required")
22
+
23
+ def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
24
+ request = urllib.request.Request(
25
+ self.base_url + path,
26
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
27
+ method="POST",
28
+ headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
29
+ )
30
+ try:
31
+ with urllib.request.urlopen(request, timeout=30) as response:
32
+ raw = response.read()
33
+ except urllib.error.HTTPError as exc:
34
+ detail = exc.read().decode("utf-8", errors="replace")
35
+ raise ControlPlaneError(f"Control plane request failed: HTTP {exc.code}: {detail}") from exc
36
+ except OSError as exc:
37
+ raise ControlPlaneError(f"Control plane request failed: {exc}") from exc
38
+ parsed = json.loads(raw.decode("utf-8"))
39
+ if not isinstance(parsed, dict):
40
+ raise ControlPlaneError("Control plane returned a non-object response")
41
+ return parsed
42
+
43
+ def gate(self, payload: dict[str, Any]) -> dict[str, Any]:
44
+ return self._post("/v1/gate", payload)
45
+
46
+ def decision(self, payload: dict[str, Any]) -> dict[str, Any]:
47
+ return self._post("/v1/decision", payload)
48
+
49
+ def resolve(self, payload: dict[str, Any]) -> dict[str, Any]:
50
+ return self._post("/v1/resolve", payload)
51
+
52
+ def application(self, payload: dict[str, Any]) -> dict[str, Any]:
53
+ return self._post("/v1/application", payload)
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class GitHubIssue:
17
+ number: int
18
+ url: str
19
+
20
+
21
+ class GitHubAPIError(RuntimeError):
22
+ pass
23
+
24
+
25
+ class GitHubAppClient:
26
+ def __init__(self, app_id: str | None = None, private_key: str | None = None, api_url: str | None = None):
27
+ self.app_id = app_id or os.environ.get("PTSIP_GITHUB_APP_ID")
28
+ configured_key = private_key or os.environ.get("PTSIP_GITHUB_PRIVATE_KEY")
29
+ key_path = os.environ.get("PTSIP_GITHUB_PRIVATE_KEY_PATH")
30
+ if configured_key is None and key_path:
31
+ configured_key = Path(key_path).expanduser().read_text(encoding="utf-8")
32
+ self.private_key = configured_key
33
+ self.api_url = (api_url or os.environ.get("PTSIP_GITHUB_API_URL") or "https://api.github.com").rstrip("/")
34
+ self._tokens: dict[int, tuple[str, float]] = {}
35
+
36
+ def _app_jwt(self) -> str:
37
+ if not self.app_id or not self.private_key:
38
+ raise GitHubAPIError("GitHub App credentials are not configured")
39
+ try:
40
+ import jwt
41
+ except ImportError as exc:
42
+ raise GitHubAPIError("PyJWT[crypto] is required for GitHub App authentication; install ptsip[github-app]") from exc
43
+ now = int(time.time())
44
+ token = jwt.encode(
45
+ {"iat": now - 60, "exp": now + 9 * 60, "iss": self.app_id},
46
+ self.private_key,
47
+ algorithm="RS256",
48
+ )
49
+ return str(token)
50
+
51
+ def _request(
52
+ self,
53
+ method: str,
54
+ path: str,
55
+ token: str,
56
+ payload: dict[str, Any] | None = None,
57
+ ) -> dict[str, Any]:
58
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
59
+ request = urllib.request.Request(
60
+ self.api_url + path,
61
+ data=data,
62
+ method=method,
63
+ headers={
64
+ "Accept": "application/vnd.github+json",
65
+ "Authorization": f"Bearer {token}",
66
+ "X-GitHub-Api-Version": "2022-11-28",
67
+ "Content-Type": "application/json",
68
+ "User-Agent": "ptsip-github-app/0.3.1",
69
+ },
70
+ )
71
+ try:
72
+ with urllib.request.urlopen(request, timeout=30) as response:
73
+ raw = response.read()
74
+ except urllib.error.HTTPError as exc:
75
+ detail = exc.read().decode("utf-8", errors="replace")
76
+ raise GitHubAPIError(f"GitHub API {method} {path} failed: HTTP {exc.code}: {detail}") from exc
77
+ if not raw:
78
+ return {}
79
+ parsed = json.loads(raw.decode("utf-8"))
80
+ if not isinstance(parsed, dict):
81
+ raise GitHubAPIError("GitHub API returned a non-object response")
82
+ return parsed
83
+
84
+ def repository_installation(self, repository: str) -> int:
85
+ if repository.count("/") != 1:
86
+ raise GitHubAPIError("repository must use owner/repository form")
87
+ owner, repo = repository.split("/", 1)
88
+ payload = self._request(
89
+ "GET",
90
+ f"/repos/{urllib.parse.quote(owner)}/{urllib.parse.quote(repo)}/installation",
91
+ self._app_jwt(),
92
+ )
93
+ installation_id = payload.get("id")
94
+ if not isinstance(installation_id, int):
95
+ raise GitHubAPIError("GitHub did not return a repository installation id")
96
+ return installation_id
97
+
98
+ def installation_token(self, installation_id: int) -> str:
99
+ cached = self._tokens.get(installation_id)
100
+ now = time.time()
101
+ if cached and cached[1] - 60 > now:
102
+ return cached[0]
103
+ payload = self._request(
104
+ "POST",
105
+ f"/app/installations/{installation_id}/access_tokens",
106
+ self._app_jwt(),
107
+ {},
108
+ )
109
+ token = str(payload.get("token", ""))
110
+ if not token:
111
+ raise GitHubAPIError("GitHub did not return an installation token")
112
+ self._tokens[installation_id] = (token, now + 50 * 60)
113
+ return token
114
+
115
+ def create_issue(self, repository: str, installation_id: int, title: str, body: str) -> GitHubIssue:
116
+ token = self.installation_token(installation_id)
117
+ payload = self._request("POST", f"/repos/{repository}/issues", token, {"title": title, "body": body})
118
+ return GitHubIssue(int(payload["number"]), str(payload["html_url"]))
119
+
120
+ def update_issue_state(self, repository: str, installation_id: int, issue_number: int, state: str) -> None:
121
+ token = self.installation_token(installation_id)
122
+ self._request("PATCH", f"/repos/{repository}/issues/{issue_number}", token, {"state": state})
123
+
124
+ def add_issue_comment(self, repository: str, installation_id: int, issue_number: int, body: str) -> None:
125
+ token = self.installation_token(installation_id)
126
+ self._request("POST", f"/repos/{repository}/issues/{issue_number}/comments", token, {"body": body})
127
+
128
+ def permission(self, repository: str, installation_id: int, username: str) -> str:
129
+ token = self.installation_token(installation_id)
130
+ payload = self._request(
131
+ "GET", f"/repos/{repository}/collaborators/{urllib.parse.quote(username)}/permission", token
132
+ )
133
+ return str(payload.get("permission", "none"))
134
+
135
+ def branch_head(self, repository: str, installation_id: int, branch: str) -> str:
136
+ token = self.installation_token(installation_id)
137
+ encoded = urllib.parse.quote(branch, safe="")
138
+ payload = self._request("GET", f"/repos/{repository}/git/ref/heads/{encoded}", token)
139
+ obj = payload.get("object")
140
+ if not isinstance(obj, dict) or not obj.get("sha"):
141
+ raise GitHubAPIError("GitHub branch ref did not contain a SHA")
142
+ return str(obj["sha"])
143
+
144
+ def file_text(self, repository: str, installation_id: int, path: str, ref: str) -> str | None:
145
+ token = self.installation_token(installation_id)
146
+ encoded_path = "/".join(urllib.parse.quote(part) for part in path.split("/"))
147
+ try:
148
+ payload = self._request(
149
+ "GET",
150
+ f"/repos/{repository}/contents/{encoded_path}?ref={urllib.parse.quote(ref, safe='')}",
151
+ token,
152
+ )
153
+ except GitHubAPIError as exc:
154
+ if "HTTP 404" in str(exc):
155
+ return None
156
+ raise
157
+ content = payload.get("content")
158
+ if not isinstance(content, str):
159
+ return None
160
+ return base64.b64decode(content.encode("ascii")).decode("utf-8-sig")
161
+
162
+ def commit_file_at_parent(
163
+ self,
164
+ repository: str,
165
+ installation_id: int,
166
+ branch: str,
167
+ parent_sha: str,
168
+ path: str,
169
+ content: str,
170
+ message: str,
171
+ ) -> str:
172
+ token = self.installation_token(installation_id)
173
+ parent = self._request("GET", f"/repos/{repository}/git/commits/{parent_sha}", token)
174
+ tree = parent.get("tree")
175
+ if not isinstance(tree, dict) or not tree.get("sha"):
176
+ raise GitHubAPIError("Parent commit did not contain a tree SHA")
177
+ created_tree = self._request(
178
+ "POST",
179
+ f"/repos/{repository}/git/trees",
180
+ token,
181
+ {
182
+ "base_tree": str(tree["sha"]),
183
+ "tree": [{"path": path, "mode": "100644", "type": "blob", "content": content}],
184
+ },
185
+ )
186
+ commit = self._request(
187
+ "POST",
188
+ f"/repos/{repository}/git/commits",
189
+ token,
190
+ {"message": message, "tree": str(created_tree["sha"]), "parents": [parent_sha]},
191
+ )
192
+ commit_sha = str(commit["sha"])
193
+ encoded_branch = urllib.parse.quote(branch, safe="")
194
+ self._request(
195
+ "PATCH",
196
+ f"/repos/{repository}/git/refs/heads/{encoded_branch}",
197
+ token,
198
+ {"sha": commit_sha, "force": False},
199
+ )
200
+ return commit_sha
ptsip/app/server.py ADDED
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .github_client import GitHubAppClient
13
+ from .service import DecisionService
14
+ from .store import DecisionStore
15
+
16
+ MAX_REQUEST_BYTES = 1024 * 1024
17
+
18
+
19
+ class _Handler(BaseHTTPRequestHandler):
20
+ server_version = "PTSIPControlPlane/0.3.1"
21
+
22
+ @property
23
+ def service(self) -> DecisionService:
24
+ return self.server.service # type: ignore[attr-defined]
25
+
26
+ @property
27
+ def agent_token(self) -> str:
28
+ return self.server.agent_token # type: ignore[attr-defined]
29
+
30
+ @property
31
+ def webhook_secret(self) -> bytes:
32
+ return self.server.webhook_secret # type: ignore[attr-defined]
33
+
34
+ def _json(self, code: int, payload: dict[str, object]) -> None:
35
+ raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
36
+ self.send_response(code)
37
+ self.send_header("Content-Type", "application/json; charset=utf-8")
38
+ self.send_header("Content-Length", str(len(raw)))
39
+ self.end_headers()
40
+ self.wfile.write(raw)
41
+
42
+ def _body(self) -> bytes:
43
+ length = int(self.headers.get("Content-Length", "0"))
44
+ if length < 0 or length > MAX_REQUEST_BYTES:
45
+ raise ValueError("request body is too large")
46
+ return self.rfile.read(length)
47
+
48
+ def _authorized(self) -> bool:
49
+ header = self.headers.get("Authorization", "")
50
+ return bool(self.agent_token) and hmac.compare_digest(header, f"Bearer {self.agent_token}")
51
+
52
+ def do_GET(self) -> None:
53
+ if self.path == "/healthz":
54
+ self._json(200, {"status": "ok", "service": "ptsip-control-plane", "version": "0.3.1"})
55
+ return
56
+ self._json(404, {"error": "not found"})
57
+
58
+ def do_POST(self) -> None:
59
+ try:
60
+ raw = self._body()
61
+ if self.path == "/github/webhook":
62
+ signature = self.headers.get("X-Hub-Signature-256", "")
63
+ expected = "sha256=" + hmac.new(self.webhook_secret, raw, hashlib.sha256).hexdigest()
64
+ if not self.webhook_secret or not hmac.compare_digest(signature, expected):
65
+ self._json(401, {"error": "invalid webhook signature"})
66
+ return
67
+ payload = json.loads(raw.decode("utf-8"))
68
+ if not isinstance(payload, dict):
69
+ raise ValueError("webhook payload must be an object")
70
+ event = self.headers.get("X-GitHub-Event", "")
71
+ self.service.register_installation_event(payload)
72
+ result = self.service.issue_comment(payload) if event == "issue_comment" else {"status": "ACCEPTED"}
73
+ self._json(200, result)
74
+ return
75
+
76
+ if not self._authorized():
77
+ self._json(401, {"error": "unauthorized"})
78
+ return
79
+ payload = json.loads(raw.decode("utf-8"))
80
+ if not isinstance(payload, dict):
81
+ raise ValueError("request body must be an object")
82
+ if self.path == "/v1/gate":
83
+ self._json(200, self.service.gate(payload))
84
+ elif self.path == "/v1/decision":
85
+ self._json(200, self.service.decision(payload))
86
+ elif self.path == "/v1/resolve":
87
+ self._json(200, self.service.resolve_agent(payload))
88
+ elif self.path == "/v1/application":
89
+ self._json(200, self.service.application(payload))
90
+ else:
91
+ self._json(404, {"error": "not found"})
92
+ except (KeyError, ValueError) as exc:
93
+ self._json(400, {"error": str(exc)})
94
+ except Exception as exc:
95
+ self._json(500, {"error": str(exc)})
96
+
97
+ def log_message(self, format: str, *args: Any) -> None:
98
+ if os.environ.get("PTSIP_APP_QUIET") != "1":
99
+ super().log_message(format, *args)
100
+
101
+
102
+ def _parser() -> argparse.ArgumentParser:
103
+ parser = argparse.ArgumentParser(prog="ptsip-app", description="PTSIP GitHub App decision control plane")
104
+ parser.add_argument("--host", default=os.environ.get("PTSIP_APP_HOST", "127.0.0.1"))
105
+ parser.add_argument("--port", type=int, default=int(os.environ.get("PTSIP_APP_PORT", "8080")))
106
+ parser.add_argument("--db", default=os.environ.get("PTSIP_APP_DB", "ptsip-control-plane.sqlite3"))
107
+ return parser
108
+
109
+
110
+ def main(argv: list[str] | None = None) -> int:
111
+ args = _parser().parse_args(argv)
112
+ agent_token = os.environ.get("PTSIP_CONTROL_PLANE_TOKEN", "")
113
+ webhook_secret = os.environ.get("PTSIP_GITHUB_WEBHOOK_SECRET", "")
114
+ if not agent_token:
115
+ raise SystemExit("PTSIP_CONTROL_PLANE_TOKEN is required")
116
+ if not webhook_secret:
117
+ raise SystemExit("PTSIP_GITHUB_WEBHOOK_SECRET is required")
118
+ store = DecisionStore(Path(args.db))
119
+ service = DecisionService(store, GitHubAppClient())
120
+ server = ThreadingHTTPServer((args.host, args.port), _Handler)
121
+ server.service = service # type: ignore[attr-defined]
122
+ server.agent_token = agent_token # type: ignore[attr-defined]
123
+ server.webhook_secret = webhook_secret.encode("utf-8") # type: ignore[attr-defined]
124
+ try:
125
+ server.serve_forever()
126
+ except KeyboardInterrupt:
127
+ pass
128
+ finally:
129
+ server.server_close()
130
+ return 0