parapet 0.1.0__tar.gz
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.
- parapet-0.1.0/.gitignore +34 -0
- parapet-0.1.0/PKG-INFO +12 -0
- parapet-0.1.0/parapet/__init__.py +131 -0
- parapet-0.1.0/parapet/header.py +85 -0
- parapet-0.1.0/parapet/sidecar.py +212 -0
- parapet-0.1.0/parapet/transport.py +257 -0
- parapet-0.1.0/parapet/trust.py +157 -0
- parapet-0.1.0/pyproject.toml +21 -0
- parapet-0.1.0/tests/__init__.py +2 -0
- parapet-0.1.0/tests/test_header.py +111 -0
- parapet-0.1.0/tests/test_init.py +89 -0
- parapet-0.1.0/tests/test_sidecar.py +146 -0
- parapet-0.1.0/tests/test_transport.py +212 -0
- parapet-0.1.0/tests/test_trust.py +183 -0
parapet-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Rust
|
|
2
|
+
parapet/target/
|
|
3
|
+
|
|
4
|
+
# Python
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.egg-info/
|
|
7
|
+
.venv/
|
|
8
|
+
*.pyc
|
|
9
|
+
|
|
10
|
+
# IDE
|
|
11
|
+
.idea/
|
|
12
|
+
|
|
13
|
+
# Claude Code
|
|
14
|
+
.claude/
|
|
15
|
+
|
|
16
|
+
# Internal docs
|
|
17
|
+
implement/
|
|
18
|
+
|
|
19
|
+
# Eval datasets (reproducible via scripts/)
|
|
20
|
+
schema/eval/opensource_*.yaml
|
|
21
|
+
schema/eval/multiturn_*.yaml
|
|
22
|
+
schema/eval/out_of_scope/
|
|
23
|
+
|
|
24
|
+
# LaTeX build artifacts
|
|
25
|
+
paper/*.aux
|
|
26
|
+
paper/*.log
|
|
27
|
+
paper/*.out
|
|
28
|
+
|
|
29
|
+
# OS
|
|
30
|
+
.DS_Store
|
|
31
|
+
Thumbs.db
|
|
32
|
+
|
|
33
|
+
NUL
|
|
34
|
+
nul
|
parapet-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parapet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Parapet LLM proxy firewall
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
12
|
+
Requires-Dist: respx>=0.22; extra == 'dev'
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright 2026 The Parapet Project
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
# Parapet Python SDK — defined in M1.11
|
|
5
|
+
#
|
|
6
|
+
# Public API:
|
|
7
|
+
# parapet.init(config_path) — validate config, start engine sidecar, patch httpx
|
|
8
|
+
# parapet.session(...) — context manager that sets W3C Baggage for the scope
|
|
9
|
+
# parapet.untrusted(content) — mark a string as untrusted content
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from contextvars import ContextVar
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Generator
|
|
17
|
+
|
|
18
|
+
from parapet.header import build_baggage_header
|
|
19
|
+
from parapet.sidecar import EngineState, start_engine
|
|
20
|
+
from parapet.transport import patch_httpx
|
|
21
|
+
from parapet.trust import _ensure_registry
|
|
22
|
+
from typing import Iterable
|
|
23
|
+
|
|
24
|
+
__all__ = ["init", "session", "untrusted"]
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("parapet")
|
|
27
|
+
|
|
28
|
+
_DEFAULT_PORT = 9800
|
|
29
|
+
|
|
30
|
+
# Module-level engine state — single instance shared across init/session.
|
|
31
|
+
_engine_state = EngineState()
|
|
32
|
+
|
|
33
|
+
# Active baggage for the current context (set inside session(), cleared on exit).
|
|
34
|
+
_active_baggage: ContextVar[str | None] = ContextVar(
|
|
35
|
+
"parapet_active_baggage", default=None
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionContext:
|
|
40
|
+
"""Holds the baggage string for the duration of a session scope."""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("baggage",)
|
|
43
|
+
|
|
44
|
+
def __init__(self, baggage: str) -> None:
|
|
45
|
+
self.baggage = baggage
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def init(
|
|
49
|
+
config_path: str,
|
|
50
|
+
*,
|
|
51
|
+
port: int = _DEFAULT_PORT,
|
|
52
|
+
extra_hosts: Iterable[str] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Initialize the parapet SDK.
|
|
55
|
+
|
|
56
|
+
Validates that *config_path* exists, starts the engine sidecar (if not
|
|
57
|
+
already running), and patches httpx to route LLM requests through the
|
|
58
|
+
engine.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
config_path: Path to the parapet.yaml configuration file.
|
|
62
|
+
port: Port for the engine to listen on (default 9800).
|
|
63
|
+
extra_hosts: Additional LLM API hosts to intercept beyond the
|
|
64
|
+
built-in defaults (e.g., ``["api.together.xyz"]``).
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
FileNotFoundError: If *config_path* does not exist.
|
|
68
|
+
"""
|
|
69
|
+
path = Path(config_path)
|
|
70
|
+
if not path.exists():
|
|
71
|
+
raise FileNotFoundError(
|
|
72
|
+
f"Config file not found: {config_path}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
hosts = frozenset(extra_hosts) if extra_hosts else None
|
|
76
|
+
start_engine(config_path=config_path, port=port, state=_engine_state)
|
|
77
|
+
patch_httpx(port=port, extra_hosts=hosts)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@contextmanager
|
|
81
|
+
def session(
|
|
82
|
+
*,
|
|
83
|
+
user_id: str | None = None,
|
|
84
|
+
role: str | None = None,
|
|
85
|
+
) -> Generator[SessionContext, None, None]:
|
|
86
|
+
"""Set W3C Baggage headers for the duration of this scope.
|
|
87
|
+
|
|
88
|
+
Must be called after ``init()``.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
user_id: Optional user identifier to include in baggage.
|
|
92
|
+
role: Optional role string to include in baggage.
|
|
93
|
+
|
|
94
|
+
Yields:
|
|
95
|
+
A ``SessionContext`` with the computed baggage string.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
RuntimeError: If ``init()`` has not been called.
|
|
99
|
+
"""
|
|
100
|
+
if not _engine_state.initialized:
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
"parapet.session() called before init(). Call parapet.init() first."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
baggage = build_baggage_header(user_id=user_id, role=role)
|
|
106
|
+
token = _active_baggage.set(baggage)
|
|
107
|
+
try:
|
|
108
|
+
yield SessionContext(baggage=baggage)
|
|
109
|
+
finally:
|
|
110
|
+
_active_baggage.reset(token)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def untrusted(content: str, source: str = "unknown") -> str:
|
|
114
|
+
"""Mark a string as untrusted content.
|
|
115
|
+
|
|
116
|
+
Registers the string in the per-context trust registry so the SDK
|
|
117
|
+
can locate it in serialized requests and emit byte-range trust spans.
|
|
118
|
+
|
|
119
|
+
The string is returned unchanged for inline use::
|
|
120
|
+
|
|
121
|
+
prompt = f"Context: {parapet.untrusted(rag_snippet, source='rag')}"
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
content: The untrusted content string.
|
|
125
|
+
source: Provenance label (e.g., "rag", "user_input", "web_search").
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The content string, unchanged.
|
|
129
|
+
"""
|
|
130
|
+
registry = _ensure_registry()
|
|
131
|
+
return registry.register(content, source=source)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Copyright 2026 The Parapet Project
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Header serialization for Parapet SDK.
|
|
5
|
+
|
|
6
|
+
Includes:
|
|
7
|
+
- W3C Baggage header (``baggage: key1=value1,key2=value2``)
|
|
8
|
+
- X-Guard-Trust header (``inline:<base64-encoded JSON>``)
|
|
9
|
+
|
|
10
|
+
Values are percent-encoded to handle special characters safely.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
__all__ = ["build_baggage_header", "build_trust_header"]
|
|
20
|
+
|
|
21
|
+
MAX_TRUST_HEADER_BYTES = 4096 # 4KB inline limit
|
|
22
|
+
|
|
23
|
+
# Characters that must be percent-encoded in baggage values.
|
|
24
|
+
# W3C Baggage uses a subset of RFC 3986 — commas, semicolons, equals,
|
|
25
|
+
# and spaces are delimiters and must not appear unescaped in values.
|
|
26
|
+
_SAFE_CHARS = "" # encode everything except unreserved chars
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _encode_value(value: str) -> str:
|
|
30
|
+
"""Percent-encode a baggage value per W3C Baggage spec."""
|
|
31
|
+
return quote(value, safe=_SAFE_CHARS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_baggage_header(
|
|
35
|
+
*,
|
|
36
|
+
user_id: str | None = None,
|
|
37
|
+
role: str | None = None,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Build a W3C Baggage header value from optional fields.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
user_id: Optional user identifier.
|
|
43
|
+
role: Optional role string.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Baggage header value string (e.g. ``user_id=u_1,role=admin``).
|
|
47
|
+
Returns empty string when no fields are provided.
|
|
48
|
+
"""
|
|
49
|
+
parts: list[str] = []
|
|
50
|
+
|
|
51
|
+
if user_id is not None:
|
|
52
|
+
parts.append(f"user_id={_encode_value(user_id)}")
|
|
53
|
+
|
|
54
|
+
if role is not None:
|
|
55
|
+
parts.append(f"role={_encode_value(role)}")
|
|
56
|
+
|
|
57
|
+
return ",".join(parts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_trust_header(spans: list) -> str | None:
|
|
61
|
+
"""Serialize trust spans as X-Guard-Trust header value.
|
|
62
|
+
|
|
63
|
+
Format: ``inline:<base64-encoded JSON>``
|
|
64
|
+
JSON schema: ``[{"s": start, "e": end, "src": source}, ...]``
|
|
65
|
+
|
|
66
|
+
Returns None if no spans or if encoded value exceeds 4KB limit.
|
|
67
|
+
Compact keys (s/e/src) to minimize header size.
|
|
68
|
+
"""
|
|
69
|
+
if not spans:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
compact = [{"s": s.start, "e": s.end, "src": s.source} for s in spans]
|
|
73
|
+
payload = json.dumps(compact, separators=(",", ":")).encode("utf-8")
|
|
74
|
+
|
|
75
|
+
if len(payload) > MAX_TRUST_HEADER_BYTES:
|
|
76
|
+
logging.getLogger("parapet.header").warning(
|
|
77
|
+
"X-Guard-Trust header exceeds %d byte limit (%d spans, %d bytes); dropping",
|
|
78
|
+
MAX_TRUST_HEADER_BYTES,
|
|
79
|
+
len(spans),
|
|
80
|
+
len(payload),
|
|
81
|
+
)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
encoded = base64.b64encode(payload).decode("ascii")
|
|
85
|
+
return f"inline:{encoded}"
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Copyright 2026 The Parapet Project
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Engine process lifecycle management.
|
|
5
|
+
|
|
6
|
+
Manages spawning the Rust engine as a subprocess, PID file tracking,
|
|
7
|
+
stale PID cleanup, heartbeat watchdog, and clean shutdown via atexit.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import IO
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
__all__ = ["EngineState", "start_engine", "stop_engine"]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("parapet.sidecar")
|
|
26
|
+
|
|
27
|
+
_HEARTBEAT_INTERVAL_SECONDS = 10
|
|
28
|
+
_PARAPET_DIR = Path.home() / ".parapet"
|
|
29
|
+
_DEFAULT_PID_PATH = _PARAPET_DIR / "engine.pid"
|
|
30
|
+
_DEFAULT_LOG_PATH = _PARAPET_DIR / "engine.log"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class EngineState:
|
|
35
|
+
"""Holds the state of the running engine sidecar.
|
|
36
|
+
|
|
37
|
+
This is not global mutable state scattered across the module --
|
|
38
|
+
it is a single, explicit object passed to functions that need it.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
process: subprocess.Popen | None = None
|
|
42
|
+
initialized: bool = False
|
|
43
|
+
port: int | None = None
|
|
44
|
+
_log_file: IO[bytes] | None = field(default=None, repr=False)
|
|
45
|
+
_heartbeat_thread: threading.Thread | None = field(
|
|
46
|
+
default=None, repr=False
|
|
47
|
+
)
|
|
48
|
+
_heartbeat_stop: threading.Event = field(
|
|
49
|
+
default_factory=threading.Event, repr=False
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_process_alive(pid: int) -> bool:
|
|
54
|
+
"""Check whether a process with the given PID is still running."""
|
|
55
|
+
try:
|
|
56
|
+
os.kill(pid, 0)
|
|
57
|
+
return True
|
|
58
|
+
except (OSError, ProcessLookupError):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _start_heartbeat(state: EngineState, port: int) -> None:
|
|
63
|
+
"""Start a daemon thread that pings the engine heartbeat endpoint."""
|
|
64
|
+
|
|
65
|
+
def _heartbeat_loop() -> None:
|
|
66
|
+
while not state._heartbeat_stop.is_set():
|
|
67
|
+
try:
|
|
68
|
+
httpx.get(
|
|
69
|
+
f"http://127.0.0.1:{port}/v1/heartbeat",
|
|
70
|
+
timeout=5.0,
|
|
71
|
+
)
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.debug("Heartbeat send failed (engine may be starting)")
|
|
74
|
+
state._heartbeat_stop.wait(_HEARTBEAT_INTERVAL_SECONDS)
|
|
75
|
+
|
|
76
|
+
thread = threading.Thread(
|
|
77
|
+
target=_heartbeat_loop,
|
|
78
|
+
name="parapet-heartbeat",
|
|
79
|
+
daemon=True,
|
|
80
|
+
)
|
|
81
|
+
thread.start()
|
|
82
|
+
state._heartbeat_thread = thread
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def start_engine(
|
|
86
|
+
config_path: str,
|
|
87
|
+
port: int,
|
|
88
|
+
*,
|
|
89
|
+
state: EngineState,
|
|
90
|
+
pid_path: Path = _DEFAULT_PID_PATH,
|
|
91
|
+
log_path: Path = _DEFAULT_LOG_PATH,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Spawn the Rust engine as a subprocess.
|
|
94
|
+
|
|
95
|
+
Idempotent: if the engine is already running (tracked via *state*),
|
|
96
|
+
this is a no-op. If a PID file exists from a previous run but the
|
|
97
|
+
process is dead, the stale PID is cleaned up and a fresh engine
|
|
98
|
+
is started.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
config_path: Path to the parapet.yaml configuration file.
|
|
102
|
+
port: Port for the engine to listen on.
|
|
103
|
+
state: Mutable engine state object.
|
|
104
|
+
pid_path: Path to write the engine PID file.
|
|
105
|
+
log_path: Path to write the engine log file.
|
|
106
|
+
"""
|
|
107
|
+
# Already running in this process -- reuse.
|
|
108
|
+
if state.initialized and state.process is not None:
|
|
109
|
+
if state.process.poll() is None:
|
|
110
|
+
logger.debug("Engine already running (pid=%d), reusing", state.process.pid)
|
|
111
|
+
return
|
|
112
|
+
# Process died unexpectedly; fall through to restart.
|
|
113
|
+
logger.warning("Engine process died unexpectedly, restarting")
|
|
114
|
+
state.process = None
|
|
115
|
+
state.initialized = False
|
|
116
|
+
|
|
117
|
+
# Check for stale PID file from a previous run.
|
|
118
|
+
if pid_path.exists():
|
|
119
|
+
try:
|
|
120
|
+
old_pid = int(pid_path.read_text().strip())
|
|
121
|
+
except (ValueError, OSError):
|
|
122
|
+
old_pid = None
|
|
123
|
+
|
|
124
|
+
if old_pid is not None and _is_process_alive(old_pid):
|
|
125
|
+
# Another engine is still running -- reuse it.
|
|
126
|
+
logger.info(
|
|
127
|
+
"Reusing existing engine from PID file (pid=%d)", old_pid
|
|
128
|
+
)
|
|
129
|
+
state.initialized = True
|
|
130
|
+
state.port = port
|
|
131
|
+
_start_heartbeat(state, port)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Stale PID file -- clean up.
|
|
135
|
+
logger.info("Cleaning up stale PID file (pid=%s)", old_pid)
|
|
136
|
+
pid_path.unlink(missing_ok=True)
|
|
137
|
+
|
|
138
|
+
# Ensure the parapet directory exists.
|
|
139
|
+
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
|
|
141
|
+
# Open log file for engine stdout/stderr.
|
|
142
|
+
log_file: IO[bytes] = open(log_path, "ab")
|
|
143
|
+
|
|
144
|
+
# Spawn the engine process.
|
|
145
|
+
proc = subprocess.Popen(
|
|
146
|
+
[
|
|
147
|
+
"parapet-engine",
|
|
148
|
+
"--config",
|
|
149
|
+
config_path,
|
|
150
|
+
"--port",
|
|
151
|
+
str(port),
|
|
152
|
+
],
|
|
153
|
+
stdout=log_file,
|
|
154
|
+
stderr=log_file,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
state.process = proc
|
|
158
|
+
state.initialized = True
|
|
159
|
+
state.port = port
|
|
160
|
+
state._log_file = log_file
|
|
161
|
+
|
|
162
|
+
# Write PID file.
|
|
163
|
+
pid_path.write_text(str(proc.pid))
|
|
164
|
+
logger.info("Engine started (pid=%d, port=%d)", proc.pid, port)
|
|
165
|
+
|
|
166
|
+
# Start heartbeat watchdog.
|
|
167
|
+
_start_heartbeat(state, port)
|
|
168
|
+
|
|
169
|
+
# Register atexit handler for clean shutdown.
|
|
170
|
+
atexit.register(stop_engine, state=state, pid_path=pid_path)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def stop_engine(
|
|
174
|
+
*,
|
|
175
|
+
state: EngineState,
|
|
176
|
+
pid_path: Path = _DEFAULT_PID_PATH,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Terminate the engine subprocess and clean up.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
state: The engine state to clean up.
|
|
182
|
+
pid_path: Path to the PID file to remove.
|
|
183
|
+
"""
|
|
184
|
+
# Signal heartbeat thread to stop.
|
|
185
|
+
state._heartbeat_stop.set()
|
|
186
|
+
|
|
187
|
+
if state.process is not None:
|
|
188
|
+
logger.info("Stopping engine (pid=%d)", state.process.pid)
|
|
189
|
+
try:
|
|
190
|
+
state.process.terminate()
|
|
191
|
+
state.process.wait(timeout=5)
|
|
192
|
+
except Exception:
|
|
193
|
+
logger.warning("Engine did not stop gracefully, killing")
|
|
194
|
+
try:
|
|
195
|
+
state.process.kill()
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
# Close the log file handle.
|
|
200
|
+
if state._log_file is not None:
|
|
201
|
+
try:
|
|
202
|
+
state._log_file.close()
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
state._log_file = None
|
|
206
|
+
|
|
207
|
+
state.process = None
|
|
208
|
+
state.initialized = False
|
|
209
|
+
|
|
210
|
+
if pid_path.exists():
|
|
211
|
+
pid_path.unlink(missing_ok=True)
|
|
212
|
+
logger.debug("Removed PID file %s", pid_path)
|