codeanalyzer-python 1.5.4__tar.gz → 1.6.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.
Files changed (98) hide show
  1. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/PKG-INFO +11 -19
  2. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/README.md +1 -1
  3. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/__main__.py +3 -4
  4. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/artifacts/config_keys.py +1 -6
  5. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/artifacts/parsers.py +1 -6
  6. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/core.py +119 -3
  7. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/access_paths.py +1 -1
  8. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/builder.py +52 -5
  9. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/cfg.py +2 -2
  10. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/defuse.py +1 -1
  11. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/pdg.py +1 -1
  12. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/sdg.py +2 -2
  13. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/summaries.py +10 -4
  14. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/neo4j/cypher.py +83 -0
  15. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/neo4j/emit.py +5 -3
  16. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/neo4j/project.py +5 -2
  17. codeanalyzer_python-1.6.0/codeanalyzer/schema/__init__.py +100 -0
  18. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/schema/py_schema.py +60 -46
  19. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/pyproject.toml +17 -37
  20. codeanalyzer_python-1.5.4/codeanalyzer/schema/__init__.py +0 -141
  21. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/.gitignore +0 -0
  22. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/LICENSE +0 -0
  23. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/NOTICE +0 -0
  24. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/__init__.py +0 -0
  25. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/artifacts/__init__.py +0 -0
  26. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/artifacts/config_use.py +0 -0
  27. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/artifacts/config_use_rules.yml +0 -0
  28. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/artifacts/dependencies.py +0 -0
  29. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/artifacts/discovery.py +0 -0
  30. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/__init__.py +0 -0
  31. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/alias.py +0 -0
  32. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/dominance.py +0 -0
  33. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/identity.py +0 -0
  34. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/LICENSE +0 -0
  35. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/README.md +0 -0
  36. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/SSA/__init__.py +0 -0
  37. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/SSA/const.py +0 -0
  38. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/__init__.py +0 -0
  39. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/cfg/__init__.py +0 -0
  40. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/cfg/builder.py +0 -0
  41. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/cfg/model.py +0 -0
  42. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/core/__init__.py +0 -0
  43. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/core/func_call_visitor.py +0 -0
  44. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel/core/vars_visitor.py +0 -0
  45. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scalpel_oracle.py +0 -0
  46. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/scc.py +0 -0
  47. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/slicing.py +0 -0
  48. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/dataflow/syntactic.py +0 -0
  49. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/entrypoints/__init__.py +0 -0
  50. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/entrypoints/detect.py +0 -0
  51. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/entrypoints/matching.py +0 -0
  52. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/entrypoints/pipeline.py +0 -0
  53. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/entrypoints/rules.py +0 -0
  54. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/entrypoints/rules.yml +0 -0
  55. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/jedi/__init__.py +0 -0
  56. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/jedi/jedi.py +0 -0
  57. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/neo4j/__init__.py +0 -0
  58. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/neo4j/bolt.py +0 -0
  59. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/neo4j/rows.py +0 -0
  60. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/neo4j/schema.py +0 -0
  61. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/options/__init__.py +0 -0
  62. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/options/options.py +0 -0
  63. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/provenance.py +0 -0
  64. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/py.typed +0 -0
  65. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/schema/assign_ids.py +0 -0
  66. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/schema/call_graph_ids.py +0 -0
  67. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/schema/ids.py +0 -0
  68. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/schema/l1_body.py +0 -0
  69. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/schema/l2_callees.py +0 -0
  70. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/semantic_analysis/__init__.py +0 -0
  71. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/semantic_analysis/call_graph.py +0 -0
  72. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/semantic_analysis/defuse_linker.py +0 -0
  73. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/syntactic_analysis/__init__.py +0 -0
  74. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/syntactic_analysis/exceptions.py +0 -0
  75. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/syntactic_analysis/import_resolver.py +0 -0
  76. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/syntactic_analysis/symbol_table_builder.py +0 -0
  77. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/utils/__init__.py +0 -0
  78. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/utils/logging.py +0 -0
  79. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/codeanalyzer/utils/progress_bar.py +0 -0
  80. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/docs/handoff/README.md +0 -0
  81. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/docs/handoff/schema.neo4j.json +0 -0
  82. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/schema-uml.drawio +0 -0
  83. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/schema.neo4j.json +0 -0
  84. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/flask/README.md +0 -0
  85. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/flask/examples/celery/README.md +0 -0
  86. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/flask/requirements-skip/README.md +0 -0
  87. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/flask/src/flask/sansio/README.md +0 -0
  88. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/requests/LICENSE +0 -0
  89. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/requests/NOTICE +0 -0
  90. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/requests/README.md +0 -0
  91. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/requests/docs/_themes/LICENSE +0 -0
  92. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/requests/ext/LICENSE +0 -0
  93. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/xarray/LICENSE +0 -0
  94. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/xarray/README.md +0 -0
  95. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/xarray/properties/README.md +0 -0
  96. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/xarray/xarray/datatree_/LICENSE +0 -0
  97. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/xarray/xarray/datatree_/README.md +0 -0
  98. {codeanalyzer_python-1.5.4 → codeanalyzer_python-1.6.0}/test/fixtures/whole_applications/xarray/xarray/datatree_/docs/README.md +0 -0
@@ -1,31 +1,23 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codeanalyzer-python
3
- Version: 1.5.4
3
+ Version: 1.6.0
4
4
  Summary: Static analysis for Python — canonical schema v2 (symbol table, call graph, and native CFG/PDG/SDG dataflow) as analysis.json or a Neo4j property graph.
5
5
  Author-email: Rahul Krishna <i.m.ralk@gmail.com>
6
6
  License-File: LICENSE
7
7
  License-File: NOTICE
8
- Requires-Python: >=3.9
8
+ Requires-Python: >=3.11
9
9
  Requires-Dist: astor<0.9.0,>=0.8.1
10
- Requires-Dist: jedi<0.20.0,>=0.18.0; python_version < '3.11'
11
- Requires-Dist: jedi<=0.19.2; python_version >= '3.11'
12
- Requires-Dist: networkx<3.2.0,>=2.6.0; python_version < '3.11'
13
- Requires-Dist: networkx<4.0.0,>=3.0.0; python_version >= '3.11'
10
+ Requires-Dist: jedi<=0.19.2
11
+ Requires-Dist: networkx<4.0.0,>=3.0.0
14
12
  Requires-Dist: packaging>=25.0
15
13
  Requires-Dist: parso>=0.8.5
16
- Requires-Dist: pydantic<2.0.0,>=1.8.0; python_version < '3.11'
17
- Requires-Dist: pydantic<3.0.0,>=2.0.0; python_version >= '3.11'
14
+ Requires-Dist: pydantic<3.0.0,>=2.0.0
18
15
  Requires-Dist: pyyaml<7.0,>=6.0
19
- Requires-Dist: ray<3.0.0,>=2.10.0; python_version >= '3.11'
20
- Requires-Dist: ray==2.0.0; python_version < '3.11'
21
- Requires-Dist: requests<3.0.0,>=2.20.0; python_version >= '3.11'
22
- Requires-Dist: rich<14.0.0,>=12.6.0; python_version < '3.11'
23
- Requires-Dist: rich<15.0.0,>=14.0.0; python_version >= '3.11'
24
- Requires-Dist: tomli>=2.0; python_version < '3.11'
25
- Requires-Dist: typer<1.0.0,>=0.9.0; python_version < '3.11'
26
- Requires-Dist: typer<2.0.0,>=0.9.0; python_version >= '3.11'
27
- Requires-Dist: typing-extensions<5.0.0,>=4.0.0; python_version < '3.11'
28
- Requires-Dist: typing-extensions<6.0.0,>=4.5.0; python_version >= '3.11'
16
+ Requires-Dist: ray<3.0.0,>=2.10.0
17
+ Requires-Dist: requests<3.0.0,>=2.20.0
18
+ Requires-Dist: rich<15.0.0,>=14.0.0
19
+ Requires-Dist: typer<2.0.0,>=0.9.0
20
+ Requires-Dist: typing-extensions<6.0.0,>=4.5.0
29
21
  Requires-Dist: uv>=0.5.0
30
22
  Provides-Extra: neo4j
31
23
  Requires-Dist: neo4j<6.0.0,>=5.0.0; extra == 'neo4j'
@@ -112,7 +104,7 @@ needs.
112
104
 
113
105
  ### Prerequisites
114
106
 
115
- - **Python 3.10 or newer.**
107
+ - **Python 3.11 or newer.**
116
108
  - A C toolchain and the `venv` / development headers — the analyzer builds an isolated virtual
117
109
  environment per project (via Python's `venv`) so Jedi can resolve types and imports:
118
110
 
@@ -79,7 +79,7 @@ needs.
79
79
 
80
80
  ### Prerequisites
81
81
 
82
- - **Python 3.10 or newer.**
82
+ - **Python 3.11 or newer.**
83
83
  - A C toolchain and the `venv` / development headers — the analyzer builds an isolated virtual
84
84
  environment per project (via Python's `venv`) so Jedi can resolve types and imports:
85
85
 
@@ -406,11 +406,10 @@ def _write_output(artifacts, output_dir: Path):
406
406
  # Use Pydantic's model_dump_json() for compact output
407
407
  # Strip internal-only fields here rather than with a field-level Pydantic
408
408
  # `exclude`: the analysis cache shares the serializer and must keep them.
409
- json_str = json.dumps(
410
- strip_internal_only(model_dump(artifacts, mode="json", exclude_none=True))
411
- )
409
+ from codeanalyzer.core import stream_analysis_json, _log_mem # LOW-MEM PATCH
412
410
  with output_file.open("w") as f:
413
- f.write(json_str)
411
+ stream_analysis_json(artifacts, f, strip_internal=True)
412
+ _log_mem("after analysis.json write")
414
413
  logger.info(f"Analysis saved to {output_file}")
415
414
 
416
415
 
@@ -58,14 +58,9 @@ from __future__ import annotations
58
58
  import configparser
59
59
  import json
60
60
  import re
61
- import sys
61
+ import tomllib
62
62
  from typing import Callable, Dict, List, Optional, Tuple
63
63
 
64
- if sys.version_info >= (3, 11):
65
- import tomllib
66
- else: # pragma: no cover - exercised on the 3.10 CI leg
67
- import tomli as tomllib
68
-
69
64
  import yaml
70
65
 
71
66
  from codeanalyzer.schema.ids import config_key_id
@@ -4,15 +4,10 @@ import ast
4
4
  import configparser
5
5
  import json
6
6
  import re
7
- import sys
7
+ import tomllib
8
8
  from dataclasses import dataclass
9
9
  from typing import Dict, List, Optional, Tuple
10
10
 
11
- if sys.version_info >= (3, 11):
12
- import tomllib
13
- else: # pragma: no cover - exercised on the 3.10 CI leg
14
- import tomli as tomllib
15
-
16
11
  import yaml
17
12
 
18
13
 
@@ -1,5 +1,8 @@
1
+ import gc
1
2
  import hashlib
3
+ import json
2
4
  import os
5
+ import resource
3
6
  import shutil
4
7
  import subprocess
5
8
  import sys
@@ -53,6 +56,90 @@ def _artifact_full_text(project_dir: Path, path: str, art) -> str:
53
56
  return art.source
54
57
 
55
58
 
59
+ def _log_mem(label: str) -> None:
60
+ """LOW-MEM PATCH: log current/peak RSS of the driver at phase boundaries."""
61
+ try:
62
+ cur = int(os.popen(f"ps -o rss= -p {os.getpid()}").read().strip() or 0) / 1024
63
+ except Exception:
64
+ cur = -1
65
+ peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
66
+ peak = peak / (1024 * 1024) if sys.platform == "darwin" else peak / 1024 # bytes on macOS, KB on Linux
67
+ logger.info("[mem] %-40s rss=%6.0f MB peak=%6.0f MB", label, cur, peak)
68
+
69
+
70
+ def stream_analysis_json(analysis, fh, *, strip_internal: bool) -> None:
71
+ """LOW-MEM PATCH: serialize an ``Analysis`` envelope to ``fh`` one module at a
72
+ time instead of materializing the whole tree as one JSON string (cache) or
73
+ as a dict tree + stripped copy + string (output). The JSON *content* equals
74
+ ``model_dump_json(exclude_none=True)`` / ``json.dumps(strip_internal_only(
75
+ model_dump(mode="json", exclude_none=True)))``; only whitespace differs
76
+ (compact separators throughout). ``call_sites`` live solely under the
77
+ symbol table's callables, so the envelope needs no stripping."""
78
+ from codeanalyzer.schema import strip_internal_only
79
+
80
+ from typing import List as _List
81
+ from pydantic import TypeAdapter
82
+ from codeanalyzer.schema.py_schema import ParamEdge, PyCallEdge
83
+
84
+ app = analysis.application
85
+ # Detach the big collections, dump the (now small) envelope, then splice
86
+ # each collection back in at its own slot, streaming it in chunks.
87
+ big = {
88
+ "symbol_table": (app.symbol_table, "{}"),
89
+ "call_graph": (app.call_graph, "[]"),
90
+ "param_in": (app.param_in, "[]"),
91
+ "param_out": (app.param_out, "[]"),
92
+ }
93
+ try:
94
+ app.symbol_table, app.call_graph, app.param_in, app.param_out = {}, [], [], []
95
+ envelope = analysis.model_dump_json(exclude_none=True)
96
+ finally:
97
+ app.symbol_table = big["symbol_table"][0]
98
+ app.call_graph = big["call_graph"][0]
99
+ app.param_in = big["param_in"][0]
100
+ app.param_out = big["param_out"][0]
101
+ slots = []
102
+ for name, (_value, empty) in big.items():
103
+ marker = f'"{name}":{empty}'
104
+ idx = envelope.find(marker)
105
+ if idx < 0 or envelope.find(marker, idx + 1) >= 0:
106
+ raise RuntimeError(f"stream_analysis_json: could not locate a unique {name} slot")
107
+ slots.append((idx + len(marker) - 1, name)) # position of the closing bracket
108
+ slots.sort()
109
+ adapters = {
110
+ "call_graph": TypeAdapter(_List[PyCallEdge]),
111
+ "param_in": TypeAdapter(_List[ParamEdge]),
112
+ "param_out": TypeAdapter(_List[ParamEdge]),
113
+ }
114
+ CHUNK = 20000
115
+ pos = 0
116
+ for cut, name in slots:
117
+ fh.write(envelope[pos:cut])
118
+ pos = cut
119
+ value = big[name][0]
120
+ first = True
121
+ if name == "symbol_table":
122
+ for key, module in value.items():
123
+ if strip_internal:
124
+ body = json.dumps(
125
+ strip_internal_only(module.model_dump(mode="json", exclude_none=True)),
126
+ separators=(",", ":"),
127
+ )
128
+ else:
129
+ body = module.model_dump_json(exclude_none=True)
130
+ fh.write(("" if first else ",") + json.dumps(key) + ":" + body)
131
+ first = False
132
+ else:
133
+ adapter = adapters[name]
134
+ for i in range(0, len(value), CHUNK):
135
+ chunk = adapter.dump_json(value[i:i + CHUNK], exclude_none=True).decode()
136
+ inner = chunk[1:-1] # strip the chunk's own [ ]
137
+ if inner:
138
+ fh.write(("" if first else ",") + inner)
139
+ first = False
140
+ fh.write(envelope[pos:])
141
+
142
+
56
143
  def _ensure_ray() -> None:
57
144
  """Initialize Ray with the driver's pinned hash seed in the workers.
58
145
 
@@ -60,13 +147,23 @@ def _ensure_ray() -> None:
60
147
  interpreters, so Jedi inference in Ray workers would run with random
61
148
  set-iteration order and the emitted edges vary run to run (issue #99)."""
62
149
  if not ray.is_initialized():
63
- ray.init(
150
+ # LOW-MEM PATCH: start a private local cluster with object spilling to
151
+ # disk disabled, a bounded object store, and an optional worker cap
152
+ # (CANPY_RAY_CPUS); shut down after the symbol-table phase so the idle
153
+ # workers release their memory before the single-process L2-L4 phases.
154
+ init_kwargs = dict(
64
155
  runtime_env={
65
156
  "env_vars": {
66
157
  "PYTHONHASHSEED": os.environ.get("PYTHONHASHSEED", "0")
67
158
  }
68
159
  },
160
+ _system_config={"automatic_object_spilling_enabled": False},
161
+ object_store_memory=int(os.environ.get("CANPY_RAY_OBJECT_STORE", 1_000_000_000)),
162
+ include_dashboard=False,
69
163
  )
164
+ if os.environ.get("CANPY_RAY_CPUS"):
165
+ init_kwargs["num_cpus"] = int(os.environ["CANPY_RAY_CPUS"])
166
+ ray.init(**init_kwargs)
70
167
 
71
168
 
72
169
  @ray.remote
@@ -747,7 +844,16 @@ class Codeanalyzer:
747
844
  k=self.options.graph_field_depth,
748
845
  oracle_factory=lambda c, fast: SyntacticOracle(),
749
846
  )
847
+ del _func_asts # LOW-MEM PATCH: unused at L3; drop the def-node ASTs now
750
848
  emit_l3_body(app, infos, sig_to_id, set(self.options.graphs.split(",")))
849
+ # LOW-MEM PATCH: L4 only needs the syntactic DDG *sets* (for the
850
+ # points-to delta), not the PDGs with their statement ASTs.
851
+ from codeanalyzer.dataflow.builder import syntactic_ddg_sets
852
+ syn_ddg_sets = syntactic_ddg_sets(app, infos, sig_to_id)
853
+ infos.clear()
854
+ del infos
855
+ gc.collect()
856
+ _log_mem("after L3 emit (L3 PDGs freed)")
751
857
 
752
858
  # L4: interprocedural dataflow (param vertices + summary + param_in/out)
753
859
  # layered on top of the L3 syntactic overlay (L3 ⊆ L4). Scalpel is the
@@ -772,7 +878,14 @@ class Codeanalyzer:
772
878
  # Semantic ddg delta: the alias-derived def-use edges the real
773
879
  # oracle adds beyond the L3 syntactic set, tagged prov=["points-to"].
774
880
  # ``infos`` are the syntactic (L3) PDGs from the >=3 block above.
775
- emit_ddg_pointsto_delta(app, infos, ir, sig_to_id)
881
+ emit_ddg_pointsto_delta(app, syn_ddg_sets, ir, sig_to_id)
882
+ # LOW-MEM PATCH: the L3/L4 intermediates (per-function PDGs with their
883
+ # statement ASTs, SDG IR) are dead from here on; free them before the
884
+ # config tiers and serialization instead of at analyze() return.
885
+ del ir
886
+ del syn_ddg_sets
887
+ gc.collect()
888
+ _log_mem("after L4 emit (intermediates freed)")
776
889
 
777
890
  # config_use (#162) resolution: literal tier always runs at >= 2;
778
891
  # the dataflow tiers widen the set as substrate comes online -- intra
@@ -872,7 +985,8 @@ class Codeanalyzer:
872
985
  cache_file.parent.mkdir(parents=True, exist_ok=True)
873
986
 
874
987
  with cache_file.open('w') as f:
875
- f.write(model_dump_json(analysis, indent=2))
988
+ stream_analysis_json(analysis, f, strip_internal=False) # LOW-MEM PATCH
989
+ _log_mem("after cache save")
876
990
 
877
991
  logger.info(f"Analysis cached to {cache_file}")
878
992
 
@@ -1013,6 +1127,8 @@ class Codeanalyzer:
1013
1127
  if result:
1014
1128
  symbol_table.update(result)
1015
1129
  progress.advance()
1130
+ ray.shutdown() # LOW-MEM PATCH: release the worker pool's memory
1131
+ logger.info("Ray shut down after the symbol-table phase.")
1016
1132
  else:
1017
1133
  logger.info("Building symbol table serially.")
1018
1134
  symbol_table_builder = SymbolTableBuilder(self.project_dir, self.virtualenv)
@@ -126,7 +126,7 @@ class FunctionScope:
126
126
  return "global" # unknown free name: a module/builtin binding
127
127
 
128
128
 
129
- @dataclass
129
+ @dataclass(slots=True) # LOW-MEM PATCH: slots
130
130
  class StatementFacts:
131
131
  """Defs and uses (k-limited access-path strings) of one CFG node."""
132
132
 
@@ -50,6 +50,14 @@ from codeanalyzer.utils import logger
50
50
 
51
51
  DEFAULT_K_LIMIT = 3
52
52
 
53
+ # LOW-MEM PATCH: one shared provenance list per kind instead of a fresh
54
+ # ``["ssa"]`` list object on each of the (tens of) millions of DDG edges. The
55
+ # lists are never mutated; equality comparisons (``e.prov != [...]``) and JSON
56
+ # serialization are unaffected.
57
+ _PROV_SSA = ["ssa"]
58
+ _PROV_REACHING_DEFS = ["reaching-defs"]
59
+ _PROV_POINTS_TO = ["points-to"]
60
+
53
61
 
54
62
  def _walk_callables(
55
63
  module: PyModule,
@@ -339,7 +347,7 @@ def emit_l3_body(
339
347
  src=im.local(e.source),
340
348
  dst=im.local(e.target),
341
349
  var=e.var,
342
- prov=["ssa"],
350
+ prov=_PROV_SSA,
343
351
  )
344
352
  for e in pdg.edges
345
353
  if e.type == "DDG"
@@ -442,6 +450,16 @@ def build_program_graphs(
442
450
  if nested_sig in infos:
443
451
  info.nested_defs.append((node.id, nested_sig))
444
452
 
453
+ # LOW-MEM PATCH: from here on (summary fixpoint, SDG assembly, L4 emit) no
454
+ # stage reads a statement's AST -- only CFG node ids/positions, facts and
455
+ # oracles. Drop the def-node map and the per-node AST references so the
456
+ # whole-program AST copy this pass parsed is collected before the two most
457
+ # memory-hungry stages run.
458
+ func_asts.clear()
459
+ for info in infos.values():
460
+ for node in info.pdg.cfg.nodes:
461
+ node.ast_node = None
462
+
445
463
  call_edges = [
446
464
  (e.src, e.dst)
447
465
  for e in app.call_graph
@@ -609,7 +627,7 @@ def emit_l4(
609
627
  continue
610
628
  seen.add(key)
611
629
  rows.append(
612
- DdgEdge(src=src, dst=dst, var=e.var, prov=["reaching-defs"])
630
+ DdgEdge(src=src, dst=dst, var=e.var, prov=_PROV_REACHING_DEFS)
613
631
  )
614
632
  rows.sort(key=lambda r: (r.src, r.dst, r.var or ""))
615
633
  pycallable.ddg.extend(rows)
@@ -629,9 +647,36 @@ def _ddg_local_set(im, pdg) -> Set[Tuple[str, str, Optional[str]]]:
629
647
  }
630
648
 
631
649
 
650
+ def syntactic_ddg_sets(
651
+ app: PyApplication,
652
+ infos: Dict[str, FunctionInfo],
653
+ sig_to_id: Dict[str, str],
654
+ ) -> Dict[str, Set[Tuple[str, str, Optional[str]]]]:
655
+ """LOW-MEM PATCH: precompute, per signature, the L3 syntactic DDG set in the
656
+ exact form :func:`emit_ddg_pointsto_delta` derives from ``syntactic_infos``
657
+ (local ids are position-based, so an IdentityMap over the syntactic PDG
658
+ yields the same locals as one over the L4 PDG). Lets the caller free the L3
659
+ ``FunctionInfo`` records -- and the statement ASTs they pin -- before L4."""
660
+ from codeanalyzer.dataflow.identity import IdentityMap
661
+
662
+ sig_to_callable: Dict[str, PyCallable] = {}
663
+ for module in app.symbol_table.values():
664
+ for pycallable, _chain in _walk_callables(module):
665
+ sig_to_callable[pycallable.signature] = pycallable
666
+ out: Dict[str, Set[Tuple[str, str, Optional[str]]]] = {}
667
+ for sig, info in infos.items():
668
+ pycallable = sig_to_callable.get(sig)
669
+ if pycallable is None:
670
+ continue
671
+ callable_id = sig_to_id.get(sig) or pycallable.id
672
+ im = IdentityMap.for_function(callable_id, info.pdg)
673
+ out[sig] = _ddg_local_set(im, info.pdg)
674
+ return out
675
+
676
+
632
677
  def emit_ddg_pointsto_delta(
633
678
  app: PyApplication,
634
- syntactic_infos: Dict[str, FunctionInfo],
679
+ syntactic_infos,
635
680
  ir: ProgramGraphsIR,
636
681
  sig_to_id: Dict[str, str],
637
682
  ) -> None:
@@ -678,12 +723,14 @@ def emit_ddg_pointsto_delta(
678
723
  # untouched — they are L3's and this delta is strictly additive over them.
679
724
  pycallable.ddg = [e for e in pycallable.ddg if e.prov != ["points-to"]]
680
725
 
681
- delta = _ddg_local_set(im, fg.pdg) - _ddg_local_set(im, syn.pdg)
726
+ # LOW-MEM PATCH: accept a precomputed syntactic set (see syntactic_ddg_sets).
727
+ syn_set = syn if isinstance(syn, (set, frozenset)) else _ddg_local_set(im, syn.pdg)
728
+ delta = _ddg_local_set(im, fg.pdg) - syn_set
682
729
  for src, dst, var in sorted(delta, key=lambda t: (t[0], t[1], t[2] or "")):
683
730
  if src not in pycallable.body or dst not in pycallable.body:
684
731
  continue
685
732
  pycallable.ddg.append(
686
- DdgEdge(src=src, dst=dst, var=var, prov=["points-to"])
733
+ DdgEdge(src=src, dst=dst, var=var, prov=_PROV_POINTS_TO)
687
734
  )
688
735
 
689
736
 
@@ -102,7 +102,7 @@ EDGE_KINDS = (
102
102
  )
103
103
 
104
104
 
105
- @dataclass
105
+ @dataclass(slots=True) # LOW-MEM PATCH: slots
106
106
  class CFGNode:
107
107
  """A statement-level CFG node. ``id`` is assigned in source-span order
108
108
  after construction (ENTRY = 0, EXIT = last CFG id)."""
@@ -118,7 +118,7 @@ class CFGNode:
118
118
  ast_node: Optional[ast.AST] = field(default=None, repr=False, compare=False)
119
119
 
120
120
 
121
- @dataclass(frozen=True)
121
+ @dataclass(slots=True, frozen=True) # LOW-MEM PATCH: slots
122
122
  class CFGEdge:
123
123
  source: int
124
124
  target: int
@@ -44,7 +44,7 @@ from codeanalyzer.dataflow.alias import TypeBasedAliasOracle
44
44
  from codeanalyzer.dataflow.cfg import ControlFlowGraph
45
45
 
46
46
 
47
- @dataclass(frozen=True)
47
+ @dataclass(slots=True, frozen=True) # LOW-MEM PATCH: slots
48
48
  class DDGEdge:
49
49
  source: int # the def node
50
50
  target: int # the use node
@@ -42,7 +42,7 @@ from codeanalyzer.dataflow.defuse import ddg_edges
42
42
  from codeanalyzer.dataflow.dominance import control_dependence
43
43
 
44
44
 
45
- @dataclass(frozen=True)
45
+ @dataclass(slots=True, frozen=True) # LOW-MEM PATCH: slots
46
46
  class PDGEdge:
47
47
  source: int
48
48
  target: int
@@ -59,7 +59,7 @@ CAPTURE_PREFIX = "<capture>:"
59
59
  GLOBAL_PREFIX = "<global>:"
60
60
 
61
61
 
62
- @dataclass
62
+ @dataclass(slots=True) # LOW-MEM PATCH: slots
63
63
  class ParamNode:
64
64
  id: int
65
65
  kind: str # formal_in | formal_out | actual_in | actual_out
@@ -69,7 +69,7 @@ class ParamNode:
69
69
  end_line: int = -1
70
70
 
71
71
 
72
- @dataclass(frozen=True)
72
+ @dataclass(slots=True, frozen=True) # LOW-MEM PATCH: slots
73
73
  class SDGEdge:
74
74
  source_sig: str
75
75
  source_node: int
@@ -102,15 +102,21 @@ def _is_global(path: str) -> bool:
102
102
 
103
103
  def augmented_facts(info: FunctionInfo, summaries: Dict[str, FunctionSummary]):
104
104
  """Per-node facts with callee global footprints injected at callsites."""
105
- facts = {nid: f for nid, f in info.pdg.facts.items()}
106
- out = {}
107
- for nid, f in facts.items():
108
- out[nid] = type(f)(defs=set(f.defs), uses=set(f.uses))
105
+ # LOW-MEM PATCH: copy-on-write. Only call-site nodes are ever augmented, and
106
+ # every downstream consumer (reaching-defs, flows, the SDG assembler) only
107
+ # reads facts, so the untouched nodes can share the PDG's own records
108
+ # instead of being deep-copied for every solve.
109
+ out = dict(info.pdg.facts)
110
+ copied = set()
109
111
  for cs in info.call_sites:
110
112
  for target in cs.targets:
111
113
  s = summaries.get(target)
112
114
  if s is None:
113
115
  continue
116
+ if cs.node_id not in copied:
117
+ f = out[cs.node_id]
118
+ out[cs.node_id] = type(f)(defs=set(f.defs), uses=set(f.uses))
119
+ copied.add(cs.node_id)
114
120
  out[cs.node_id].uses |= s.global_reads
115
121
  out[cs.node_id].defs |= s.global_writes
116
122
  return out
@@ -156,3 +156,86 @@ def _edge_statements(edges: List[EdgeRow]) -> List[str]:
156
156
  f"SET r += row.p;"
157
157
  )
158
158
  return blocks
159
+
160
+
161
+ # ----------------------------------------------------------------------------------------------
162
+ # LOW-MEM PATCH: streaming snapshot writer (see write_cypher_streaming).
163
+ # ----------------------------------------------------------------------------------------------
164
+ from codeanalyzer.neo4j.rows import EdgeRow, GraphRows, NodeRef, RowBuilder # noqa: E402
165
+
166
+
167
+ class _NodesOnlyBuilder(RowBuilder):
168
+ """Pass 1: nodes (merged props/labels, exactly as RowBuilder) + key set; no edges."""
169
+
170
+ def edge(self, type_, from_ref, to_ref, props=None, key=None):
171
+ return None
172
+
173
+ def edge_to_symbol(self, type_, from_ref, target_ref, props=None):
174
+ return None
175
+
176
+
177
+ class _EdgeStreamBuilder(RowBuilder):
178
+ """Pass 2: nodes are already on disk; each edge batch is rendered with
179
+ ``_edge_statements`` (same grouping/BATCH) the moment it fills."""
180
+
181
+ def __init__(self, keys, fh):
182
+ super().__init__()
183
+ self._keys = keys
184
+ self._fh = fh
185
+ self._pending: Dict[str, List[EdgeRow]] = {}
186
+
187
+ def node(self, labels, key_prop, value, props):
188
+ return NodeRef(labels[0], key_prop, value)
189
+
190
+ def edge(self, type_, from_ref, to_ref, props=None, key=None):
191
+ e = EdgeRow(type_, from_ref, to_ref, dict(props or {}), key)
192
+ g = (
193
+ f"{e.type}|{e.from_ref.label}.{e.from_ref.key_prop}"
194
+ f"|{e.to_ref.label}.{e.to_ref.key_prop}|{e.key is not None}"
195
+ )
196
+ batch = self._pending.setdefault(g, [])
197
+ batch.append(e)
198
+ if len(batch) >= BATCH:
199
+ self._flush(g)
200
+
201
+ def edge_to_symbol(self, type_, from_ref, target_ref, props=None):
202
+ if (target_ref.label, target_ref.value) in self._keys:
203
+ self.edge(type_, from_ref, target_ref, props)
204
+
205
+ def _flush(self, g):
206
+ batch = self._pending.pop(g, None)
207
+ if batch:
208
+ for stmt in _edge_statements(batch):
209
+ self._fh.write(stmt)
210
+ self._fh.write("\n")
211
+
212
+ def finish(self) -> GraphRows:
213
+ for g in list(self._pending):
214
+ self._flush(g)
215
+ return GraphRows([], [])
216
+
217
+
218
+ def write_cypher_streaming(fh, app, app_name: str, sig_to_id: dict, analyzer=None) -> None:
219
+ """Write the ``graph.cypher`` snapshot for ``app`` to ``fh`` without holding the
220
+ edge rows or the rendered text in memory (two projection passes)."""
221
+ from codeanalyzer.neo4j.project import project
222
+
223
+ nodes_b = _NodesOnlyBuilder()
224
+ project(app, app_name, sig_to_id, analyzer=analyzer, builder=nodes_b)
225
+ rows = nodes_b.finish()
226
+ keys = nodes_b._keys
227
+
228
+ fh.write("// ── constraints & indexes ──\n")
229
+ for stmt in CONSTRAINTS:
230
+ fh.write(f"{stmt};\n")
231
+ for stmt in INDEXES:
232
+ fh.write(f"{stmt};\n")
233
+ fh.write("\n// ── wipe this project's prior subgraph (externals/packages/decorators are shared) ──\n")
234
+ fh.write(_wipe(app_name) + "\n")
235
+ fh.write("\n// ── nodes ──\n")
236
+ for stmt in _node_statements(rows.nodes):
237
+ fh.write(stmt + "\n")
238
+ del rows, nodes_b
239
+
240
+ fh.write("\n// ── relationships ──\n")
241
+ project(app, app_name, sig_to_id, analyzer=analyzer, builder=_EdgeStreamBuilder(keys, fh))
@@ -29,7 +29,7 @@ from typing import Optional
29
29
 
30
30
  from codeanalyzer.neo4j.bolt import BoltConfig, bolt_writer
31
31
  from codeanalyzer.neo4j.schema import build_schema_document
32
- from codeanalyzer.neo4j.cypher import render_cypher
32
+ from codeanalyzer.neo4j.cypher import render_cypher, write_cypher_streaming
33
33
  from codeanalyzer.neo4j.project import project
34
34
  from codeanalyzer.options import GRAPH_CYPHER, AnalysisOptions
35
35
  from codeanalyzer.schema import Analysis
@@ -58,9 +58,9 @@ def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None:
58
58
  # canonical ``can://`` id and returns the ``signature -> id`` map the projection
59
59
  # keys nodes on, so the JSON and Neo4j projections agree.
60
60
  sig_to_id = assign_ids(analysis.application, app_name)
61
- rows = project(analysis.application, app_name, sig_to_id, analyzer=analysis.analyzer)
62
61
 
63
62
  if options.neo4j_uri:
63
+ rows = project(analysis.application, app_name, sig_to_id, analyzer=analysis.analyzer)
64
64
  cfg = BoltConfig(
65
65
  uri=options.neo4j_uri,
66
66
  user=options.neo4j_user,
@@ -76,5 +76,7 @@ def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None:
76
76
  out_dir = options.output if options.output is not None else Path.cwd()
77
77
  out_dir.mkdir(parents=True, exist_ok=True)
78
78
  target = out_dir / GRAPH_CYPHER
79
- target.write_text(render_cypher(rows, app_name))
79
+ with target.open("w") as fh: # LOW-MEM PATCH: streamed, see cypher.write_cypher_streaming
80
+ write_cypher_streaming(fh, analysis.application, app_name, sig_to_id,
81
+ analyzer=analysis.analyzer)
80
82
  logger.info(f"Neo4j graph written to {target}")
@@ -56,11 +56,11 @@ from codeanalyzer.schema.py_schema import PyDecorator, byte_offsets
56
56
 
57
57
 
58
58
  def project(app: PyApplication, app_name: str, sig_to_id: dict,
59
- analyzer: Optional[Any] = None) -> GraphRows:
59
+ analyzer: Optional[Any] = None, builder: Optional[RowBuilder] = None) -> GraphRows:
60
60
  """``analyzer`` is the envelope-level ``PyAnalyzerInfo`` (the keystone home
61
61
  for analyzer identity); the caller that holds the ``Analysis`` envelope
62
62
  passes it through so the :PyApplication node carries it as props."""
63
- b = RowBuilder()
63
+ b = builder if builder is not None else RowBuilder() # LOW-MEM PATCH
64
64
 
65
65
  # Keyed on the ``can://<app>`` id, not on ``--app-name``: two applications
66
66
  # analyzed under the same free-text name used to MERGE onto one root, with
@@ -928,6 +928,9 @@ def _stringify_if(value: Any) -> Optional[str]:
928
928
 
929
929
 
930
930
  def _jsonable(o: Any) -> Any:
931
+ import dataclasses as _dc # LOW-MEM PATCH: slotted-dataclass schema leaves
932
+ if _dc.is_dataclass(o) and not isinstance(o, type):
933
+ return _dc.asdict(o)
931
934
  if hasattr(o, "model_dump"):
932
935
  return o.model_dump()
933
936
  if hasattr(o, "dict"):