pythograph 0.1.0__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 (71) hide show
  1. pythograph/__init__.py +7 -0
  2. pythograph/__main__.py +6 -0
  3. pythograph/cli/__init__.py +1 -0
  4. pythograph/cli/common.py +73 -0
  5. pythograph/cli/graph_commands.py +383 -0
  6. pythograph/cli/main.py +450 -0
  7. pythograph/exchange/__init__.py +1 -0
  8. pythograph/exchange/document.py +280 -0
  9. pythograph/exchange/order.py +168 -0
  10. pythograph/exchange/persistence.py +144 -0
  11. pythograph/exchange/scope.py +271 -0
  12. pythograph/exchange/template.py +177 -0
  13. pythograph/graph/__init__.py +0 -0
  14. pythograph/graph/build.py +511 -0
  15. pythograph/graph/calls.py +381 -0
  16. pythograph/graph/collect.py +383 -0
  17. pythograph/graph/document.py +272 -0
  18. pythograph/graph/framework.py +176 -0
  19. pythograph/graph/framework_table.py +1728 -0
  20. pythograph/graph/index.py +262 -0
  21. pythograph/graph/limitations.py +149 -0
  22. pythograph/graph/model.py +120 -0
  23. pythograph/graph/mro.py +314 -0
  24. pythograph/graph/revision.py +73 -0
  25. pythograph/graph/roots.py +122 -0
  26. pythograph/graph/scope.py +882 -0
  27. pythograph/graph/traversal.py +474 -0
  28. pythograph/graph/values.py +129 -0
  29. pythograph/persistence/__init__.py +0 -0
  30. pythograph/persistence/command.py +288 -0
  31. pythograph/persistence/django/__init__.py +0 -0
  32. pythograph/persistence/django/apps.py +231 -0
  33. pythograph/persistence/django/catalog.py +1120 -0
  34. pythograph/persistence/django/declarations.py +128 -0
  35. pythograph/persistence/django/fields.py +352 -0
  36. pythograph/persistence/django/queries.py +998 -0
  37. pythograph/persistence/django/settings.py +384 -0
  38. pythograph/persistence/location.py +33 -0
  39. pythograph/persistence/model.py +102 -0
  40. pythograph/persistence/names.py +214 -0
  41. pythograph/persistence/scope.py +261 -0
  42. pythograph/persistence/sql.py +795 -0
  43. pythograph/persistence/sqlalchemy/__init__.py +0 -0
  44. pythograph/persistence/sqlalchemy/catalog.py +1082 -0
  45. pythograph/persistence/sqlalchemy/queries.py +540 -0
  46. pythograph/persistence/sqltext.py +296 -0
  47. pythograph/py.typed +0 -0
  48. pythograph/routes/__init__.py +1 -0
  49. pythograph/routes/classes.py +493 -0
  50. pythograph/routes/command.py +137 -0
  51. pythograph/routes/django/__init__.py +1 -0
  52. pythograph/routes/django/drf.py +389 -0
  53. pythograph/routes/django/extract.py +527 -0
  54. pythograph/routes/django/settings.py +141 -0
  55. pythograph/routes/django/urlconf.py +951 -0
  56. pythograph/routes/django/views.py +378 -0
  57. pythograph/routes/flask/__init__.py +1 -0
  58. pythograph/routes/flask/extract.py +1018 -0
  59. pythograph/routes/flask/rules.py +256 -0
  60. pythograph/routes/model.py +134 -0
  61. pythograph/routes/pattern.py +883 -0
  62. pythograph/routes/versions.py +232 -0
  63. pythograph/source/__init__.py +1 -0
  64. pythograph/source/evaluate.py +164 -0
  65. pythograph/source/project.py +371 -0
  66. pythograph/source/symbols.py +562 -0
  67. pythograph-0.1.0.dist-info/METADATA +336 -0
  68. pythograph-0.1.0.dist-info/RECORD +71 -0
  69. pythograph-0.1.0.dist-info/WHEEL +4 -0
  70. pythograph-0.1.0.dist-info/entry_points.txt +2 -0
  71. pythograph-0.1.0.dist-info/licenses/LICENSE +21 -0
pythograph/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """pythograph — Python 서비스의 정적 사실을 isthmus bridge-facts 형식으로 내는 CLI.
2
+
3
+ 분석 대상 프로젝트를 import하거나 실행하지 않고 표준 라이브러리 `ast`로만 읽는다.
4
+ """
5
+
6
+ #: 배포 버전. `pyproject.toml`의 `project.version`과 같아야 한다(테스트가 대조한다).
7
+ __version__ = "0.1.0"
pythograph/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m pythograph` 진입점이다. 콘솔 스크립트와 같은 `main`을 부른다."""
2
+
3
+ from pythograph.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """명령행 인터페이스(인자 해석, 종료 코드 계약)."""
@@ -0,0 +1,73 @@
1
+ """CLI 공통 도우미: 사용법·입력 오류 예외, 시각·프로젝트 경로 검증.
2
+
3
+ `main`과 `graph_commands`가 함께 쓴다(순환 import를 피하려고 따로 둔다).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ #: 계약이 금지하는 식별자 문자다(제어 문자, U+2028/2029, UTF-8로 쓸 수 없는 짝 없는 서로게이트).
14
+ FORBIDDEN = re.compile("[\u0000-\u001f\u007f-\u009f\u2028\u2029\ud800-\udfff]")
15
+
16
+ #: `--generated-at` 형식이다.
17
+ TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z")
18
+
19
+
20
+ class UsageError(Exception):
21
+ """사용법 오류(64). 메시지는 해결 방향을 담는다."""
22
+
23
+
24
+ class InputError(Exception):
25
+ """입력 오류(2). 메시지는 원인과 해결 방향을 담는다."""
26
+
27
+
28
+ def parse_timestamp(value: str | None) -> datetime | None:
29
+ """`--generated-at` 값을 해석한다.
30
+
31
+ Args:
32
+ value: 값.
33
+
34
+ Returns:
35
+ UTC 시각 또는 None.
36
+
37
+ Raises:
38
+ UsageError: 형식 위반.
39
+ """
40
+ if value is None:
41
+ return None
42
+ if TIMESTAMP.fullmatch(value) is None:
43
+ raise UsageError("--generated-at takes a UTC timestamp such as 2026-01-01T00:00:00.000Z.")
44
+ try:
45
+ return datetime.strptime(value[:-1] + ("" if "." in value else ".0"), "%Y-%m-%dT%H:%M:%S.%f").replace(
46
+ tzinfo=timezone.utc
47
+ )
48
+ except ValueError as error:
49
+ raise UsageError("--generated-at takes a valid UTC timestamp such as 2026-01-01T00:00:00.000Z.") from error
50
+
51
+
52
+ def resolve_project(argument: str) -> Path:
53
+ """프로젝트 경로를 realpath로 정규화하고 디렉터리인지 확인한다.
54
+
55
+ Args:
56
+ argument: `--project` 값.
57
+
58
+ Returns:
59
+ realpath.
60
+
61
+ Raises:
62
+ InputError: 디렉터리가 아니거나 계약이 금지하는 문자를 담을 때.
63
+ """
64
+ try:
65
+ root = Path(os.path.realpath(argument))
66
+ is_directory = root.is_dir()
67
+ except (OSError, ValueError):
68
+ is_directory = False
69
+ if not is_directory:
70
+ raise InputError("--project does not name a readable directory; pass the project root.")
71
+ if FORBIDDEN.search(root.as_posix()):
72
+ raise InputError("the project path contains characters the exchange format forbids; rename or move it.")
73
+ return root
@@ -0,0 +1,383 @@
1
+ """`graph`·`reach`·`impact` 명령: 인자 해석, 그래프 생성, 문서 조립.
2
+
3
+ `reach`는 root가 기대는 쪽(`dependencies`), `impact`는 root에 기대는 쪽(`dependents`)을 isthmus `language-traversal`
4
+ v1로 낸다. 그래프 정점이 아닌 root가 섞이면 나머지 root로 순회한 문서를 쓰고 64로 끝난다(tsograph·cartograph와 같은
5
+ root-not-found 규칙). 문서 없는 순수 사용법 오류도 64이며 표준 출력이 비어 있어 구별된다.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import sys
12
+ from dataclasses import dataclass, replace
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import TextIO
16
+
17
+ from pythograph import __version__
18
+ from pythograph.cli.common import InputError, UsageError, parse_timestamp, resolve_project
19
+ from pythograph.exchange.document import DocumentLimitError, encode_document
20
+ from pythograph.graph.build import build_graph
21
+ from pythograph.graph.document import (
22
+ GraphHeader,
23
+ TraversalInput,
24
+ build_graph_document,
25
+ build_traversal_document,
26
+ )
27
+ from pythograph.graph.model import DISPATCH_MODES, CallGraph
28
+ from pythograph.graph.revision import git_revision
29
+ from pythograph.graph.roots import FORBIDDEN_CHARACTERS, RootsError, collect_roots
30
+ from pythograph.graph.traversal import (
31
+ MAX_REACHED,
32
+ MAX_TRAVERSAL_DEPTH,
33
+ TraversalRequest,
34
+ TraversalResult,
35
+ traverse,
36
+ )
37
+ from pythograph.source.project import Project
38
+
39
+ GRAPH_USAGE = """Usage: pythograph graph --project <root> [--include-tests] [--revision <id>]
40
+ [--generated-at <timestamp>] [--format json]
41
+
42
+ Build the project's Python call graph with the standard-library ast and write a pythograph-graph v1
43
+ snapshot (nodes, edges with evidence tiers, unresolved-call counts, limitations) to stdout.
44
+
45
+ Options:
46
+ --project <root> Project root; node ids and locations are relative to it
47
+ --include-tests Also make test sources graph nodes
48
+ --revision <id> Source revision to record (default: git HEAD when the work tree is clean)
49
+ --generated-at <timestamp> Fixed generatedAt (YYYY-MM-DDTHH:MM:SS.sssZ) for byte-identical output
50
+ --format json Output format (json is the only format)
51
+
52
+ Exit codes: 0 success, 2 unreadable project or oversized output, 64 usage error.
53
+ """
54
+
55
+ _TRAVERSAL_OPTIONS = """Options:
56
+ --project <root> Project root; ids are pythograph symbol ids (routes and schema symbol.usr)
57
+ --dispatch <mode> direct (default), bound (currently the direct graph), or candidates
58
+ (also subclass overrides as candidate edges)
59
+ --max-depth <n> Maximum edge count from a root, 1-128 (default 128)
60
+ --max-reached <n> Maximum reached symbols, 1-100000 (default 100000)
61
+ --roots-from <file|-> More roots from a JSON string array or a bridge-facts document (- is stdin)
62
+ --include-tests Also make test sources graph nodes
63
+ --revision <id> Source revision to record (default: git HEAD when the work tree is clean)
64
+ --generated-at <timestamp> Fixed generatedAt (YYYY-MM-DDTHH:MM:SS.sssZ) for byte-identical output
65
+ --format json Output format (json is the only format)
66
+
67
+ Ids that are not graph nodes are listed without symbol (root-not-found); the document is still
68
+ written and the command exits 64. Exit codes: 0 success, 2 unreadable project or oversized output,
69
+ 64 usage error (empty stdout) or root-not-found (document written).
70
+ """
71
+
72
+ REACH_USAGE = (
73
+ """Usage: pythograph reach --project <root> [options] [--] <id>...
74
+
75
+ Write the symbols the roots depend on (direction "dependencies") as an isthmus language-traversal
76
+ v1 document.
77
+
78
+ """
79
+ + _TRAVERSAL_OPTIONS
80
+ )
81
+
82
+ IMPACT_USAGE = (
83
+ """Usage: pythograph impact --project <root> [options] [--] <id>...
84
+
85
+ Write the symbols that depend on the roots (direction "dependents") as an isthmus
86
+ language-traversal v1 document.
87
+
88
+ """
89
+ + _TRAVERSAL_OPTIONS
90
+ )
91
+
92
+ #: graph 명령의 값 옵션이다.
93
+ _GRAPH_VALUES = ("--project", "--format", "--generated-at", "--revision")
94
+
95
+ #: reach·impact 명령의 값 옵션이다.
96
+ _TRAVERSAL_VALUES = (*_GRAPH_VALUES, "--dispatch", "--max-depth", "--max-reached", "--roots-from")
97
+
98
+ #: 불리언 옵션이다.
99
+ _FLAGS = ("--include-tests", "--help")
100
+
101
+ #: `--revision` 최대 길이다.
102
+ MAX_REVISION_LENGTH = 256
103
+
104
+ #: 양의 정수 인자 형식이다.
105
+ _INTEGER = re.compile(r"[1-9][0-9]{0,6}")
106
+
107
+
108
+ class RootNotFoundExit(Exception):
109
+ """정점이 아닌 root가 있어 문서를 쓰고 64로 끝낸다.
110
+
111
+ Attributes:
112
+ text: 표준 출력에 쓸 문서.
113
+ missing: 정점이 아닌 root 수.
114
+ """
115
+
116
+ def __init__(self, text: str, missing: int) -> None:
117
+ """예외를 만든다.
118
+
119
+ Args:
120
+ text: 문서.
121
+ missing: 정점이 아닌 root 수.
122
+ """
123
+ super().__init__("root-not-found")
124
+ self.text = text
125
+ self.missing = missing
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class CommandLine:
130
+ """해석한 인자.
131
+
132
+ Attributes:
133
+ values: 값 옵션.
134
+ flags: 불리언 옵션.
135
+ positional: 위치 인자.
136
+ """
137
+
138
+ values: dict[str, str]
139
+ flags: set[str]
140
+ positional: list[str]
141
+
142
+
143
+ def parse_command_line(arguments: list[str], value_flags: tuple[str, ...], usage: str, positional: bool) -> CommandLine:
144
+ """옵션과 위치 인자를 나눈다. `--` 뒤는 모두 위치 인자다.
145
+
146
+ Args:
147
+ arguments: 인자 목록.
148
+ value_flags: 허용하는 값 옵션.
149
+ usage: 오류 문구에 붙일 사용법.
150
+ positional: 위치 인자를 받는지.
151
+
152
+ Returns:
153
+ 해석한 인자.
154
+
155
+ Raises:
156
+ UsageError: 모르는·반복·값 없는 옵션, 받지 않는 위치 인자.
157
+ """
158
+ result = CommandLine({}, set(), [])
159
+ index = 0
160
+ while index < len(arguments):
161
+ token = arguments[index]
162
+ if token == "--" and positional:
163
+ result.positional.extend(arguments[index + 1 :])
164
+ break
165
+ if not token.startswith("--"):
166
+ if not positional:
167
+ raise UsageError("unexpected positional argument.\n" + usage)
168
+ result.positional.append(token)
169
+ index += 1
170
+ continue
171
+ index = _parse_option(arguments, index, value_flags, usage, result)
172
+ return result
173
+
174
+
175
+ def _parse_option(
176
+ arguments: list[str], index: int, value_flags: tuple[str, ...], usage: str, result: CommandLine
177
+ ) -> int:
178
+ """옵션 하나를 해석한다.
179
+
180
+ Args:
181
+ arguments: 인자 목록.
182
+ index: 옵션 위치.
183
+ value_flags: 허용하는 값 옵션.
184
+ usage: 사용법.
185
+ result: 채울 결과.
186
+
187
+ Returns:
188
+ 다음 인자 위치.
189
+
190
+ Raises:
191
+ UsageError: 잘못된 옵션.
192
+ """
193
+ name, has_inline, inline = arguments[index].partition("=")
194
+ if name in _FLAGS and not has_inline and name not in result.flags:
195
+ result.flags.add(name)
196
+ return index + 1
197
+ if name not in value_flags or name in result.values:
198
+ raise UsageError("unknown or repeated option.\n" + usage)
199
+ value = inline if has_inline else (arguments[index + 1] if index + 1 < len(arguments) else "")
200
+ if not value or (not has_inline and value.startswith("--")):
201
+ raise UsageError(f"{name} needs a value.\n" + usage)
202
+ result.values[name] = value
203
+ return index + (1 if has_inline else 2)
204
+
205
+
206
+ def run_graph(arguments: list[str]) -> str:
207
+ """graph 명령을 실행한다.
208
+
209
+ Args:
210
+ arguments: graph 뒤 인자.
211
+
212
+ Returns:
213
+ JSON 문서.
214
+
215
+ Raises:
216
+ UsageError: 사용법 오류.
217
+ InputError: 입력 오류.
218
+ """
219
+ line = parse_command_line(arguments, _GRAPH_VALUES, GRAPH_USAGE, positional=False)
220
+ if "--help" in line.flags:
221
+ return GRAPH_USAGE
222
+ root, header = _common(line, GRAPH_USAGE)
223
+ graph = build_graph(Project.open(root), "--include-tests" in line.flags)
224
+ try:
225
+ return encode_document(build_graph_document(header, graph))
226
+ except DocumentLimitError as error:
227
+ # 스냅샷은 isthmus 입력이 아니므로 상한만 알리고, isthmus가 받는 순회 문서(reach·impact)를 안내한다.
228
+ raise InputError(
229
+ "the graph snapshot would exceed the 16 Mi character output limit; scan a smaller project root, "
230
+ "or use reach/impact, which write only the traversed symbols."
231
+ ) from error
232
+
233
+
234
+ def run_traversal(arguments: list[str], direction: str, stdin: TextIO | None = None) -> str:
235
+ """reach·impact 명령을 실행한다.
236
+
237
+ Args:
238
+ arguments: 명령 뒤 인자.
239
+ direction: `dependencies`(reach) 또는 `dependents`(impact).
240
+ stdin: 표준 입력(`--roots-from -`, 테스트 주입용).
241
+
242
+ Returns:
243
+ JSON 문서.
244
+
245
+ Raises:
246
+ UsageError: 사용법 오류.
247
+ InputError: 입력 오류.
248
+ RootNotFoundExit: 정점이 아닌 root가 있을 때(문서를 싣는다).
249
+ """
250
+ usage = REACH_USAGE if direction == "dependencies" else IMPACT_USAGE
251
+ line = parse_command_line(arguments, _TRAVERSAL_VALUES, usage, positional=True)
252
+ if "--help" in line.flags:
253
+ return usage
254
+ dispatch = line.values.get("--dispatch", "direct")
255
+ if dispatch not in DISPATCH_MODES:
256
+ raise UsageError("--dispatch must be direct, bound, or candidates.")
257
+ max_depth = _bounded(line.values.get("--max-depth"), MAX_TRAVERSAL_DEPTH, "--max-depth")
258
+ max_reached = _bounded(line.values.get("--max-reached"), MAX_REACHED, "--max-reached")
259
+ try:
260
+ requested = collect_roots(line.positional, line.values.get("--roots-from"), stdin or sys.stdin)
261
+ except RootsError as error:
262
+ raise UsageError(str(error)) from error
263
+ root, header = _common(line, usage)
264
+ graph = build_graph(Project.open(root), "--include-tests" in line.flags)
265
+ request = TraversalRequest(requested, direction, max_depth, max_reached, dispatch)
266
+ document, missing = traversal_document(graph, header, request)
267
+ text = _encode(document)
268
+ if missing:
269
+ raise RootNotFoundExit(text, missing)
270
+ return text
271
+
272
+
273
+ def traversal_document(
274
+ graph: CallGraph, header: GraphHeader, request: TraversalRequest
275
+ ) -> tuple[dict[str, object], int]:
276
+ """정점인 root로만 순회하고, root 인덱스를 요청 순서로 옮겨 문서를 만든다.
277
+
278
+ Args:
279
+ graph: 호출 그래프.
280
+ header: 머리 필드.
281
+ request: 요청 순서의 전체 root를 담은 순회 요청.
282
+
283
+ Returns:
284
+ (문서, 정점이 아닌 root 수).
285
+ """
286
+ nodes = graph.node_map()
287
+ resolved = tuple(root for root in request.root_ids if root in nodes)
288
+ missing = frozenset(root for root in request.root_ids if root not in nodes)
289
+ result = TraversalResult((), (), False, False)
290
+ if resolved:
291
+ result = remap_roots(traverse(graph, replace(request, root_ids=resolved)), resolved, request.root_ids)
292
+ data = TraversalInput(header, graph, request.direction, request.dispatch, request.root_ids, missing, result)
293
+ return build_traversal_document(data), len(missing)
294
+
295
+
296
+ def remap_roots(result: TraversalResult, resolved: tuple[str, ...], requested: tuple[str, ...]) -> TraversalResult:
297
+ """정점인 root 순서의 인덱스를 요청 순서의 인덱스로 옮긴다(순서를 보존하므로 규칙 결과가 그대로다).
298
+
299
+ Args:
300
+ result: 순회 결과.
301
+ resolved: 정점인 root(요청 순서 유지).
302
+ requested: 요청 순서의 전체 root.
303
+
304
+ Returns:
305
+ 옮긴 순회 결과.
306
+ """
307
+ if resolved == requested:
308
+ return result
309
+ position = {root: index for index, root in enumerate(requested)}
310
+ mapping = [position[root] for root in resolved]
311
+ reached = tuple(replace(entry, roots=tuple(mapping[index] for index in entry.roots)) for entry in result.reached)
312
+ return replace(result, reached=reached)
313
+
314
+
315
+ def _bounded(value: str | None, limit: int, name: str) -> int:
316
+ """1~limit 정수 인자를 해석한다(없으면 limit).
317
+
318
+ Args:
319
+ value: 값.
320
+ limit: 상한(기본값).
321
+ name: 옵션 이름.
322
+
323
+ Returns:
324
+ 정수.
325
+
326
+ Raises:
327
+ UsageError: 형식·범위 위반.
328
+ """
329
+ if value is None:
330
+ return limit
331
+ if _INTEGER.fullmatch(value) is None or int(value) > limit:
332
+ raise UsageError(f"{name} takes an integer from 1 to {limit}.")
333
+ return int(value)
334
+
335
+
336
+ def _common(line: CommandLine, usage: str) -> tuple[Path, GraphHeader]:
337
+ """공통 인자(project·format·revision·generated-at)를 검증하고 머리 필드를 만든다.
338
+
339
+ Args:
340
+ line: 해석한 인자.
341
+ usage: 사용법.
342
+
343
+ Returns:
344
+ (프로젝트 realpath, 머리 필드).
345
+
346
+ Raises:
347
+ UsageError: 사용법 오류.
348
+ """
349
+ if line.values.get("--format", "json") != "json":
350
+ raise UsageError("--format supports only json.")
351
+ project_argument = line.values.get("--project")
352
+ if project_argument is None:
353
+ raise UsageError("--project <root> is required.\n" + usage)
354
+ revision = line.values.get("--revision")
355
+ if revision is not None and (len(revision) > MAX_REVISION_LENGTH or FORBIDDEN_CHARACTERS.search(revision)):
356
+ raise UsageError(f"--revision must be 1-{MAX_REVISION_LENGTH} characters without control characters.")
357
+ generated_at = parse_timestamp(line.values.get("--generated-at"))
358
+ root = resolve_project(project_argument)
359
+ header = GraphHeader(
360
+ tool_version=__version__,
361
+ generated_at=generated_at or datetime.now(timezone.utc),
362
+ project=root.as_posix(),
363
+ revision=revision if revision is not None else git_revision(root),
364
+ )
365
+ return root, header
366
+
367
+
368
+ def _encode(document: dict[str, object]) -> str:
369
+ """문서를 직렬화한다.
370
+
371
+ Args:
372
+ document: 문서.
373
+
374
+ Returns:
375
+ JSON 문자열.
376
+
377
+ Raises:
378
+ InputError: 출력이 상한을 넘을 때.
379
+ """
380
+ try:
381
+ return encode_document(document)
382
+ except DocumentLimitError as error:
383
+ raise InputError(str(error)) from error