cg-code-graph 0.10.1__py3-none-any.whl

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 (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
@@ -0,0 +1,245 @@
1
+ """Python settings -> external-system facts for codegraph/external.py (#40).
2
+
3
+ Module-level settings dicts (Django `DATABASES` / `CACHES`, NetBox-style `DATABASE` / `REDIS`, and nested aliases
4
+ `{"default": {...}}`) with a HOST / LOCATION, and URL settings (`DATABASE_URL`, `CELERY_BROKER_URL`, `BROKER_URL`,
5
+ `REDIS_URL`, `CACHE_URL`, `EMAIL_HOST`): literal values, `os.environ.get("K", default)` / `os.getenv` / `env("K")`
6
+ reads and names of other module dicts. Client constructors with an address in their arguments (#77):
7
+ `psycopg.connect(host=...)`, `redis.Redis(host=)` / `redis.from_url(url)`, `smtplib.SMTP(host, port)`,
8
+ `pymongo.MongoClient(url)`, `ldap3.Server(host)`, `boto3.client("s3", endpoint_url=)` ... Only facts are recorded
9
+ (builder.external_facts); passwords are kept as the env key or the location of a literal, never the value."""
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+
14
+ DICT_VARS = {"DATABASES": None, "DATABASE": None, "CACHES": None, "REDIS": "redis", "CACHE": None}
15
+ URL_VARS = {"DATABASE_URL": None, "CELERY_BROKER_URL": None, "BROKER_URL": None, "REDIS_URL": "redis", "CACHE_URL": None,
16
+ "CELERY_RESULT_BACKEND": None, "EMAIL_HOST": "smtp", "MONGO_URL": "mongodb", "MONGODB_URI": "mongodb"}
17
+ ENGINES = (("postgis", "postgres"), ("postgresql", "postgres"), ("psycopg", "postgres"), ("mysql", "mysql"),
18
+ ("oracle", "oracle"), ("mssql", "mssql"), ("sql_server", "mssql"), ("redis", "redis"), ("memcache", "memcached"),
19
+ ("sqlite", None), ("locmem", None), ("dummy", None), ("filebased", None), ("db.DatabaseCache", None))
20
+
21
+
22
+ def _value(prog, m, e, depth=0):
23
+ """('lit', str) | ('env', key, default) | ('dict', ast.Dict, module) | None"""
24
+ if e is None or depth > 3:
25
+ return None
26
+ if isinstance(e, ast.Constant) and isinstance(e.value, (str, int)) and not isinstance(e.value, bool):
27
+ return ("lit", str(e.value))
28
+ if isinstance(e, ast.Dict):
29
+ return ("dict", e, m)
30
+ if isinstance(e, ast.Call):
31
+ fn = e.func
32
+ name = fn.attr if isinstance(fn, ast.Attribute) else fn.id if isinstance(fn, ast.Name) else ""
33
+ recv = fn.value if isinstance(fn, ast.Attribute) else None
34
+ rname = recv.attr if isinstance(recv, ast.Attribute) else recv.id if isinstance(recv, ast.Name) else None
35
+ envish = (isinstance(fn, ast.Name) and name in ("getenv", "env")) or (name == "get" and rname == "environ") \
36
+ or (name == "getenv" and rname == "os") or (rname in ("env", "environ") and name in ("str", "int", "url", "db", "cache_url", "db_url"))
37
+ if envish and e.args \
38
+ and isinstance(e.args[0], ast.Constant) and isinstance(e.args[0].value, str) and e.args[0].value.isupper():
39
+ d = e.args[1] if len(e.args) > 1 else next((k.value for k in e.keywords if k.arg == "default"), None)
40
+ dv = d.value if isinstance(d, ast.Constant) and isinstance(d.value, (str, int)) else None
41
+ return ("env", e.args[0].value, None if dv is None else str(dv))
42
+ if name in ("int", "str") and e.args:
43
+ return _value(prog, m, e.args[0], depth + 1)
44
+ if isinstance(e, ast.Subscript) and isinstance(e.slice, ast.Constant) and isinstance(e.slice.value, str) \
45
+ and (getattr(e.value, "attr", None) == "environ" or getattr(e.value, "id", None) == "environ"):
46
+ return ("env", e.slice.value, None)
47
+ if isinstance(e, ast.Name):
48
+ r = prog.resolve_name(m, e.id)
49
+ if r and r[0] == "var":
50
+ for v, _l, _a in r[1].vars.get(r[2], [])[-1:]:
51
+ return _value(prog, r[1], v, depth + 1)
52
+ return None
53
+
54
+
55
+ def _proto(engine: str | None, default):
56
+ if engine:
57
+ for k, p in ENGINES:
58
+ if k in engine.lower():
59
+ return p, True
60
+ return default, False
61
+
62
+
63
+ def index(prog, b, walk_body=None) -> int:
64
+ facts = []
65
+ for m in prog.modules.values():
66
+ if not any(s in m.name.rsplit(".", 1)[-1].lower() for s in ("settings", "config", "configuration")):
67
+ continue
68
+ for name, vals in m.vars.items():
69
+ if name not in DICT_VARS and name not in URL_VARS:
70
+ continue
71
+ v, line, _a = vals[-1]
72
+ if name in URL_VARS:
73
+ x = _value(prog, m, v)
74
+ if x and x[0] in ("lit", "env"):
75
+ facts.append({"var": name, "module": m.name, "protocol": URL_VARS[name], "url": x, "src": f"module:{m.name}", "file": m.file, "line": line,
76
+ "host_only": name == "EMAIL_HOST"})
77
+ continue
78
+ x = _value(prog, m, v)
79
+ if not x or x[0] != "dict":
80
+ continue
81
+ d, dm = x[1], x[2]
82
+ entries = []
83
+ keys = {k.value: val for k, val in zip(d.keys, d.values) if isinstance(k, ast.Constant) and isinstance(k.value, str)}
84
+ if any(k in keys for k in ("HOST", "LOCATION", "ENGINE", "BACKEND", "URL")):
85
+ entries.append((None, keys, dm))
86
+ else:
87
+ for alias, val in keys.items():
88
+ y = _value(prog, dm, val)
89
+ if y and y[0] == "dict":
90
+ sub = {k.value: vv for k, vv in zip(y[1].keys, y[1].values) if isinstance(k, ast.Constant) and isinstance(k.value, str)}
91
+ entries.append((alias, sub, y[2]))
92
+ for alias, kv, em in entries:
93
+ eng = _value(prog, em, kv.get("ENGINE") or kv.get("BACKEND"))
94
+ proto, known = _proto(eng[1] if eng and eng[0] == "lit" else None, DICT_VARS[name])
95
+ if (eng and eng[0] == "lit") and known and proto is None:
96
+ continue # sqlite / local-memory cache: no network system
97
+ loc = _value(prog, em, kv.get("LOCATION") or kv.get("URL"))
98
+ nm = _value(prog, em, kv.get("NAME"))
99
+ f = {"var": name + (f"[{alias}]" if alias else ""), "module": m.name, "protocol": proto, "src": f"module:{m.name}", "file": m.file, "line": line,
100
+ "resource": nm[1] if nm and nm[0] == "lit" and nm[1] else None}
101
+ if loc is not None and (loc[0] == "env" or (loc[0] == "lit" and "://" in loc[1])):
102
+ f["url"] = loc
103
+ else:
104
+ f["host"] = _value(prog, em, kv.get("HOST"))
105
+ f["port"] = _value(prog, em, kv.get("PORT"))
106
+ if f["host"] is None:
107
+ continue
108
+ pw = kv.get("PASSWORD")
109
+ if pw is not None:
110
+ pv = _value(prog, em, pw)
111
+ f["password"] = ("env", pv[1]) if pv and pv[0] == "env" else ("literal", f"{em.file}:{pw.lineno}") \
112
+ if pv and pv[0] == "lit" and pv[1] else None
113
+ facts.append(f)
114
+ if walk_body is not None:
115
+ facts += client_facts(prog, b, walk_body)
116
+ if facts:
117
+ b.external_facts = getattr(b, "external_facts", []) + facts
118
+ return len(facts)
119
+
120
+
121
+ # top-level package -> (protocol, constructor / function names, default port override)
122
+ CLIENTS = {
123
+ "psycopg": ("postgres", {"connect", "Connection", "AsyncConnection", "ConnectionPool", "AsyncConnectionPool"}),
124
+ "psycopg2": ("postgres", {"connect", "SimpleConnectionPool", "ThreadedConnectionPool"}),
125
+ "psycopg_pool": ("postgres", {"ConnectionPool", "AsyncConnectionPool"}),
126
+ "asyncpg": ("postgres", {"connect", "create_pool"}),
127
+ "pg8000": ("postgres", {"connect", "Connection"}),
128
+ "pymysql": ("mysql", {"connect", "Connection"}),
129
+ "MySQLdb": ("mysql", {"connect", "Connection"}),
130
+ "mysql": ("mysql", {"connect", "MySQLConnection"}),
131
+ "aiomysql": ("mysql", {"connect", "create_pool"}),
132
+ "redis": ("redis", {"Redis", "StrictRedis", "from_url", "ConnectionPool"}),
133
+ "aioredis": ("redis", {"Redis", "from_url", "create_redis_pool"}),
134
+ "pymongo": ("mongodb", {"MongoClient"}),
135
+ "motor": ("mongodb", {"AsyncIOMotorClient", "MotorClient"}),
136
+ "smtplib": ("smtp", {"SMTP", "SMTP_SSL"}),
137
+ "aiosmtplib": ("smtp", {"SMTP", "send"}),
138
+ "ftplib": ("ftp", {"FTP", "FTP_TLS"}),
139
+ "ldap3": ("ldap", {"Server"}),
140
+ "ldap": ("ldap", {"initialize"}),
141
+ "pika": ("amqp", {"URLParameters", "ConnectionParameters"}),
142
+ "aio_pika": ("amqp", {"connect", "connect_robust"}),
143
+ "kombu": ("amqp", {"Connection"}),
144
+ "elasticsearch": ("elasticsearch", {"Elasticsearch", "AsyncElasticsearch"}),
145
+ "pymemcache": ("memcached", {"Client", "PooledClient"}),
146
+ "boto3": ("s3", {"client", "resource"}),
147
+ }
148
+ TLS_CTORS = {"SMTP_SSL": "465", "FTP_TLS": None}
149
+ URL_KW = ("dsn", "url", "conninfo", "host_url", "endpoint_url", "hosts")
150
+ HOST_KW = ("host", "hostname", "server")
151
+
152
+
153
+ def _dotted(m, e):
154
+ """`redis.asyncio.Redis` / `Redis` (from redis import Redis) -> 'redis.asyncio.Redis', through the imports."""
155
+ parts = []
156
+ while isinstance(e, ast.Attribute):
157
+ parts.append(e.attr)
158
+ e = e.value
159
+ if not isinstance(e, ast.Name):
160
+ return None
161
+ imp = m.imports.get(e.id)
162
+ if not isinstance(imp, tuple) or len(imp) < 2 or not isinstance(imp[1], str):
163
+ return None
164
+ base = imp[1] + (f".{imp[2]}" if len(imp) > 2 and imp[0] == "sym" and isinstance(imp[2], str) else "")
165
+ return ".".join([base, *reversed(parts)])
166
+
167
+
168
+ def _kwarg(call, names):
169
+ for k in call.keywords:
170
+ if k.arg in names:
171
+ return k.value
172
+ return None
173
+
174
+
175
+ def client_facts(prog, b, walk_body) -> list:
176
+ tops = set(CLIENTS)
177
+ facts = []
178
+ for m in prog.modules.values():
179
+ if not any(isinstance(v, tuple) and len(v) > 1 and isinstance(v[1], str) and v[1].split(".")[0] in tops
180
+ for v in m.imports.values()):
181
+ continue
182
+ scopes = [(f.id, walk_body(f.node)) for f in m.funcs.values()]
183
+ scopes += [(f.id, walk_body(f.node)) for c in m.classes.values() for f in c.methods.values()]
184
+ scopes.append((f"module:{m.name}", (n for st in m.tree.body if not isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
185
+ for n in ast.walk(st))))
186
+ for src, nodes in scopes:
187
+ if src not in b.nodes:
188
+ continue
189
+ for c in nodes:
190
+ if not isinstance(c, ast.Call):
191
+ continue
192
+ dn = _dotted(m, c.func)
193
+ if not dn:
194
+ continue
195
+ top, last = dn.split(".")[0], dn.rsplit(".", 1)[-1]
196
+ if top not in CLIENTS or last not in CLIENTS[top][1]:
197
+ continue
198
+ proto = CLIENTS[top][0]
199
+ if top == "boto3":
200
+ if not (c.args and isinstance(c.args[0], ast.Constant) and c.args[0].value == "s3"):
201
+ continue
202
+ if _kwarg(c, ("endpoint_url",)) is None:
203
+ continue
204
+ url = _kwarg(c, URL_KW)
205
+ host = _kwarg(c, HOST_KW)
206
+ pos = c.args[0] if c.args and top != "boto3" else None
207
+ if url is None and host is None and pos is not None:
208
+ pv = _value(prog, m, pos)
209
+ if pv and pv[0] == "lit" and "://" in pv[1] or last in ("from_url", "URLParameters") or top in ("pymongo", "motor", "asyncpg", "aio_pika") and pv and pv[0] == "env":
210
+ url = pos
211
+ elif proto in ("smtp", "ftp", "ldap", "elasticsearch", "memcached", "amqp") or (top == "redis" and last != "from_url"):
212
+ host = pos
213
+ port = _kwarg(c, ("port",)) or (c.args[1] if len(c.args) > 1 and proto in ("smtp", "ftp", "redis") else None)
214
+ f = {"var": f"{dn}()", "module": m.name, "protocol": proto, "src": src, "file": m.file, "line": c.lineno,
215
+ "client": top, "resource": None}
216
+ if url is not None:
217
+ u = _value(prog, m, url)
218
+ if not u or u[0] not in ("lit", "env") or (u[0] == "lit" and "://" not in u[1] and proto != "ldap"):
219
+ continue
220
+ if u[0] == "lit" and "://" not in u[1]: # ldap3.Server("ldap.example.org")
221
+ f["host"], f["port"] = u, None
222
+ else:
223
+ f["url"] = u
224
+ elif host is not None:
225
+ h = _value(prog, m, host)
226
+ if not h or h[0] not in ("lit", "env"):
227
+ continue
228
+ if h[0] == "lit" and "://" in h[1]:
229
+ f["url"] = h
230
+ else:
231
+ f["host"] = h
232
+ pv = _value(prog, m, port) if port is not None else None
233
+ f["port"] = pv if pv and pv[0] in ("lit", "env") else (("lit", TLS_CTORS[last]) if TLS_CTORS.get(last) else None)
234
+ else:
235
+ continue
236
+ db = _kwarg(c, ("dbname", "database", "db"))
237
+ dv = _value(prog, m, db) if db is not None else None
238
+ f["resource"] = dv[1] if dv and dv[0] == "lit" else None
239
+ pw = _kwarg(c, ("password", "passwd"))
240
+ if pw is not None:
241
+ pv = _value(prog, m, pw)
242
+ f["password"] = ("env", pv[1]) if pv and pv[0] == "env" else ("literal", f"{m.file}:{pw.lineno}") \
243
+ if pv and pv[0] == "lit" and pv[1] else None
244
+ facts.append(f)
245
+ return facts
@@ -0,0 +1,107 @@
1
+ """Python stored attributes as field nodes (#88 phase 1).
2
+
3
+ A class's instance attributes (`self.x = ...` in any of its methods) and its annotated class-level attributes
4
+ (`x: int = 0` in a dataclass / pydantic model / attrs class; not `ClassVar[...]`) are `field:<Class>.<attr>` nodes,
5
+ attrs `property: stored`, `declared: self | class`. Plain class-level assignments stay out: they are constants or
6
+ framework descriptors (Django model fields are the Django plugin's own `field:` nodes), and methods, properties and
7
+ nested classes are never fields.
8
+
9
+ Every `self.x` / `v.x` attribute access whose receiver type the inference knows (`self`, a parameter annotation, a
10
+ local `v = Cart()`, a typed attribute) and whose class (or a base) declares the field is a READS_PROP / WRITES_PROP
11
+ edge (`resolved`, attr `receiver`). A write is an assignment / augmented assignment / `del` target, or the receiver
12
+ of an in-place collection method (`self.items.append(x)`, `via: mutating`), or an item assignment / deletion
13
+ (`self.cache[k] = v`, `via: item`). An unknown receiver binds nothing. Test
14
+ code's accesses become TEST_USES (tests_index.py).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+
20
+ from ...core.model import RESOLVED
21
+
22
+ MUTATING = frozenset({
23
+ "append", "extend", "insert", "remove", "pop", "clear", "sort", "reverse", "add", "discard", "update",
24
+ "setdefault", "popitem", "difference_update", "intersection_update", "symmetric_difference_update", "appendleft",
25
+ "extendleft", "popleft", "rotate", "put", "put_nowait"})
26
+
27
+
28
+ def _classvar(ann) -> bool:
29
+ t = ast.unparse(ann) if ann is not None else ""
30
+ return t.startswith(("ClassVar", "typing.ClassVar", "Final", "typing.Final", "InitVar", "dataclasses.InitVar"))
31
+
32
+
33
+ def index(prog, b, Ctx, walk_body) -> dict:
34
+ fields: dict[str, dict[str, str]] = {}
35
+ names: set[str] = set()
36
+ n_nodes = 0
37
+ for c in prog.classes.values():
38
+ if c.id not in b.nodes:
39
+ continue
40
+ own: dict[str, tuple[int, str]] = {}
41
+ for nm, (val, line, ann) in c.attrs.items():
42
+ if ann is not None and not _classvar(ann):
43
+ own[nm] = (line, "class")
44
+ for nm, line in c.self_attr_lines.items():
45
+ own.setdefault(nm, (line, "self"))
46
+ inner = getattr(c, "inner_infos", {})
47
+ for nm, (line, how) in own.items():
48
+ if nm in c.methods or nm in inner:
49
+ continue
50
+ fid = b.add_node("field", f"{c.qual}.{nm}", name=nm, fqn=f"{c.qual}.{nm}", file=c.file, line=line,
51
+ module=b.nodes[c.id].module, lang="python",
52
+ attrs={"property": "stored", "declared": how})
53
+ b.add_edge(c.id, fid, "CONTAINS", c.file, line, RESOLVED)
54
+ fields.setdefault(c.qual, {})[nm] = fid
55
+ names.add(nm)
56
+ n_nodes += 1
57
+ if not names:
58
+ return {}
59
+
60
+ def lookup(cls, attr):
61
+ if cls.qual in fields and attr in fields[cls.qual]:
62
+ return fields[cls.qual][attr]
63
+ for bt in prog.mro(cls):
64
+ if bt[0] == "type" and bt[1].qual in fields and attr in fields[bt[1].qual]:
65
+ return fields[bt[1].qual][attr]
66
+ return None
67
+
68
+ reads = writes = mutating = unresolved = 0
69
+ for f in list(prog.funcs.values()):
70
+ ctx = Ctx(f.module, f, f.cls)
71
+ parent: dict[int, ast.AST] = {}
72
+ attrs = []
73
+ for n in walk_body(f.node):
74
+ for ch in ast.iter_child_nodes(n):
75
+ parent[id(ch)] = n
76
+ if isinstance(n, ast.Attribute) and n.attr in names:
77
+ attrs.append(n)
78
+ for a in attrs:
79
+ p = parent.get(id(a))
80
+ if isinstance(p, ast.Call) and p.func is a:
81
+ continue # `self.save()`: a call, not an attribute access
82
+ try:
83
+ t = prog.infer(a.value, ctx)
84
+ except RecursionError:
85
+ t = None
86
+ fid = lookup(t[1], a.attr) if t is not None and t[0] == "inst" else None
87
+ if fid is None:
88
+ unresolved += 1
89
+ continue
90
+ via = None
91
+ write = isinstance(a.ctx, (ast.Store, ast.Del))
92
+ if not write and isinstance(p, ast.Subscript) and p.value is a and isinstance(p.ctx, (ast.Store, ast.Del)):
93
+ write, via = True, "item" # `self.cache[k] = v` / `del self.cache[k]`
94
+ elif not write and isinstance(p, ast.Attribute) and p.value is a and p.attr in MUTATING:
95
+ gp = parent.get(id(p))
96
+ if isinstance(gp, ast.Call) and gp.func is p:
97
+ write, via = True, "mutating"
98
+ recv = ast.unparse(a.value)[:40] if not (isinstance(a.value, ast.Name) and a.value.id == "self") else "self"
99
+ b.add_edge(f.id, fid, "WRITES_PROP" if write else "READS_PROP", f.file, a.lineno, RESOLVED,
100
+ receiver=recv, **({"via": via} if via else {}))
101
+ if write:
102
+ writes += 1
103
+ mutating += via == "mutating"
104
+ else:
105
+ reads += 1
106
+ return {"stored_attribute_nodes": n_nodes, "stored_attribute_reads": reads, "stored_attribute_writes": writes,
107
+ "stored_attribute_writes_mutating": mutating, "stored_attribute_refs_unresolved": unresolved}