wydcode 0.4.3
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.
- package/LICENSE +100 -0
- package/NOTICE +5 -0
- package/README.md +434 -0
- package/SECURITY.md +64 -0
- package/SOCRA_SOURCE.md +36 -0
- package/TRADEMARKS.md +12 -0
- package/bin/wydcode.js +74 -0
- package/examples/behavior-preference-card.json +12 -0
- package/examples/exact-recurrence-card.json +11 -0
- package/examples/heldout-gene-evidence.json +6 -0
- package/examples/humaneval-ornith.md +29 -0
- package/examples/large_context_json_diagnostic.py +187 -0
- package/fixtures/failing-node-repo/package.json +9 -0
- package/fixtures/failing-node-repo/src/math.js +3 -0
- package/fixtures/failing-node-repo/test/math.test.js +8 -0
- package/package.json +55 -0
- package/pyproject.toml +24 -0
- package/socra_harness/__init__.py +3 -0
- package/socra_harness/attempts.py +76 -0
- package/socra_harness/central_traces.py +627 -0
- package/socra_harness/cli.py +265 -0
- package/socra_harness/eval/__init__.py +1 -0
- package/socra_harness/eval/humaneval.py +388 -0
- package/socra_harness/events.py +39 -0
- package/socra_harness/init.py +206 -0
- package/socra_harness/job/__init__.py +2 -0
- package/socra_harness/job/events.py +132 -0
- package/socra_harness/job/lock.py +90 -0
- package/socra_harness/job/manager.py +3785 -0
- package/socra_harness/job/planner.py +253 -0
- package/socra_harness/job/state.py +106 -0
- package/socra_harness/mumpix_trace_bridge.cjs +32 -0
- package/socra_harness/product.py +533 -0
- package/socra_harness/recurrence.py +263 -0
- package/socra_harness/scan/__init__.py +1 -0
- package/socra_harness/scan/repo.py +85 -0
- package/socra_harness/serve/__init__.py +1 -0
- package/socra_harness/serve/local_proxy.py +293 -0
- package/socra_harness/start.py +331 -0
- package/socra_harness/tui/__init__.py +1 -0
- package/socra_harness/tui/app.py +1114 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def now_ms() -> int:
|
|
10
|
+
return int(time.time() * 1000)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventLog:
|
|
14
|
+
def __init__(self, path: Path | None):
|
|
15
|
+
self.path = path
|
|
16
|
+
if self.path:
|
|
17
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
def emit(self, event: str, **payload: Any) -> None:
|
|
20
|
+
if not self.path:
|
|
21
|
+
return
|
|
22
|
+
row = {"ts_ms": now_ms(), "event": event, **payload}
|
|
23
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
24
|
+
handle.write(json.dumps(row, sort_keys=True) + "\n")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_events(path: Path, limit: int = 200) -> list[dict[str, Any]]:
|
|
28
|
+
if not path.exists():
|
|
29
|
+
return []
|
|
30
|
+
if limit <= 0:
|
|
31
|
+
return []
|
|
32
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
33
|
+
out = []
|
|
34
|
+
for line in lines[-limit:]:
|
|
35
|
+
try:
|
|
36
|
+
out.append(json.loads(line))
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
continue
|
|
39
|
+
return out
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_ENDPOINT = "http://127.0.0.1:11439/v1"
|
|
14
|
+
DEFAULT_CONTEXT_BUDGET = 8192
|
|
15
|
+
DEFAULT_MAX_OUTPUT = 8192
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_git(project: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
19
|
+
return subprocess.run(
|
|
20
|
+
["git", *args],
|
|
21
|
+
cwd=project,
|
|
22
|
+
text=True,
|
|
23
|
+
stdout=subprocess.PIPE,
|
|
24
|
+
stderr=subprocess.PIPE,
|
|
25
|
+
check=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def require_git_repo(project: Path) -> Path:
|
|
30
|
+
result = run_git(project, ["rev-parse", "--show-toplevel"])
|
|
31
|
+
if result.returncode != 0:
|
|
32
|
+
raise SystemExit("wydcode init requires a git repository. Run git init first.")
|
|
33
|
+
return Path(result.stdout.strip()).resolve()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def git_dirty(project: Path) -> bool:
|
|
37
|
+
result = run_git(project, ["status", "--porcelain"])
|
|
38
|
+
if result.returncode != 0:
|
|
39
|
+
return True
|
|
40
|
+
return bool(result.stdout.strip())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect_package_manager(project: Path) -> str | None:
|
|
44
|
+
if (project / "pnpm-lock.yaml").exists():
|
|
45
|
+
return "pnpm"
|
|
46
|
+
if (project / "yarn.lock").exists():
|
|
47
|
+
return "yarn"
|
|
48
|
+
if (project / "bun.lockb").exists() or (project / "bun.lock").exists():
|
|
49
|
+
return "bun"
|
|
50
|
+
if (project / "package-lock.json").exists():
|
|
51
|
+
return "npm"
|
|
52
|
+
if (project / "package.json").exists():
|
|
53
|
+
return "npm"
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_package_json(project: Path) -> dict[str, Any]:
|
|
58
|
+
path = project / "package.json"
|
|
59
|
+
if not path.exists():
|
|
60
|
+
return {}
|
|
61
|
+
try:
|
|
62
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except json.JSONDecodeError as exc:
|
|
64
|
+
raise SystemExit(f"package.json is not valid JSON: {exc}") from exc
|
|
65
|
+
if not isinstance(value, dict):
|
|
66
|
+
raise SystemExit("package.json must contain a JSON object.")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def package_command(package_manager: str, script: str) -> list[str]:
|
|
71
|
+
if package_manager == "npm":
|
|
72
|
+
return ["npm", "run", script]
|
|
73
|
+
if package_manager == "pnpm":
|
|
74
|
+
return ["pnpm", script]
|
|
75
|
+
if package_manager == "yarn":
|
|
76
|
+
return ["yarn", script]
|
|
77
|
+
if package_manager == "bun":
|
|
78
|
+
return ["bun", "run", script]
|
|
79
|
+
return [package_manager, "run", script]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def detect_test_command(project: Path, package_manager: str | None) -> list[str] | None:
|
|
83
|
+
package = load_package_json(project)
|
|
84
|
+
scripts = package.get("scripts") if isinstance(package.get("scripts"), dict) else {}
|
|
85
|
+
if package_manager and isinstance(scripts, dict):
|
|
86
|
+
# Prefer explicitly finite CI/run scripts over watch-oriented defaults.
|
|
87
|
+
for script in ["test:run", "test:ci", "ci:test", "test", "check", "build"]:
|
|
88
|
+
value = scripts.get(script)
|
|
89
|
+
if isinstance(value, str) and value.strip():
|
|
90
|
+
return package_command(package_manager, script)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def detect_project_type(project: Path) -> str:
|
|
95
|
+
if (project / "package.json").exists():
|
|
96
|
+
package = load_package_json(project)
|
|
97
|
+
deps = {}
|
|
98
|
+
for key in ["dependencies", "devDependencies"]:
|
|
99
|
+
if isinstance(package.get(key), dict):
|
|
100
|
+
deps.update(package[key])
|
|
101
|
+
if "next" in deps:
|
|
102
|
+
return "node-nextjs"
|
|
103
|
+
if "react" in deps or "vite" in deps:
|
|
104
|
+
return "node-frontend"
|
|
105
|
+
return "node"
|
|
106
|
+
return "unknown"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def probe_endpoint(endpoint: str, timeout: float = 1.0) -> dict[str, Any]:
|
|
110
|
+
base = endpoint.rstrip("/")
|
|
111
|
+
candidates = [base.removesuffix("/v1") + "/health", base + "/models"]
|
|
112
|
+
errors: list[str] = []
|
|
113
|
+
for url in candidates:
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
116
|
+
body = response.read(4096).decode("utf-8", errors="replace")
|
|
117
|
+
parsed: Any
|
|
118
|
+
try:
|
|
119
|
+
parsed = json.loads(body) if body else {}
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
parsed = {"raw": body[:256]}
|
|
122
|
+
return {"ok": True, "url": url, "status": "reachable", "response": parsed}
|
|
123
|
+
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
|
124
|
+
errors.append(f"{url}: {exc}")
|
|
125
|
+
return {"ok": False, "status": "unreachable", "errors": errors}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_config(args: argparse.Namespace, project: Path, repo_root: Path) -> dict[str, Any]:
|
|
129
|
+
package_manager = detect_package_manager(repo_root)
|
|
130
|
+
test_command = detect_test_command(repo_root, package_manager)
|
|
131
|
+
endpoint = args.endpoint or os.environ.get("SOCRA_ENDPOINT") or DEFAULT_ENDPOINT
|
|
132
|
+
model = args.model or os.environ.get("SOCRA_MODEL")
|
|
133
|
+
endpoint_probe = probe_endpoint(endpoint) if not args.skip_endpoint_probe else {"ok": None, "status": "skipped"}
|
|
134
|
+
return {
|
|
135
|
+
"schema_version": 1,
|
|
136
|
+
"tool": "socra-harness",
|
|
137
|
+
"project_root": str(repo_root),
|
|
138
|
+
"initialized_from": str(project),
|
|
139
|
+
"git": {
|
|
140
|
+
"required": True,
|
|
141
|
+
"root": str(repo_root),
|
|
142
|
+
"dirty_at_init": git_dirty(repo_root),
|
|
143
|
+
},
|
|
144
|
+
"project": {
|
|
145
|
+
"type": detect_project_type(repo_root),
|
|
146
|
+
"package_manager": package_manager,
|
|
147
|
+
"test_command": test_command,
|
|
148
|
+
},
|
|
149
|
+
"model": {
|
|
150
|
+
"endpoint": endpoint,
|
|
151
|
+
"model": model,
|
|
152
|
+
"local_first": True,
|
|
153
|
+
"hosted_fallback": False,
|
|
154
|
+
"endpoint_probe": endpoint_probe,
|
|
155
|
+
},
|
|
156
|
+
"budgets": {
|
|
157
|
+
"context_budget_tokens": args.context_budget,
|
|
158
|
+
"max_output_tokens": args.max_output,
|
|
159
|
+
},
|
|
160
|
+
"output_contract": {
|
|
161
|
+
"record_finish_reason": True,
|
|
162
|
+
"record_content_chars": True,
|
|
163
|
+
"record_reasoning_chars": True,
|
|
164
|
+
"classify_length_without_content_separately": True,
|
|
165
|
+
"prefer_module_sized_tasks": True,
|
|
166
|
+
},
|
|
167
|
+
"promotion": {
|
|
168
|
+
"source_writes_require_promote": True,
|
|
169
|
+
"review_and_approval_are_separate_steps": True,
|
|
170
|
+
"approval_is_hash_bound": True,
|
|
171
|
+
},
|
|
172
|
+
"traces": {
|
|
173
|
+
"cross_project": True,
|
|
174
|
+
"home": "~/.wydcode/traces",
|
|
175
|
+
"source_of_truth": "hash_chained_jsonl",
|
|
176
|
+
"mumpix_index": True,
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def init_main(args: argparse.Namespace) -> int:
|
|
182
|
+
project = args.project.expanduser().resolve()
|
|
183
|
+
if not project.exists():
|
|
184
|
+
raise SystemExit(f"Project path does not exist: {project}")
|
|
185
|
+
if not project.is_dir():
|
|
186
|
+
raise SystemExit(f"Project path is not a directory: {project}")
|
|
187
|
+
|
|
188
|
+
repo_root = require_git_repo(project)
|
|
189
|
+
config = build_config(args, project, repo_root)
|
|
190
|
+
socra_dir = repo_root / ".socra"
|
|
191
|
+
socra_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
config_path = socra_dir / "config.json"
|
|
193
|
+
if config_path.exists() and not args.force:
|
|
194
|
+
raise SystemExit(f"{config_path} already exists. Pass --force to overwrite.")
|
|
195
|
+
config_path.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
196
|
+
|
|
197
|
+
print(f"Initialized Socra config: {config_path}")
|
|
198
|
+
print(f"Project: {repo_root}")
|
|
199
|
+
if config["git"]["dirty_at_init"]:
|
|
200
|
+
print("Warning: git worktree is dirty at init.")
|
|
201
|
+
package_manager = config["project"]["package_manager"] or "not detected"
|
|
202
|
+
print(f"Package manager: {package_manager}")
|
|
203
|
+
print(f"Test command: {' '.join(config['project']['test_command']) if config['project']['test_command'] else 'not detected'}")
|
|
204
|
+
probe = config["model"]["endpoint_probe"]
|
|
205
|
+
print(f"Endpoint: {config['model']['endpoint']} ({probe.get('status')})")
|
|
206
|
+
return 0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import warnings
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
EVENT_SCHEMA_VERSION = 1
|
|
14
|
+
IDEMPOTENT_DUPLICATE_EVENT_TYPES = {
|
|
15
|
+
"creator_endpoint_health_checked",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JobEventError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass(frozen=True)
|
|
24
|
+
class JobEvent:
|
|
25
|
+
schema_version: int
|
|
26
|
+
seq: int
|
|
27
|
+
event_id: str
|
|
28
|
+
timestamp: str
|
|
29
|
+
job_id: str
|
|
30
|
+
type: str
|
|
31
|
+
payload: dict[str, Any]
|
|
32
|
+
|
|
33
|
+
def to_json(self) -> dict[str, Any]:
|
|
34
|
+
return dataclasses.asdict(self)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def now_iso() -> str:
|
|
38
|
+
return dt.datetime.now(dt.timezone.utc).isoformat()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def event_id(seq: int) -> str:
|
|
42
|
+
return f"evt-{seq:06d}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_event(data: dict[str, Any]) -> JobEvent:
|
|
46
|
+
required = {"schema_version", "seq", "event_id", "timestamp", "job_id", "type", "payload"}
|
|
47
|
+
missing = required - set(data)
|
|
48
|
+
if missing:
|
|
49
|
+
raise JobEventError(f"event missing required fields: {sorted(missing)}")
|
|
50
|
+
if data["schema_version"] != EVENT_SCHEMA_VERSION:
|
|
51
|
+
raise JobEventError(f"schema_migration_required: {data['schema_version']}")
|
|
52
|
+
if not isinstance(data["seq"], int) or data["seq"] < 1:
|
|
53
|
+
raise JobEventError("event seq must be a positive integer")
|
|
54
|
+
if data["event_id"] != event_id(data["seq"]):
|
|
55
|
+
raise JobEventError(f"event_id does not match seq: {data['event_id']} vs {event_id(data['seq'])}")
|
|
56
|
+
if not isinstance(data["payload"], dict):
|
|
57
|
+
raise JobEventError("event payload must be an object")
|
|
58
|
+
return JobEvent(
|
|
59
|
+
schema_version=data["schema_version"],
|
|
60
|
+
seq=data["seq"],
|
|
61
|
+
event_id=data["event_id"],
|
|
62
|
+
timestamp=str(data["timestamp"]),
|
|
63
|
+
job_id=str(data["job_id"]),
|
|
64
|
+
type=str(data["type"]),
|
|
65
|
+
payload=data["payload"],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_events(path: Path) -> list[JobEvent]:
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return []
|
|
72
|
+
events: list[JobEvent] = []
|
|
73
|
+
previous_seq = 0
|
|
74
|
+
previous_fingerprint: str | None = None
|
|
75
|
+
text = path.read_text(encoding="utf-8")
|
|
76
|
+
lines = text.splitlines()
|
|
77
|
+
final_line_may_be_torn = bool(lines) and not text.endswith("\n")
|
|
78
|
+
for line_no, line in enumerate(lines, start=1):
|
|
79
|
+
if not line.strip():
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
raw = json.loads(line)
|
|
83
|
+
except json.JSONDecodeError as exc:
|
|
84
|
+
if final_line_may_be_torn and line_no == len(lines):
|
|
85
|
+
warnings.warn(f"dropping torn final event line in {path}: {exc}", RuntimeWarning)
|
|
86
|
+
break
|
|
87
|
+
raise JobEventError(f"invalid JSON at line {line_no}: {exc}") from exc
|
|
88
|
+
event = validate_event(raw)
|
|
89
|
+
if event.seq != previous_seq + 1:
|
|
90
|
+
raise JobEventError(f"non-monotonic event seq at line {line_no}: {event.seq} after {previous_seq}")
|
|
91
|
+
fingerprint_payload = {
|
|
92
|
+
"job_id": event.job_id,
|
|
93
|
+
"type": event.type,
|
|
94
|
+
"payload": event.payload,
|
|
95
|
+
}
|
|
96
|
+
fingerprint = hashlib.sha256(json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")).hexdigest()
|
|
97
|
+
if previous_fingerprint == fingerprint and event.type not in IDEMPOTENT_DUPLICATE_EVENT_TYPES:
|
|
98
|
+
warnings.warn(
|
|
99
|
+
f"adjacent duplicate event content fingerprint in {path}: line {line_no}",
|
|
100
|
+
RuntimeWarning,
|
|
101
|
+
)
|
|
102
|
+
previous_fingerprint = fingerprint
|
|
103
|
+
previous_seq = event.seq
|
|
104
|
+
events.append(event)
|
|
105
|
+
return events
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class JobEventLog:
|
|
109
|
+
def __init__(self, path: Path, job_id: str):
|
|
110
|
+
self.path = path
|
|
111
|
+
self.job_id = job_id
|
|
112
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
|
|
114
|
+
def next_seq(self) -> int:
|
|
115
|
+
return len(read_events(self.path)) + 1
|
|
116
|
+
|
|
117
|
+
def append(self, event_type: str, payload: dict[str, Any] | None = None) -> JobEvent:
|
|
118
|
+
seq = self.next_seq()
|
|
119
|
+
event = JobEvent(
|
|
120
|
+
schema_version=EVENT_SCHEMA_VERSION,
|
|
121
|
+
seq=seq,
|
|
122
|
+
event_id=event_id(seq),
|
|
123
|
+
timestamp=now_iso(),
|
|
124
|
+
job_id=self.job_id,
|
|
125
|
+
type=event_type,
|
|
126
|
+
payload=payload or {},
|
|
127
|
+
)
|
|
128
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
129
|
+
handle.write(json.dumps(event.to_json(), ensure_ascii=False) + "\n")
|
|
130
|
+
handle.flush()
|
|
131
|
+
os.fsync(handle.fileno())
|
|
132
|
+
return event
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JobLockError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclasses.dataclass
|
|
16
|
+
class JobLock:
|
|
17
|
+
path: Path
|
|
18
|
+
stale_after_seconds: int = 24 * 60 * 60
|
|
19
|
+
|
|
20
|
+
def __enter__(self) -> JobLock:
|
|
21
|
+
self.acquire()
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
25
|
+
self.release()
|
|
26
|
+
|
|
27
|
+
def acquire(self) -> None:
|
|
28
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
while True:
|
|
30
|
+
try:
|
|
31
|
+
fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
32
|
+
except FileExistsError:
|
|
33
|
+
if self._remove_if_stale():
|
|
34
|
+
continue
|
|
35
|
+
raise JobLockError(f"job is already locked: {self.path}") from None
|
|
36
|
+
payload = {
|
|
37
|
+
"pid": os.getpid(),
|
|
38
|
+
"created_at": time.time(),
|
|
39
|
+
}
|
|
40
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
41
|
+
handle.write(json.dumps(payload, indent=2) + "\n")
|
|
42
|
+
handle.flush()
|
|
43
|
+
os.fsync(handle.fileno())
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
def release(self) -> None:
|
|
47
|
+
try:
|
|
48
|
+
payload = self._read_payload()
|
|
49
|
+
if int(payload.get("pid", -1)) == os.getpid():
|
|
50
|
+
self.path.unlink()
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
return
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
def _read_payload(self) -> dict[str, Any]:
|
|
57
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
58
|
+
|
|
59
|
+
def _remove_if_stale(self) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
payload = self._read_payload()
|
|
62
|
+
pid = int(payload.get("pid", -1))
|
|
63
|
+
created_at = float(payload.get("created_at", 0))
|
|
64
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
65
|
+
pid = -1
|
|
66
|
+
created_at = 0
|
|
67
|
+
|
|
68
|
+
if pid > 0 and self._pid_is_alive(pid):
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
if created_at and time.time() - created_at < self.stale_after_seconds and pid > 0:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
self.path.unlink()
|
|
76
|
+
return True
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
return True
|
|
79
|
+
except OSError:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _pid_is_alive(pid: int) -> bool:
|
|
84
|
+
try:
|
|
85
|
+
os.kill(pid, 0)
|
|
86
|
+
except ProcessLookupError:
|
|
87
|
+
return False
|
|
88
|
+
except PermissionError:
|
|
89
|
+
return True
|
|
90
|
+
return True
|