code-analysis-client 1.6.93__tar.gz → 1.6.95__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.
Files changed (25) hide show
  1. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/PKG-INFO +1 -1
  2. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/client.py +31 -2
  3. code_analysis_client-1.6.95/code_analysis_client/schema_disk_cache.py +207 -0
  4. code_analysis_client-1.6.95/code_analysis_client/version.txt +1 -0
  5. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client.egg-info/PKG-INFO +1 -1
  6. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client.egg-info/SOURCES.txt +1 -0
  7. code_analysis_client-1.6.93/code_analysis_client/version.txt +0 -1
  8. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/README.md +0 -0
  9. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/__init__.py +0 -0
  10. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/commands_proxy.py +0 -0
  11. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/config.py +0 -0
  12. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/exceptions.py +0 -0
  13. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/file_session.py +0 -0
  14. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/py.typed +0 -0
  15. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/queue_wait.py +0 -0
  16. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/responses.py +0 -0
  17. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/server_api.py +0 -0
  18. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/server_schema.py +0 -0
  19. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/universal_file.py +0 -0
  20. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client/validation.py +0 -0
  21. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client.egg-info/dependency_links.txt +0 -0
  22. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client.egg-info/requires.txt +0 -0
  23. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/code_analysis_client.egg-info/top_level.txt +0 -0
  24. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/pyproject.toml +0 -0
  25. {code_analysis_client-1.6.93 → code_analysis_client-1.6.95}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-analysis-client
3
- Version: 1.6.93
3
+ Version: 1.6.95
4
4
  Summary: Async JSON-RPC client for the code-analysis MCP server (mcp-proxy-adapter JsonRpcClient)
5
5
  Author-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
6
6
  Requires-Python: >=3.10
@@ -32,6 +32,11 @@ from code_analysis_client.queue_wait import (
32
32
  wait_for_job,
33
33
  )
34
34
  from code_analysis_client.universal_file import UniversalFileClient
35
+ from code_analysis_client.schema_disk_cache import (
36
+ clear_cached_schemas,
37
+ load_cached_schema,
38
+ store_cached_schema,
39
+ )
35
40
  from code_analysis_client.server_schema import fetch_command_schema_from_server
36
41
  from code_analysis_client.validation import (
37
42
  prepare_params_for_schema,
@@ -127,8 +132,15 @@ class CodeAnalysisAsyncClient:
127
132
  return self._commands_proxy
128
133
 
129
134
  def clear_command_schema_cache(self) -> None:
130
- """Drop cached command schemas (after ``reload`` or when server definitions change)."""
135
+ """Drop cached command schemas (after ``reload`` or when server definitions change).
136
+
137
+ Clears both the in-memory cache and this server's on-disk schema
138
+ cache (:mod:`code_analysis_client.schema_disk_cache`, bug 8e6acb34) —
139
+ otherwise the very next call would just repopulate the in-memory
140
+ cache from a now-stale disk entry, defeating the point of clearing it.
141
+ """
131
142
  self._command_schema_cache.clear()
143
+ clear_cached_schemas(self._rpc.base_url)
132
144
 
133
145
  @property
134
146
  def file_sessions(self) -> FileSessionClient:
@@ -143,11 +155,28 @@ class CodeAnalysisAsyncClient:
143
155
  async def get_command_schema(
144
156
  self, command: str, *, refresh: bool = False
145
157
  ) -> Dict[str, Any]:
146
- """Fetch input JSON schema for ``command`` using server ``help`` (with in-memory cache)."""
158
+ """Fetch input JSON schema for ``command`` using server ``help``.
159
+
160
+ Checked in order (bug 8e6acb34, Fix 1): the in-memory per-instance
161
+ cache, then a bounded-TTL on-disk cache shared across process
162
+ invocations against the same server
163
+ (:mod:`code_analysis_client.schema_disk_cache`) — this is what
164
+ removes the ~2.7ms ``help`` round trip that a fresh client instance
165
+ would otherwise pay on every first use of a given command, since most
166
+ real callers are short-lived processes that never benefit from an
167
+ in-memory-only cache. ``refresh=True`` skips both caches and always
168
+ re-fetches from the server, repopulating them afterward.
169
+ """
147
170
  if not refresh and command in self._command_schema_cache:
148
171
  return self._command_schema_cache[command]
172
+ if not refresh:
173
+ cached = load_cached_schema(self._rpc.base_url, command)
174
+ if cached is not None:
175
+ self._command_schema_cache[command] = cached
176
+ return cached
149
177
  schema = await fetch_command_schema_from_server(self._rpc, command)
150
178
  self._command_schema_cache[command] = schema
179
+ store_cached_schema(self._rpc.base_url, command, schema)
151
180
  return schema
152
181
 
153
182
  async def _execute(
@@ -0,0 +1,207 @@
1
+ """
2
+ Cross-process on-disk cache for per-command JSON schemas (bug 8e6acb34, Fix 1).
3
+
4
+ Root cause this addresses: :meth:`CodeAnalysisAsyncClient.get_command_schema`
5
+ already memoizes a schema for the lifetime of one client instance
6
+ (``self._command_schema_cache``), but that in-memory cache starts empty on
7
+ every new process. Most real callers (one-shot CLI invocations, the
8
+ ``pipeline`` live checks, individual agent tool calls) construct a fresh
9
+ :class:`~code_analysis_client.client.CodeAnalysisAsyncClient`, call a command
10
+ once, and exit -- so the in-memory cache never pays for itself and every
11
+ single ``call_validated``/``commands.<name>()`` invocation pays a full extra
12
+ ``help(cmdname)`` network round trip before the actual command call.
13
+
14
+ Measured on the deployed server (192.168.254.26:15010), warm TCP connection,
15
+ first ``call_validated("health", {})`` on a fresh client instance (schema
16
+ cache miss) vs the same call with schema already cached in memory: the
17
+ ``help`` round trip costs ~2.7ms median -- matching bug 8e6acb34's reported
18
+ ~3.0ms "our own wrapper" figure almost exactly. This module removes that
19
+ network round trip on repeat runs by persisting each fetched schema to a
20
+ small JSON file on local disk, keyed by (server base URL, command name),
21
+ with a bounded TTL so a stale schema is never trusted for long after a
22
+ server-side deploy changes it.
23
+
24
+ This does NOT weaken validation: the exact same schema shape is used either
25
+ way, just fetched from local disk instead of the network when it is still
26
+ fresh. ``refresh=True`` (existing :meth:`get_command_schema` parameter) and
27
+ :meth:`CodeAnalysisAsyncClient.clear_command_schema_cache` (which now also
28
+ purges the on-disk entries for that server) remain the explicit invalidation
29
+ paths for when a caller knows the server's command schemas changed (e.g.
30
+ after ``reload``).
31
+
32
+ Configuration (env vars, all optional):
33
+ ``CODE_ANALYSIS_CLIENT_SCHEMA_CACHE_DIR`` -- override the cache root
34
+ (default: ``~/.cache/code_analysis_client/schema``).
35
+ ``CODE_ANALYSIS_CLIENT_SCHEMA_CACHE_TTL_SECONDS`` -- override the
36
+ freshness window (default: 300 seconds -- long enough to amortize
37
+ the round trip across a short-lived process's calls and across
38
+ back-to-back process invocations, short enough that a schema change
39
+ shipped by a deploy is picked up within five minutes without any
40
+ explicit action).
41
+ ``CODE_ANALYSIS_CLIENT_DISABLE_SCHEMA_CACHE`` -- set to ``1``/``true`` to
42
+ disable on-disk caching entirely and fall back to the original
43
+ per-instance-only, always-network behavior.
44
+
45
+ Every function here is best-effort: any I/O or (de)serialization failure is
46
+ swallowed and treated as a cache miss / no-op. A broken or unwritable cache
47
+ directory must never break a command call, only forfeit the speedup.
48
+
49
+ Author: Vasiliy Zdanovskiy
50
+ email: vasilyvz@gmail.com
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import hashlib
56
+ import json
57
+ import os
58
+ import time
59
+ from pathlib import Path
60
+ from typing import Any, Dict, Optional
61
+
62
+ _ENV_CACHE_DIR = "CODE_ANALYSIS_CLIENT_SCHEMA_CACHE_DIR"
63
+ _ENV_TTL_SECONDS = "CODE_ANALYSIS_CLIENT_SCHEMA_CACHE_TTL_SECONDS"
64
+ _ENV_DISABLE = "CODE_ANALYSIS_CLIENT_DISABLE_SCHEMA_CACHE"
65
+
66
+ _DEFAULT_TTL_SECONDS = 300.0
67
+ _TRUTHY = {"1", "true", "yes", "on"}
68
+
69
+
70
+ def _disabled() -> bool:
71
+ """Return True when on-disk schema caching is disabled via env var."""
72
+ return os.environ.get(_ENV_DISABLE, "").strip().lower() in _TRUTHY
73
+
74
+
75
+ def _cache_root() -> Optional[Path]:
76
+ """Return the configured cache root directory, or None when disabled."""
77
+ if _disabled():
78
+ return None
79
+ override = os.environ.get(_ENV_CACHE_DIR)
80
+ if override:
81
+ return Path(override)
82
+ return Path.home() / ".cache" / "code_analysis_client" / "schema"
83
+
84
+
85
+ def _ttl_seconds() -> float:
86
+ """Return the configured freshness window in seconds."""
87
+ raw = os.environ.get(_ENV_TTL_SECONDS)
88
+ if raw:
89
+ try:
90
+ return float(raw)
91
+ except ValueError:
92
+ pass
93
+ return _DEFAULT_TTL_SECONDS
94
+
95
+
96
+ def _server_dir(root: Path, base_url: str) -> Path:
97
+ """Return the per-server subdirectory for ``base_url`` under ``root``."""
98
+ server_key = hashlib.sha256(base_url.encode("utf-8")).hexdigest()[:16]
99
+ return root / server_key
100
+
101
+
102
+ def _safe_command_filename(command: str) -> str:
103
+ """Return a filesystem-safe file name for ``command``."""
104
+ safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in command)
105
+ return f"{safe}.json"
106
+
107
+
108
+ def _entry_path(base_url: str, command: str) -> Optional[Path]:
109
+ """Return the cache file path for (base_url, command), or None if disabled/invalid.
110
+
111
+ ``base_url`` must be a real string (a mock or other stand-in used by
112
+ tests is not, and is deliberately treated as "caching unavailable" so
113
+ unit tests exercising a mocked transport never touch the filesystem).
114
+ """
115
+ if not isinstance(base_url, str) or not base_url or not isinstance(command, str):
116
+ return None
117
+ root = _cache_root()
118
+ if root is None:
119
+ return None
120
+ return _server_dir(root, base_url) / _safe_command_filename(command)
121
+
122
+
123
+ def load_cached_schema(base_url: str, command: str) -> Optional[Dict[str, Any]]:
124
+ """Return a fresh-enough disk-cached schema for (base_url, command), else None.
125
+
126
+ Args:
127
+ base_url: The server's base URL (``JsonRpcClient.base_url``), used as
128
+ the cache partition key.
129
+ command: Command name whose schema is wanted.
130
+
131
+ Returns:
132
+ The cached schema dict when a readable, unexpired entry exists;
133
+ ``None`` on any cache miss, staleness, or I/O/parse failure.
134
+ """
135
+ path = _entry_path(base_url, command)
136
+ if path is None:
137
+ return None
138
+ try:
139
+ if not path.exists():
140
+ return None
141
+ raw = json.loads(path.read_text(encoding="utf-8"))
142
+ except (OSError, ValueError):
143
+ return None
144
+ if not isinstance(raw, dict):
145
+ return None
146
+ fetched_at = raw.get("fetched_at")
147
+ schema = raw.get("schema")
148
+ if not isinstance(fetched_at, (int, float)) or not isinstance(schema, dict):
149
+ return None
150
+ if (time.time() - fetched_at) > _ttl_seconds():
151
+ return None
152
+ return schema
153
+
154
+
155
+ def store_cached_schema(base_url: str, command: str, schema: Dict[str, Any]) -> None:
156
+ """Best-effort persist ``schema`` for (base_url, command) to local disk.
157
+
158
+ Writes to a process-unique temp file first and renames it over the final
159
+ path (atomic on the same filesystem) so a concurrent reader never sees a
160
+ partially written file. Never raises: a write failure only forfeits the
161
+ speedup, it must not break the calling command.
162
+
163
+ Args:
164
+ base_url: The server's base URL, used as the cache partition key.
165
+ command: Command name the schema belongs to.
166
+ schema: The schema dict to persist (as returned by the server's
167
+ ``help`` command for this command name).
168
+ """
169
+ path = _entry_path(base_url, command)
170
+ if path is None:
171
+ return
172
+ try:
173
+ path.parent.mkdir(parents=True, exist_ok=True)
174
+ tmp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp")
175
+ tmp_path.write_text(
176
+ json.dumps({"fetched_at": time.time(), "schema": schema}),
177
+ encoding="utf-8",
178
+ )
179
+ tmp_path.replace(path)
180
+ except OSError:
181
+ return
182
+
183
+
184
+ def clear_cached_schemas(base_url: str) -> None:
185
+ """Best-effort delete every on-disk cached schema entry for ``base_url``.
186
+
187
+ Args:
188
+ base_url: The server's base URL whose cached entries should be
189
+ forgotten (e.g. after the caller learns the server reloaded its
190
+ command definitions).
191
+ """
192
+ if not isinstance(base_url, str) or not base_url:
193
+ return
194
+ root = _cache_root()
195
+ if root is None:
196
+ return
197
+ server_dir = _server_dir(root, base_url)
198
+ try:
199
+ if not server_dir.exists():
200
+ return
201
+ for entry in server_dir.iterdir():
202
+ try:
203
+ entry.unlink()
204
+ except OSError:
205
+ continue
206
+ except OSError:
207
+ return
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-analysis-client
3
- Version: 1.6.93
3
+ Version: 1.6.95
4
4
  Summary: Async JSON-RPC client for the code-analysis MCP server (mcp-proxy-adapter JsonRpcClient)
5
5
  Author-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
6
6
  Requires-Python: >=3.10
@@ -9,6 +9,7 @@ code_analysis_client/file_session.py
9
9
  code_analysis_client/py.typed
10
10
  code_analysis_client/queue_wait.py
11
11
  code_analysis_client/responses.py
12
+ code_analysis_client/schema_disk_cache.py
12
13
  code_analysis_client/server_api.py
13
14
  code_analysis_client/server_schema.py
14
15
  code_analysis_client/universal_file.py