codeanalyzer-python 0.2.0__tar.gz → 0.2.1__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 (47) hide show
  1. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/PKG-INFO +24 -2
  2. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/README.md +22 -1
  3. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/__main__.py +9 -0
  4. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/core.py +100 -18
  5. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/neo4j/bolt.py +13 -2
  6. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/neo4j/catalog.py +2 -2
  7. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/neo4j/project.py +18 -10
  8. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/neo4j/rows.py +10 -5
  9. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/options/options.py +1 -0
  10. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/schema/__init__.py +2 -0
  11. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/schema/py_schema.py +15 -0
  12. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/pyproject.toml +5 -1
  13. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/schema.neo4j.json +3 -2
  14. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/.gitignore +0 -0
  15. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/LICENSE +0 -0
  16. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/NOTICE +0 -0
  17. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/__init__.py +0 -0
  18. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/config/__init__.py +0 -0
  19. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/config/config.py +0 -0
  20. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/jedi/__init__.py +0 -0
  21. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/jedi/jedi.py +0 -0
  22. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/neo4j/__init__.py +0 -0
  23. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/neo4j/cypher.py +0 -0
  24. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/neo4j/emit.py +0 -0
  25. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/neo4j/schema.py +0 -0
  26. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/options/__init__.py +0 -0
  27. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/py.typed +0 -0
  28. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/semantic_analysis/__init__.py +0 -0
  29. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/semantic_analysis/call_graph.py +0 -0
  30. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/semantic_analysis/codeql/__init__.py +0 -0
  31. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/semantic_analysis/codeql/codeql_analysis.py +0 -0
  32. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/semantic_analysis/codeql/codeql_exceptions.py +0 -0
  33. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/semantic_analysis/codeql/codeql_loader.py +0 -0
  34. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/semantic_analysis/codeql/codeql_query_runner.py +0 -0
  35. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/syntactic_analysis/__init__.py +0 -0
  36. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/syntactic_analysis/exceptions.py +0 -0
  37. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/syntactic_analysis/symbol_table_builder.py +0 -0
  38. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/utils/__init__.py +0 -0
  39. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/utils/logging.py +0 -0
  40. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/codeanalyzer/utils/progress_bar.py +0 -0
  41. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/schema-uml.drawio +0 -0
  42. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/test/fixtures/whole_applications/xarray/LICENSE +0 -0
  43. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/test/fixtures/whole_applications/xarray/README.md +0 -0
  44. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/test/fixtures/whole_applications/xarray/properties/README.md +0 -0
  45. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/test/fixtures/whole_applications/xarray/xarray/datatree_/LICENSE +0 -0
  46. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/test/fixtures/whole_applications/xarray/xarray/datatree_/README.md +0 -0
  47. {codeanalyzer_python-0.2.0 → codeanalyzer_python-0.2.1}/test/fixtures/whole_applications/xarray/xarray/datatree_/docs/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeanalyzer-python
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Static Analysis on Python source code using Jedi, CodeQL and Treesitter — emits analysis.json or a Neo4j property graph.
5
5
  Author-email: Rahul Krishna <i.m.ralk@gmail.com>
6
6
  License-File: LICENSE
@@ -29,6 +29,7 @@ Requires-Dist: typer<1.0.0,>=0.9.0; python_version < '3.11'
29
29
  Requires-Dist: typer<2.0.0,>=0.9.0; python_version >= '3.11'
30
30
  Requires-Dist: typing-extensions<5.0.0,>=4.0.0; python_version < '3.11'
31
31
  Requires-Dist: typing-extensions<6.0.0,>=4.5.0; python_version >= '3.11'
32
+ Requires-Dist: uv>=0.5.0
32
33
  Provides-Extra: neo4j
33
34
  Requires-Dist: neo4j<6.0.0,>=5.0.0; extra == 'neo4j'
34
35
  Description-Content-Type: text/markdown
@@ -43,7 +44,8 @@ Description-Content-Type: text/markdown
43
44
 
44
45
  [![PyPI](https://img.shields.io/pypi/v/codeanalyzer-python?style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/codeanalyzer-python/)
45
46
  [![Python](https://img.shields.io/pypi/pyversions/codeanalyzer-python?style=for-the-badge&logo=python&logoColor=white)](https://pypi.org/project/codeanalyzer-python/)
46
- [![Release](https://img.shields.io/github/actions/workflow/status/codellm-devkit/codeanalyzer-python/release.yml?style=for-the-badge&label=release&logo=github)](https://github.com/codellm-devkit/codeanalyzer-python/actions/workflows/release.yml)
47
+ [![GitHub release](https://img.shields.io/github/v/release/codellm-devkit/codeanalyzer-python?style=for-the-badge&logo=github&label=GitHub&color=2dba4e)](https://github.com/codellm-devkit/codeanalyzer-python/releases/latest)
48
+ [![Release](https://img.shields.io/github/actions/workflow/status/codellm-devkit/codeanalyzer-python/release.yml?style=for-the-badge&label=release&logo=githubactions&logoColor=white)](https://github.com/codellm-devkit/codeanalyzer-python/actions/workflows/release.yml)
47
49
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge)](./LICENSE)
48
50
 
49
51
  </div>
@@ -70,6 +72,7 @@ and merges them with the Jedi-derived edges, also backfilling callees Jedi could
70
72
  - [Prerequisites](#prerequisites)
71
73
  - [Install via pip (PyPI)](#install-via-pip-pypi)
72
74
  - [Install via shell script](#install-via-shell-script)
75
+ - [Install via Homebrew](#install-via-homebrew)
73
76
  - [Build from source](#build-from-source)
74
77
  - [Usage](#usage)
75
78
  - [Options](#options)
@@ -136,6 +139,15 @@ Install the CLI as an isolated tool with the one-line installer (provisions via
136
139
  curl --proto '=https' --tlsv1.2 -LsSf https://github.com/codellm-devkit/codeanalyzer-python/releases/latest/download/canpy-installer.sh | sh
137
140
  ```
138
141
 
142
+ ### Install via Homebrew
143
+
144
+ ```sh
145
+ brew install codellm-devkit/tap/codeanalyzer-python
146
+ ```
147
+
148
+ The formula depends on [uv](https://docs.astral.sh/uv/) and installs `canpy` as an isolated,
149
+ version-pinned uv tool (the package and its dependencies are resolved and cached on first run).
150
+
139
151
  ### Build from source
140
152
 
141
153
  This project uses [uv](https://docs.astral.sh/uv/) for dependency management.
@@ -240,6 +252,16 @@ $ canpy --help
240
252
  │ in analysis. │
241
253
  │ [default: │
242
254
  │ skip-tests] │
255
+ │ --no-venv --venv Skip virtualenv │
256
+ │ creation and │
257
+ │ dependency │
258
+ │ installation; │
259
+ │ resolve imports │
260
+ │ against the │
261
+ │ ambient Python │
262
+ │ environment │
263
+ │ instead. │
264
+ │ [default: venv] │
243
265
  │ --file-name PATH Analyze only the │
244
266
  │ specified file │
245
267
  │ (relative to │
@@ -8,7 +8,8 @@
8
8
 
9
9
  [![PyPI](https://img.shields.io/pypi/v/codeanalyzer-python?style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/codeanalyzer-python/)
10
10
  [![Python](https://img.shields.io/pypi/pyversions/codeanalyzer-python?style=for-the-badge&logo=python&logoColor=white)](https://pypi.org/project/codeanalyzer-python/)
11
- [![Release](https://img.shields.io/github/actions/workflow/status/codellm-devkit/codeanalyzer-python/release.yml?style=for-the-badge&label=release&logo=github)](https://github.com/codellm-devkit/codeanalyzer-python/actions/workflows/release.yml)
11
+ [![GitHub release](https://img.shields.io/github/v/release/codellm-devkit/codeanalyzer-python?style=for-the-badge&logo=github&label=GitHub&color=2dba4e)](https://github.com/codellm-devkit/codeanalyzer-python/releases/latest)
12
+ [![Release](https://img.shields.io/github/actions/workflow/status/codellm-devkit/codeanalyzer-python/release.yml?style=for-the-badge&label=release&logo=githubactions&logoColor=white)](https://github.com/codellm-devkit/codeanalyzer-python/actions/workflows/release.yml)
12
13
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge)](./LICENSE)
13
14
 
14
15
  </div>
@@ -35,6 +36,7 @@ and merges them with the Jedi-derived edges, also backfilling callees Jedi could
35
36
  - [Prerequisites](#prerequisites)
36
37
  - [Install via pip (PyPI)](#install-via-pip-pypi)
37
38
  - [Install via shell script](#install-via-shell-script)
39
+ - [Install via Homebrew](#install-via-homebrew)
38
40
  - [Build from source](#build-from-source)
39
41
  - [Usage](#usage)
40
42
  - [Options](#options)
@@ -101,6 +103,15 @@ Install the CLI as an isolated tool with the one-line installer (provisions via
101
103
  curl --proto '=https' --tlsv1.2 -LsSf https://github.com/codellm-devkit/codeanalyzer-python/releases/latest/download/canpy-installer.sh | sh
102
104
  ```
103
105
 
106
+ ### Install via Homebrew
107
+
108
+ ```sh
109
+ brew install codellm-devkit/tap/codeanalyzer-python
110
+ ```
111
+
112
+ The formula depends on [uv](https://docs.astral.sh/uv/) and installs `canpy` as an isolated,
113
+ version-pinned uv tool (the package and its dependencies are resolved and cached on first run).
114
+
104
115
  ### Build from source
105
116
 
106
117
  This project uses [uv](https://docs.astral.sh/uv/) for dependency management.
@@ -205,6 +216,16 @@ $ canpy --help
205
216
  │ in analysis. │
206
217
  │ [default: │
207
218
  │ skip-tests] │
219
+ │ --no-venv --venv Skip virtualenv │
220
+ │ creation and │
221
+ │ dependency │
222
+ │ installation; │
223
+ │ resolve imports │
224
+ │ against the │
225
+ │ ambient Python │
226
+ │ environment │
227
+ │ instead. │
228
+ │ [default: venv] │
208
229
  │ --file-name PATH Analyze only the │
209
230
  │ specified file │
210
231
  │ (relative to │
@@ -104,6 +104,14 @@ def main(
104
104
  help="Skip test files in analysis.",
105
105
  ),
106
106
  ] = True,
107
+ no_venv: Annotated[
108
+ bool,
109
+ typer.Option(
110
+ "--no-venv/--venv",
111
+ help="Skip virtualenv creation and dependency installation; resolve "
112
+ "imports against the ambient Python environment instead.",
113
+ ),
114
+ ] = False,
107
115
  file_name: Annotated[
108
116
  Optional[Path],
109
117
  typer.Option(
@@ -144,6 +152,7 @@ def main(
144
152
  using_ray=using_ray,
145
153
  rebuild_analysis=rebuild_analysis,
146
154
  skip_tests=skip_tests,
155
+ no_venv=no_venv,
147
156
  file_name=file_name,
148
157
  cache_dir=cache_dir,
149
158
  clear_cache=clear_cache,
@@ -8,7 +8,13 @@ from typing import Any, Dict, Optional, Union, List
8
8
 
9
9
  import ray
10
10
  from codeanalyzer.utils import logger
11
- from codeanalyzer.schema import PyApplication, PyModule, model_dump_json, model_validate_json
11
+ from codeanalyzer.schema import (
12
+ PyApplication,
13
+ PyExternalSymbol,
14
+ PyModule,
15
+ model_dump_json,
16
+ model_validate_json,
17
+ )
12
18
  from codeanalyzer.schema.py_schema import PyCallEdge
13
19
  from codeanalyzer.semantic_analysis.call_graph import (
14
20
  jedi_call_graph_edges,
@@ -60,6 +66,7 @@ class Codeanalyzer:
60
66
  self.skip_tests = options.skip_tests
61
67
  self.using_codeql = options.using_codeql
62
68
  self.rebuild_analysis = options.rebuild_analysis
69
+ self.no_venv = options.no_venv
63
70
  self.cache_dir = (
64
71
  options.cache_dir.resolve() if options.cache_dir is not None else self.project_dir
65
72
  ) / ".codeanalyzer"
@@ -226,13 +233,41 @@ class Codeanalyzer:
226
233
  f"a working Python interpreter that can create virtual environments."
227
234
  )
228
235
 
236
+ @staticmethod
237
+ def _uv_bin() -> Optional[str]:
238
+ """Path to a uv binary: the one bundled with the ``uv`` PyPI package (a
239
+ dependency, so normally always present -- including inside a Docker image),
240
+ else a uv on PATH, else ``None`` (callers fall back to pip)."""
241
+ try:
242
+ from uv import find_uv_bin
243
+
244
+ return str(find_uv_bin())
245
+ except Exception:
246
+ return shutil.which("uv")
247
+
248
+ def _install_into_venv(self, venv_python: Path, args: List[str]) -> None:
249
+ """Install packages into the target venv, preferring uv for speed (parallel
250
+ downloads + a shared global cache) and falling back to the venv's own pip
251
+ when uv is unavailable."""
252
+ uv = self._uv_bin()
253
+ if uv:
254
+ cmd = [uv, "pip", "install", "--python", str(venv_python), *args]
255
+ else:
256
+ cmd = [str(venv_python), "-m", "pip", "install", *args]
257
+ self._cmd_exec_helper(cmd, cwd=self.project_dir, check=True)
258
+
229
259
  def __enter__(self) -> "Codeanalyzer":
230
260
  # If no virtualenv is provided, try to create one using requirements.txt or pyproject.toml
231
261
  venv_path = self.cache_dir / self.project_dir.name / "virtualenv"
232
262
  # Ensure the cache directory exists for this project
233
263
  venv_path.parent.mkdir(parents=True, exist_ok=True)
264
+ if self.no_venv:
265
+ logger.info(
266
+ "--no-venv: using the ambient Python environment "
267
+ "(skipping virtualenv creation and dependency installation)"
268
+ )
234
269
  # Create the virtual environment if it does not exist
235
- if not venv_path.exists() or self.rebuild_analysis:
270
+ if not self.no_venv and (not venv_path.exists() or self.rebuild_analysis):
236
271
  logger.info(f"(Re-)creating virtual environment at {venv_path}")
237
272
  self._cmd_exec_helper(
238
273
  [str(self._get_base_interpreter()), "-m", "venv", str(venv_path)],
@@ -249,24 +284,19 @@ class Codeanalyzer:
249
284
  ("test-requirements.txt", ["-r"]),
250
285
  ]
251
286
 
252
- for dep_file, pip_args in dependency_files:
287
+ for dep_file, _ in dependency_files:
253
288
  if (self.project_dir / dep_file).exists():
254
289
  logger.info(f"Installing dependencies from {dep_file}")
255
- self._cmd_exec_helper(
256
- [str(venv_python), "-m", "pip", "install", "-U"] + pip_args + [str(self.project_dir / dep_file)],
257
- cwd=self.project_dir,
258
- check=True,
290
+ self._install_into_venv(
291
+ venv_python,
292
+ ["--upgrade", "-r", str(self.project_dir / dep_file)],
259
293
  )
260
294
 
261
295
  # Handle Pipenv files
262
296
  if (self.project_dir / "Pipfile").exists():
263
297
  logger.info("Installing dependencies from Pipfile")
264
298
  # Note: This would require pipenv to be installed
265
- self._cmd_exec_helper(
266
- [str(venv_python), "-m", "pip", "install", "pipenv"],
267
- cwd=self.project_dir,
268
- check=True,
269
- )
299
+ self._install_into_venv(venv_python, ["pipenv"])
270
300
  self._cmd_exec_helper(
271
301
  ["pipenv", "install", "--dev"],
272
302
  cwd=self.project_dir,
@@ -289,14 +319,18 @@ class Codeanalyzer:
289
319
 
290
320
  if any((self.project_dir / file).exists() for file in package_definition_files):
291
321
  logger.info("Installing project in editable mode")
292
- self._cmd_exec_helper(
293
- [str(venv_python), "-m", "pip", "install", "-e", str(self.project_dir)],
294
- cwd=self.project_dir,
295
- check=True,
296
- )
322
+ self._install_into_venv(venv_python, ["-e", str(self.project_dir)])
297
323
  else:
298
324
  logger.warning("No package definition files found, skipping editable installation")
299
325
 
326
+ # Point Jedi at the analysis venv so it resolves the project's third-party
327
+ # imports. This runs on both a fresh build and a lazy reuse of an existing
328
+ # venv -- previously self.virtualenv stayed None, so the install above was
329
+ # never actually used by the symbol-table builder. With --no-venv we leave
330
+ # it None so Jedi resolves against the ambient interpreter instead.
331
+ if not self.no_venv and venv_path.exists():
332
+ self.virtualenv = venv_path
333
+
300
334
  if self.using_codeql:
301
335
  logger.info(f"(Re-)initializing CodeQL analysis for {self.project_dir}")
302
336
 
@@ -358,6 +392,43 @@ class Codeanalyzer:
358
392
  logger.info(f"Clearing cache directory: {self.cache_dir}")
359
393
  shutil.rmtree(self.cache_dir)
360
394
 
395
+ @staticmethod
396
+ def _compute_external_symbols(symbol_table, call_graph):
397
+ """Build the external-symbol map: every call-graph endpoint whose signature
398
+ is not a declared class/callable in the symbol table is an external (an
399
+ imported library or builtin member). ``name``/``module`` are derived from
400
+ the signature (best effort: split on the last dot)."""
401
+ declared = set()
402
+
403
+ def walk_callable(c):
404
+ declared.add(c.signature)
405
+ for ic in (c.inner_callables or {}).values():
406
+ walk_callable(ic)
407
+ for cl in (c.inner_classes or {}).values():
408
+ walk_class(cl)
409
+
410
+ def walk_class(cl):
411
+ declared.add(cl.signature)
412
+ for m in (cl.methods or {}).values():
413
+ walk_callable(m)
414
+ for ic in (cl.inner_classes or {}).values():
415
+ walk_class(ic)
416
+
417
+ for mod in symbol_table.values():
418
+ for c in (mod.functions or {}).values():
419
+ walk_callable(c)
420
+ for cl in (mod.classes or {}).values():
421
+ walk_class(cl)
422
+
423
+ externals: Dict[str, PyExternalSymbol] = {}
424
+ for edge in call_graph:
425
+ for sig in (edge.source, edge.target):
426
+ if sig in declared or sig in externals:
427
+ continue
428
+ module, name = sig.rsplit(".", 1) if "." in sig else (sig, sig)
429
+ externals[sig] = PyExternalSymbol(name=name, module=module)
430
+ return externals
431
+
361
432
  def analyze(self) -> PyApplication:
362
433
  """Analyze the project and return a PyApplication with symbol table.
363
434
 
@@ -397,8 +468,19 @@ class Codeanalyzer:
397
468
  jedi_edges = jedi_call_graph_edges(symbol_table)
398
469
  call_graph = merge_edges(jedi_edges, codeql_edges)
399
470
 
471
+ # Classify call-graph endpoints that are not declared in the symbol table
472
+ # (imported library / builtin members) once, so the JSON and Neo4j backends
473
+ # share one authoritative external-symbol set.
474
+ external_symbols = self._compute_external_symbols(symbol_table, call_graph)
475
+
400
476
  # Recreate pyapplication
401
- app = PyApplication.builder().symbol_table(symbol_table).call_graph(call_graph).build()
477
+ app = (
478
+ PyApplication.builder()
479
+ .symbol_table(symbol_table)
480
+ .call_graph(call_graph)
481
+ .external_symbols(external_symbols)
482
+ .build()
483
+ )
402
484
 
403
485
  # Save to cache
404
486
  self._save_analysis_cache(app, cache_file)
@@ -77,6 +77,13 @@ def bolt_writer(rows: GraphRows, cfg: BoltConfig, full_run: bool) -> None:
77
77
  for stmt in [*CONSTRAINTS, *INDEXES]:
78
78
  s.run(stmt)
79
79
 
80
+ # The application anchor (a shared node) — used to scope the orphan prune
81
+ # so it never touches modules belonging to a different :PyApplication.
82
+ app_name = next(
83
+ (n.value for n in rows.nodes if n.labels and n.labels[0] == "PyApplication"),
84
+ None,
85
+ )
86
+
80
87
  # Partition nodes by owning module; shared nodes have no _module.
81
88
  by_module: Dict[str, List[NodeRow]] = {}
82
89
  shared: List[NodeRow] = []
@@ -135,13 +142,17 @@ def bolt_writer(rows: GraphRows, cfg: BoltConfig, full_run: bool) -> None:
135
142
  _upsert_edges(session, neo4j, edges)
136
143
 
137
144
  # 6. orphan prune — only safe on a full run (a targeted run can't tell deleted from untargeted).
138
- if full_run:
145
+ # Scope to THIS application's anchor so a full run for application B never
146
+ # deletes application A's modules from a shared database.
147
+ if full_run and app_name is not None:
139
148
  present = list(by_module.keys())
140
149
  with session() as s:
141
150
  res = s.run(
142
- "MATCH (m:PyModule) WHERE NOT m.file_key IN $present "
151
+ "MATCH (:PyApplication {name: $app})-[:PY_HAS_MODULE]->(m:PyModule) "
152
+ "WHERE NOT m.file_key IN $present "
143
153
  f"OPTIONAL MATCH (m)-{DESCENDANTS}->(x) DETACH DELETE x, m "
144
154
  "RETURN count(m) AS pruned",
155
+ app=app_name,
145
156
  present=present,
146
157
  )
147
158
  pruned = res.single()
@@ -34,7 +34,7 @@ from typing import Dict, List
34
34
 
35
35
  from codeanalyzer.neo4j.schema import CONSTRAINTS, INDEXES
36
36
 
37
- SCHEMA_VERSION = "1.0.0"
37
+ SCHEMA_VERSION = "1.1.0"
38
38
 
39
39
  # PropType ∈ {"string", "integer", "float", "boolean", "string[]", "integer[]"}.
40
40
 
@@ -119,7 +119,7 @@ NODE_LABELS: List[NodeLabel] = [
119
119
  "PyExternal",
120
120
  "PySymbol",
121
121
  "signature",
122
- {"signature": "string", "name": "string"},
122
+ {"signature": "string", "name": "string", "module": "string"},
123
123
  ),
124
124
  NodeLabel("PyPackage", "PyPackage", "name", {"name": "string"}),
125
125
  NodeLabel(
@@ -60,11 +60,12 @@ def project(app: PyApplication, app_name: str) -> GraphRows:
60
60
  b.edge("PY_HAS_MODULE", app_ref, mod_ref)
61
61
  _project_module_body(b, file_key, mod_ref, mod)
62
62
 
63
- # The aggregated :PY_CALLS twin. Endpoints not present in the symbol table become
64
- # :PyExternal ghost nodes (the analyzer already preserves them as ghost nodes).
63
+ # The aggregated :PY_CALLS twin. Endpoints listed in app.external_symbols become
64
+ # :PyExternal ghost nodes; the rest are declared :PySymbol nodes already emitted.
65
+ externals = app.external_symbols or {}
65
66
  for e in app.call_graph:
66
- src = _call_endpoint(b, e.source)
67
- tgt = _call_endpoint(b, e.target)
67
+ src = _call_endpoint(b, e.source, externals)
68
+ tgt = _call_endpoint(b, e.target, externals)
68
69
  b.edge("PY_CALLS", src, tgt, _call_edge_props(e.weight, list(e.provenance or [])))
69
70
 
70
71
  return b.finish()
@@ -74,13 +75,20 @@ def _sym(signature: str) -> NodeRef:
74
75
  return NodeRef("PySymbol", "signature", signature)
75
76
 
76
77
 
77
- def _call_endpoint(b: RowBuilder, signature: str) -> NodeRef:
78
- """A call-graph endpoint: a known callable already emitted, or a phantom
79
- :PyExternal symbol materialized on demand for a ghost target."""
80
- if b.has_key(signature):
78
+ def _call_endpoint(b: RowBuilder, signature: str, externals: dict) -> NodeRef:
79
+ """A call-graph endpoint: a declared callable already emitted, or an external
80
+ symbol (imported library / builtin member) materialized as a :PyExternal ghost.
81
+
82
+ Classification is authoritative -- it comes from ``app.external_symbols``, not a
83
+ "present in the graph" heuristic -- so an imported module name (which exists only
84
+ as a :PyPackage) can never shadow the call target. A small fallback still
85
+ materializes an external for any endpoint that is neither declared nor listed."""
86
+ ext = externals.get(signature)
87
+ if ext is None and b.has_key("PySymbol", signature):
81
88
  return _sym(signature)
82
- name = signature.rsplit(".", 1)[-1] if "." in signature else signature
83
- return b.node(["PySymbol", "PyExternal"], "signature", signature, {"name": name})
89
+ name = ext.name if ext is not None else (signature.rsplit(".", 1)[-1] if "." in signature else signature)
90
+ module = ext.module if ext is not None else None
91
+ return b.node(["PySymbol", "PyExternal"], "signature", signature, prune({"name": name, "module": module}))
84
92
 
85
93
 
86
94
  # ----------------------------------------------------------------------------------------------
@@ -83,7 +83,11 @@ class RowBuilder:
83
83
  self._nodes: Dict[str, NodeRow] = {} # key: f"{labels[0]} {value}"
84
84
  self._edges: List[EdgeRow] = []
85
85
  self._deferred: List[EdgeRow] = [] # edges gated against node existence at finish()
86
- self._keys: set = set() # every node value seen, for resolved-gating
86
+ # (merge_label, value) of every node seen, for resolved-gating. Keyed by
87
+ # label too so a :PyPackage name can't shadow a :PySymbol signature (and
88
+ # vice versa) — otherwise a call to an imported module name like ``os``
89
+ # resolves to a :PySymbol node that was never created and the edge is lost.
90
+ self._keys: set = set()
87
91
 
88
92
  def node(self, labels: List[str], key_prop: str, value: str, props: Props) -> NodeRef:
89
93
  """Upsert a node. Re-seeing the same ``(labels[0], value)`` merges props
@@ -98,7 +102,7 @@ class RowBuilder:
98
102
  existing.labels.append(label)
99
103
  else:
100
104
  self._nodes[node_id] = NodeRow(list(labels), key_prop, value, dict(props))
101
- self._keys.add(value)
105
+ self._keys.add((labels[0], value))
102
106
  return NodeRef(labels[0], key_prop, value)
103
107
 
104
108
  def edge(self, type_: str, from_ref: NodeRef, to_ref: NodeRef, props: Optional[Props] = None) -> None:
@@ -121,12 +125,13 @@ class RowBuilder:
121
125
  )
122
126
  )
123
127
 
124
- def has_key(self, value: str) -> bool:
125
- return value in self._keys
128
+ def has_key(self, label: str, value: str) -> bool:
129
+ """Whether a node with this ``(merge_label, value)`` identity was emitted."""
130
+ return (label, value) in self._keys
126
131
 
127
132
  def finish(self) -> GraphRows:
128
133
  for e in self._deferred:
129
- if e.to_ref.value in self._keys:
134
+ if (e.to_ref.label, e.to_ref.value) in self._keys:
130
135
  self._edges.append(e)
131
136
  nodes = sorted(self._nodes.values(), key=lambda n: f"{n.labels[0]} {n.value}")
132
137
  edges = sorted(self._edges, key=lambda e: f"{e.type} {e.from_ref.value} {e.to_ref.value}")
@@ -38,6 +38,7 @@ class AnalysisOptions:
38
38
  using_ray: bool = False
39
39
  rebuild_analysis: bool = False
40
40
  skip_tests: bool = True
41
+ no_venv: bool = False
41
42
  file_name: Optional[Path] = None
42
43
  cache_dir: Optional[Path] = None
43
44
  clear_cache: bool = False
@@ -8,6 +8,7 @@ from .py_schema import (
8
8
  PyClass,
9
9
  PyClassAttribute,
10
10
  PyComment,
11
+ PyExternalSymbol,
11
12
  PyImport,
12
13
  PyModule,
13
14
  PyVariableDeclaration,
@@ -15,6 +16,7 @@ from .py_schema import (
15
16
 
16
17
  __all__ = [
17
18
  "PyApplication",
19
+ "PyExternalSymbol",
18
20
  "PyImport",
19
21
  "PyComment",
20
22
  "PyModule",
@@ -358,6 +358,17 @@ class PyCallEdge(BaseModel):
358
358
  provenance: List[Literal["jedi", "codeql", "joern"]] = []
359
359
 
360
360
 
361
+ @builder
362
+ @msgpk
363
+ class PyExternalSymbol(BaseModel):
364
+ """A call-graph target outside the analyzed project -- an imported library or
365
+ builtin member. Mirrors codeanalyzer-typescript's ``TSExternalSymbol`` and is
366
+ keyed in ``PyApplication.external_symbols`` by its call-graph signature."""
367
+
368
+ name: str # the member/short name, e.g. "get" for "requests.get"
369
+ module: Optional[str] = None # best-effort owning module, e.g. "requests"
370
+
371
+
361
372
  @builder
362
373
  @msgpk
363
374
  class PyApplication(BaseModel):
@@ -365,3 +376,7 @@ class PyApplication(BaseModel):
365
376
 
366
377
  symbol_table: Dict[str, PyModule]
367
378
  call_graph: List[PyCallEdge] = []
379
+ # Call-graph endpoints not declared in the symbol table (imported library /
380
+ # builtin members), keyed by signature. Populated by the analyzer so every
381
+ # backend (JSON and Neo4j) shares one authoritative external-symbol set.
382
+ external_symbols: Dict[str, PyExternalSymbol] = {}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codeanalyzer-python"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Static Analysis on Python source code using Jedi, CodeQL and Treesitter — emits analysis.json or a Neo4j property graph."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -43,6 +43,10 @@ dependencies = [
43
43
  "ray==2.0.0; python_version < '3.11'",
44
44
  "ray>=2.10.0,<3.0.0; python_version >= '3.11'",
45
45
  "packaging>=25.0",
46
+ # uv -- installs the analyzed project's deps into the analysis venv quickly.
47
+ # Shipped as a self-contained binary in its wheel, so it's available wherever
48
+ # canpy is pip-installed (incl. Docker); core.py falls back to pip without it.
49
+ "uv>=0.5.0",
46
50
  ]
47
51
 
48
52
  [project.optional-dependencies]
@@ -1,5 +1,5 @@
1
1
  {
2
- "schema_version": "1.0.0",
2
+ "schema_version": "1.1.0",
3
3
  "generator": "codeanalyzer-python",
4
4
  "marker_labels": [],
5
5
  "node_labels": [
@@ -67,7 +67,8 @@
67
67
  "key": "signature",
68
68
  "properties": {
69
69
  "signature": "string",
70
- "name": "string"
70
+ "name": "string",
71
+ "module": "string"
71
72
  }
72
73
  },
73
74
  {