pythograph 0.2.0__tar.gz → 0.2.2__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 (86) hide show
  1. {pythograph-0.2.0 → pythograph-0.2.2}/CHANGELOG.md +25 -0
  2. {pythograph-0.2.0 → pythograph-0.2.2}/PKG-INFO +18 -1
  3. {pythograph-0.2.0 → pythograph-0.2.2}/README.ko.md +15 -0
  4. {pythograph-0.2.0 → pythograph-0.2.2}/README.md +17 -0
  5. {pythograph-0.2.0 → pythograph-0.2.2}/pyproject.toml +1 -1
  6. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/__init__.py +1 -1
  7. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/exposure.py +10 -2
  8. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/limitations.py +5 -0
  9. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/command.py +5 -0
  10. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sql.py +6 -0
  11. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/extract.py +4 -0
  12. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/command.py +5 -0
  13. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/versions.py +10 -9
  14. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/source/project.py +135 -26
  15. {pythograph-0.2.0 → pythograph-0.2.2}/.gitignore +0 -0
  16. {pythograph-0.2.0 → pythograph-0.2.2}/LICENSE +0 -0
  17. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/__main__.py +0 -0
  18. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/cli/__init__.py +0 -0
  19. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/cli/common.py +0 -0
  20. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/cli/graph_commands.py +0 -0
  21. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/cli/main.py +0 -0
  22. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/__init__.py +0 -0
  23. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/client_document.py +0 -0
  24. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/document.py +0 -0
  25. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/order.py +0 -0
  26. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/persistence.py +0 -0
  27. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/scope.py +0 -0
  28. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/template.py +0 -0
  29. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/__init__.py +0 -0
  30. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/bound.py +0 -0
  31. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/build.py +0 -0
  32. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/calls.py +0 -0
  33. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/collect.py +0 -0
  34. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/document.py +0 -0
  35. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/flow.py +0 -0
  36. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/framework.py +0 -0
  37. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/framework_table.py +0 -0
  38. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/index.py +0 -0
  39. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/model.py +0 -0
  40. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/mro.py +0 -0
  41. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/revision.py +0 -0
  42. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/roots.py +0 -0
  43. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/scope.py +0 -0
  44. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/traversal.py +0 -0
  45. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/values.py +0 -0
  46. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/__init__.py +0 -0
  47. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/__init__.py +0 -0
  48. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/apps.py +0 -0
  49. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/catalog.py +0 -0
  50. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/declarations.py +0 -0
  51. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/fields.py +0 -0
  52. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/queries.py +0 -0
  53. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/settings.py +0 -0
  54. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/location.py +0 -0
  55. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/model.py +0 -0
  56. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/names.py +0 -0
  57. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/scope.py +0 -0
  58. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sqlalchemy/__init__.py +0 -0
  59. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sqlalchemy/catalog.py +0 -0
  60. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sqlalchemy/queries.py +0 -0
  61. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sqltext.py +0 -0
  62. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/py.typed +0 -0
  63. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/__init__.py +0 -0
  64. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/classes.py +0 -0
  65. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/__init__.py +0 -0
  66. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/compose.py +0 -0
  67. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/constants.py +0 -0
  68. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/formats.py +0 -0
  69. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/libraries.py +0 -0
  70. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/model.py +0 -0
  71. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/parts.py +0 -0
  72. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/wrappers.py +0 -0
  73. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/__init__.py +0 -0
  74. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/drf.py +0 -0
  75. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/extract.py +0 -0
  76. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/settings.py +0 -0
  77. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/urlconf.py +0 -0
  78. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/views.py +0 -0
  79. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/flask/__init__.py +0 -0
  80. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/flask/extract.py +0 -0
  81. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/flask/rules.py +0 -0
  82. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/model.py +0 -0
  83. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/pattern.py +0 -0
  84. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/source/__init__.py +0 -0
  85. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/source/evaluate.py +0 -0
  86. {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/source/symbols.py +0 -0
@@ -4,6 +4,31 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.2] - Unreleased
8
+
9
+ 릴리스 준비 중이며 아직 PyPI에 발행하지 않았다. 게시 후 실제 공개 날짜를 기록한다.
10
+
11
+ ### Fixed
12
+
13
+ - 같은 검색 루트의 `name.py`와 `name/__init__.py`가 공존하면 Python import와 같이 일반 패키지를 먼저
14
+ 선택한다. 가려진 모듈로 향하던 거짓 직접 호출 간선과 실제 패키지 대상의 누락을 바로잡는다.
15
+
16
+ - 모듈·패키지 이름 해석과 소스·의존성 선언 읽기에서 마지막 파일뿐 아니라 모든 프로젝트 상대 경로 성분의 링크를
17
+ 거부한다. Django settings·URLconf 문자열이 링크 부모를 통해 프로젝트 밖 파일의 사실을 가져오던 경계를 바로잡는다.
18
+ - 내부를 가리키는 링크와 Windows 디렉터리 junction도 제외하며, 같은 링크는 중복 없이 한계에 센다.
19
+ - 지원하는 운영체제에서는 디렉터리 핸들 상대 `O_NOFOLLOW`로 순회·읽기를 고정하고, 읽기 전에 파일 신원을 확인한다.
20
+ 링크 교체 경쟁 회귀 테스트와 대체 경로의 원자성 한계를 문서화한다. 분석 대상 코드는 여전히 import·실행하지 않는다.
21
+
22
+ - 파일 전체를 읽은 뒤 자르던 경로를 제한된 스트림 읽기로 바꿔 4 MiB 상한을 실제 메모리 사용에 적용한다.
23
+ - 디렉터리 전체를 정렬하기 전에 순회 항목 상한을 적용한다. 초과 디렉터리는 임의 부분 수확 대신 통째로 제외하고 한계를 남긴다.
24
+ - 디렉터리·항목 읽기 실패를 모든 생산자의 한계에 세고, 누락 모듈이 있는데도 `bound` 근거를 확정하던 경로를 막는다.
25
+
26
+ ## [0.2.1] - 2026-10-01
27
+
28
+ ### Fixed
29
+
30
+ - SQL FROM/JOIN의 테이블 값 함수를 관계로 추측하지 않고 미해석 피연산자로 센다.
31
+
7
32
  ## [0.2.0] - 2026-10-01
8
33
 
9
34
  0.1.0 이후 #7–#12를 담는다. 클라이언트 route-call(`routes --role client`, #10)로 파이썬 호출부를 서버 route에 잇고, `bound` 근거
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pythograph
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Static facts for Python (Django, Django REST framework, Flask, SQLAlchemy) services for the isthmus bridge-facts exchange: HTTP route declarations and persistence relation uses from the standard-library ast, without importing the analyzed project.
5
5
  Project-URL: Homepage, https://github.com/ictechgy/pythograph
6
6
  Project-URL: Repository, https://github.com/ictechgy/pythograph
@@ -29,6 +29,8 @@ Description-Content-Type: text/markdown
29
29
 
30
30
  # pythograph
31
31
 
32
+ <img src="https://raw.githubusercontent.com/ictechgy/pythograph/main/icon.png" alt="pythograph's blue tit mascot" width="112" height="112" align="right">
33
+
32
34
  [한국어](https://github.com/ictechgy/pythograph/blob/main/README.ko.md)
33
35
 
34
36
  Static facts for Python services (Django, Django REST framework, Flask, SQLAlchemy), emitted in the
@@ -378,6 +380,21 @@ client `route-call` facts (`routes --role client`), persistence `relation-use` f
378
380
  analyses (`language-traversal` v1) in `trace`. Earlier isthmus releases reject python route-decl and route-call
379
381
  documents as input errors. `--dispatch specificity` remains for those older releases.
380
382
 
383
+ ## Bounded source scanning
384
+
385
+ Source reads request at most 4 MiB plus one overflow byte. Directory enumeration consumes at most 200,000 entries
386
+ plus one overflow entry, with a maximum depth of 64. An oversized directory is omitted as a whole rather than selecting
387
+ an arbitrary filesystem-order subset; files from previously completed directories remain available. Unreadable directory
388
+ entries are counted in limitations for routes, schema, and graph output, and prevent closed-world `bound` proofs that
389
+ would otherwise rely on missing modules. A successful command with these limitations is still an incomplete analysis.
390
+
391
+ Every project-relative path component is checked for symbolic links, including module/package resolution and dependency
392
+ manifests. Links to locations inside the project are rejected too; the skipped-link count counts each encountered path
393
+ once. On systems supporting descriptor-relative `O_NOFOLLOW`, directory scans and source reads open each component
394
+ without following links, and verify the opened file before reading. Other systems recheck components and file identity
395
+ before reading, but those checks are not atomic. Analyze a stable project tree; this is not a filesystem snapshot or a
396
+ sandbox against concurrent changes to the filesystem. The analyzed Python code is never imported or executed.
397
+
381
398
  ## Development
382
399
 
383
400
  ```sh
@@ -1,5 +1,7 @@
1
1
  # pythograph
2
2
 
3
+ <img src="https://raw.githubusercontent.com/ictechgy/pythograph/main/icon.png" alt="pythograph의 푸른박새 마스코트" width="112" height="112" align="right">
4
+
3
5
  [English](README.md)
4
6
 
5
7
  Python 서비스(Django, Django REST framework, Flask, SQLAlchemy)의 정적 사실을
@@ -279,6 +281,19 @@ isthmus-cli 0.10.0(npm, `npm install --global isthmus-cli@0.10.0`)이 `platform:
279
281
  persistence `relation-use`, trace의 python `forward`·`reverse` 분석(`language-traversal` v1). 그 전 isthmus 릴리스는 python
280
282
  route-decl·route-call 문서를 입력 오류로 거부한다. 그런 옛 판용 `--dispatch specificity`는 그대로 남아 있다.
281
283
 
284
+ ## 소스 읽기 상한
285
+
286
+ 파일은 최대 4 MiB와 초과 확인용 1바이트만 읽습니다. 디렉터리 열거는 최대 200,000개와 초과 확인용 항목 하나,
287
+ 깊이는 64까지입니다. 상한을 넘는 디렉터리는 파일시스템 열거 순서에 따른 임의의 일부 대신 전체를 제외하고,
288
+ 앞서 완전히 순회한 디렉터리의 파일은 보존합니다. 읽지 못한 디렉터리 항목 수는 routes·schema·graph 한계에 남기고,
289
+ 보이지 않는 모듈에 의존하는 닫힌 세계 `bound` 증명도 막습니다. 이런 한계와 함께 성공한 명령은 여전히 불완전한 분석입니다.
290
+
291
+ 모듈·패키지 이름 해석과 의존성 선언 파일을 포함해 프로젝트 상대 경로의 모든 성분에서 심볼릭 링크를 검사합니다.
292
+ 프로젝트 내부를 가리키는 링크도 거부하며, 발견한 같은 링크 경로는 한 번만 셉니다. 디렉터리 핸들 상대 `O_NOFOLLOW`를
293
+ 지원하는 운영체제에서는 순회와 소스 읽기 때 각 성분을 링크를 따라가지 않고 열고, 읽기 전에 열린 파일의 신원도 확인합니다.
294
+ 다른 운영체제에서는 읽기 전에 경로 성분과 파일 신원을 재검사하지만 원자적인 검사는 아닙니다. 변경 중이 아닌 프로젝트를
295
+ 분석하세요. 파일시스템 스냅샷이나 동시 변경에 대한 샌드박스는 아닙니다. 분석 대상 Python 코드는 import·실행하지 않습니다.
296
+
282
297
  ## 개발
283
298
 
284
299
  ```sh
@@ -1,5 +1,7 @@
1
1
  # pythograph
2
2
 
3
+ <img src="https://raw.githubusercontent.com/ictechgy/pythograph/main/icon.png" alt="pythograph's blue tit mascot" width="112" height="112" align="right">
4
+
3
5
  [한국어](https://github.com/ictechgy/pythograph/blob/main/README.ko.md)
4
6
 
5
7
  Static facts for Python services (Django, Django REST framework, Flask, SQLAlchemy), emitted in the
@@ -349,6 +351,21 @@ client `route-call` facts (`routes --role client`), persistence `relation-use` f
349
351
  analyses (`language-traversal` v1) in `trace`. Earlier isthmus releases reject python route-decl and route-call
350
352
  documents as input errors. `--dispatch specificity` remains for those older releases.
351
353
 
354
+ ## Bounded source scanning
355
+
356
+ Source reads request at most 4 MiB plus one overflow byte. Directory enumeration consumes at most 200,000 entries
357
+ plus one overflow entry, with a maximum depth of 64. An oversized directory is omitted as a whole rather than selecting
358
+ an arbitrary filesystem-order subset; files from previously completed directories remain available. Unreadable directory
359
+ entries are counted in limitations for routes, schema, and graph output, and prevent closed-world `bound` proofs that
360
+ would otherwise rely on missing modules. A successful command with these limitations is still an incomplete analysis.
361
+
362
+ Every project-relative path component is checked for symbolic links, including module/package resolution and dependency
363
+ manifests. Links to locations inside the project are rejected too; the skipped-link count counts each encountered path
364
+ once. On systems supporting descriptor-relative `O_NOFOLLOW`, directory scans and source reads open each component
365
+ without following links, and verify the opened file before reading. Other systems recheck components and file identity
366
+ before reading, but those checks are not atomic. Analyze a stable project tree; this is not a filesystem snapshot or a
367
+ sandbox against concurrent changes to the filesystem. The analyzed Python code is never imported or executed.
368
+
352
369
  ## Development
353
370
 
354
371
  ```sh
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pythograph"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Static facts for Python (Django, Django REST framework, Flask, SQLAlchemy) services for the isthmus bridge-facts exchange: HTTP route declarations and persistence relation uses from the standard-library ast, without importing the analyzed project."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -4,4 +4,4 @@
4
4
  """
5
5
 
6
6
  #: 배포 버전. `pyproject.toml`의 `project.version`과 같아야 한다(테스트가 대조한다).
7
- __version__ = "0.2.0"
7
+ __version__ = "0.2.2"
@@ -338,8 +338,16 @@ def collect_program(project: Project, index: DefinitionIndex, resolver: Resolver
338
338
  모은 사실.
339
339
  """
340
340
  facts = ProgramFacts(library=detect_library(project), whole=whole)
341
- if project.scan_capped or project.skipped_links or project.unencodable_names or index.unparsed:
342
- facts.incomplete = "the scan is incomplete (unparsed files, skipped links, or the file limit)"
341
+ if (
342
+ project.scan_capped
343
+ or project.skipped_links
344
+ or project.unencodable_names
345
+ or project.scan_errors
346
+ or index.unparsed
347
+ ):
348
+ facts.incomplete = (
349
+ "the scan is incomplete (unreadable entries, unparsed files, skipped links, or the file limit)"
350
+ )
343
351
  names = _definition_names(index, whole)
344
352
  for definition in sorted(index.definitions.values(), key=lambda item: item.id):
345
353
  if whole or not is_test_path(definition.path):
@@ -173,4 +173,9 @@ def _scan_lines(project: Project, statistics: dict[str, object]) -> list[str]:
173
173
  "scan-incomplete: the file walk stopped at a limit or skipped symbolic links or undecodable names; "
174
174
  "symbols in those files are missing"
175
175
  )
176
+ if project.scan_errors:
177
+ lines.append(
178
+ f"scan-incomplete: {project.scan_errors} directory entries could not be read; "
179
+ "symbols in those paths may be missing"
180
+ )
176
181
  return lines
@@ -277,6 +277,11 @@ def _project_gaps(project: Project, extraction: PersistenceExtraction) -> None:
277
277
  """
278
278
  if project.scan_capped:
279
279
  extraction.add_gap("scan-truncated:", "the project scan stopped at its entry or depth limit")
280
+ if project.scan_errors:
281
+ extraction.add_gap(
282
+ "unreadable-sources:",
283
+ f"{project.scan_errors} directory entries could not be read; the project scan is incomplete",
284
+ )
280
285
  if project.unencodable_names:
281
286
  extraction.add_gap(
282
287
  "unreadable-sources:", f"{project.unencodable_names} Python files have names that are not valid UTF-8"
@@ -586,6 +586,12 @@ class _RelationScan:
586
586
  self.unresolved += 1
587
587
  return None
588
588
  name, after_name = read
589
+ # FROM/JOIN의 함수는 관계 신원이 아니다. INSERT의 컬럼 목록은 그대로 읽는다.
590
+ if keyword.text.lower() in {"from", "join"} and self._is_symbol(after_name, "("):
591
+ self.unresolved += 1
592
+ for consumed in range(index, after_name):
593
+ self.consumed[consumed] = True
594
+ return _skip_parens(self.tokens, after_name)
589
595
  if buffer is None:
590
596
  self._emit(name, keyword)
591
597
  else:
@@ -808,6 +808,10 @@ def _project_gaps(project: Project, extraction: ClientExtraction) -> None:
808
808
  """
809
809
  if project.scan_capped:
810
810
  extraction.add_gap(COVERAGE, "the project scan stopped at its entry or depth limit")
811
+ if project.scan_errors:
812
+ extraction.add_gap(
813
+ COVERAGE, f"{project.scan_errors} directory entries could not be read; the project scan is incomplete"
814
+ )
811
815
  if project.unencodable_names:
812
816
  extraction.add_gap(
813
817
  COVERAGE,
@@ -104,6 +104,11 @@ def _project_gaps(project: Project, extraction: Extraction) -> None:
104
104
  """
105
105
  if project.scan_capped:
106
106
  extraction.add_gap("route-coverage:", "the project scan stopped at its entry or depth limit")
107
+ if project.scan_errors:
108
+ extraction.add_gap(
109
+ "route-coverage:",
110
+ f"{project.scan_errors} directory entries could not be read; the project scan is incomplete",
111
+ )
107
112
  if project.unencodable_names:
108
113
  extraction.add_gap(
109
114
  "route-coverage:",
@@ -95,21 +95,22 @@ def _requirement_files(project: Project, root: str) -> list[str]:
95
95
  Returns:
96
96
  경로 목록.
97
97
  """
98
- directory = project.root / root if root else project.root
99
98
  found: list[str] = []
100
99
  try:
101
- entries = sorted(directory.iterdir())
100
+ with project.scan_directory(root) as iterator:
101
+ names = sorted(entry.name for entry in iterator)
102
102
  except OSError:
103
103
  return found
104
- for entry in entries:
105
- relative = PurePosixPath(root, entry.name).as_posix() if root else entry.name
106
- if entry.name.startswith("requirements") and entry.name.endswith(".txt"):
104
+ for name in names:
105
+ relative = PurePosixPath(root, name).as_posix() if root else name
106
+ if name.startswith("requirements") and name.endswith(".txt"):
107
107
  found.append(relative)
108
- elif entry.name == "requirements" and project.is_directory(relative):
108
+ elif name == "requirements" and project.is_directory(relative):
109
109
  try:
110
- found.extend(
111
- f"{relative}/{child.name}" for child in sorted(entry.iterdir()) if child.name.endswith(".txt")
112
- )
110
+ with project.scan_directory(relative) as iterator:
111
+ found.extend(
112
+ sorted(f"{relative}/{child.name}" for child in iterator if child.name.endswith(".txt"))
113
+ )
113
114
  except OSError:
114
115
  continue
115
116
  return found
@@ -12,8 +12,11 @@ from __future__ import annotations
12
12
  import ast
13
13
  import os
14
14
  import stat
15
+ from collections.abc import Iterator
16
+ from contextlib import contextmanager
15
17
  from dataclasses import dataclass, field
16
- from pathlib import Path, PurePosixPath
18
+ from itertools import islice
19
+ from pathlib import Path, PurePosixPath, PureWindowsPath
17
20
 
18
21
  #: 읽을 파이썬 파일의 최대 크기(바이트)다. 더 크면 생성물로 보고 건너뛴다.
19
22
  MAX_SOURCE_BYTES = 4 * 1024 * 1024
@@ -24,6 +27,14 @@ MAX_SCANNED_ENTRIES = 200_000
24
27
  #: 순회할 최대 디렉터리 깊이다.
25
28
  MAX_SCAN_DEPTH = 64
26
29
 
30
+ #: 지원하는 운영체제에서는 디렉터리 핸들에 상대적으로 열어 링크 교체 경쟁을 막는다.
31
+ _HAS_DIR_FD = (
32
+ os.open in os.supports_dir_fd
33
+ and os.scandir in os.supports_fd
34
+ and hasattr(os, "O_NOFOLLOW")
35
+ and hasattr(os, "O_DIRECTORY")
36
+ )
37
+
27
38
  #: 순회에서 건너뛰는 디렉터리 이름이다(가상 환경·캐시·빌드 산출물·의존성).
28
39
  SKIPPED_DIRECTORIES = frozenset(
29
40
  {
@@ -82,6 +93,8 @@ class Project:
82
93
  skipped_links: int = 0
83
94
  scan_capped: bool = False
84
95
  unencodable_names: int = 0
96
+ scan_errors: int = 0
97
+ _skipped_link_paths: set[str] = field(default_factory=set)
85
98
 
86
99
  @classmethod
87
100
  def open(cls, root: Path) -> Project:
@@ -129,33 +142,47 @@ class Project:
129
142
  발견한 `.py` 파일의 프로젝트 상대 경로.
130
143
  """
131
144
  found: list[str] = []
132
- pending: list[tuple[Path, int]] = [(self.root, 0)]
145
+ pending: list[tuple[str, int]] = [("", 0)]
133
146
  scanned = 0
134
147
  while pending:
135
148
  directory, depth = pending.pop()
149
+ remaining = MAX_SCANNED_ENTRIES - scanned
150
+ entries: list[os.DirEntry[str]] = []
136
151
  try:
137
- entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
152
+ with self.scan_directory(directory) as iterator:
153
+ for entry in islice(iterator, remaining + 1):
154
+ scanned += 1
155
+ entries.append(entry)
156
+ if scanned > MAX_SCANNED_ENTRIES:
157
+ # 파일시스템 열거 순서는 안정적이지 않다. 상한을 넘은 디렉터리는
158
+ # 임의의 접두사만 분석하지 않고 전부 제외해 부분 사실을 꾸미지 않는다.
159
+ self.scan_capped = True
160
+ return found
161
+ for entry in sorted(entries, key=lambda entry: entry.name):
162
+ try:
163
+ self._visit(entry, directory, depth, pending, found)
164
+ except OSError:
165
+ self.scan_errors += 1
138
166
  except OSError:
167
+ self.scan_errors += 1
139
168
  continue
140
- for entry in entries:
141
- scanned += 1
142
- if scanned > MAX_SCANNED_ENTRIES:
143
- self.scan_capped = True
144
- return found
145
- self._visit(entry, depth, pending, found)
146
169
  return found
147
170
 
148
- def _visit(self, entry: os.DirEntry[str], depth: int, pending: list[tuple[Path, int]], found: list[str]) -> None:
171
+ def _visit(
172
+ self, entry: os.DirEntry[str], directory: str, depth: int, pending: list[tuple[str, int]], found: list[str]
173
+ ) -> None:
149
174
  """순회 항목 하나를 분류한다.
150
175
 
151
176
  Args:
152
177
  entry: 디렉터리 항목.
178
+ directory: 부모의 프로젝트 상대 경로.
153
179
  depth: 부모 깊이.
154
180
  pending: 더 들어갈 디렉터리 목록(수정한다).
155
181
  found: 발견한 파일 목록(수정한다).
156
182
  """
157
- if entry.is_symlink():
158
- self.skipped_links += 1
183
+ relative = PurePosixPath(directory, entry.name).as_posix()
184
+ if _is_link(entry.stat(follow_symlinks=False)):
185
+ self._record_link(relative)
159
186
  return
160
187
  if entry.is_dir(follow_symlinks=False):
161
188
  if entry.name.startswith(".") or entry.name in SKIPPED_DIRECTORIES:
@@ -163,9 +190,8 @@ class Project:
163
190
  if depth + 1 > MAX_SCAN_DEPTH:
164
191
  self.scan_capped = True
165
192
  return
166
- pending.append((Path(entry.path), depth + 1))
193
+ pending.append((relative, depth + 1))
167
194
  elif entry.name.endswith(".py") and entry.is_file(follow_symlinks=False):
168
- relative = Path(entry.path).relative_to(self.root).as_posix()
169
195
  if _is_encodable(relative):
170
196
  found.append(relative)
171
197
  else:
@@ -182,7 +208,7 @@ class Project:
182
208
  디렉터리면 True.
183
209
  """
184
210
  try:
185
- mode = os.lstat(self.root / relative).st_mode
211
+ mode = self._checked_stat(relative).st_mode
186
212
  except OSError:
187
213
  return False
188
214
  return stat.S_ISDIR(mode)
@@ -197,7 +223,7 @@ class Project:
197
223
  일반 파일이면 True.
198
224
  """
199
225
  try:
200
- mode = os.lstat(self.root / relative).st_mode
226
+ mode = self._checked_stat(relative).st_mode
201
227
  except OSError:
202
228
  return False
203
229
  return stat.S_ISREG(mode)
@@ -212,10 +238,10 @@ class Project:
212
238
  Returns:
213
239
  내용(BOM 제거), 읽을 수 없으면 None.
214
240
  """
215
- if not self.is_file(relative) or not _inside(self.root, relative):
241
+ if limit < 0:
216
242
  return None
217
243
  try:
218
- data = (self.root / relative).read_bytes()[: limit + 1]
244
+ data = self._read_bytes(relative, limit)
219
245
  except OSError:
220
246
  return None
221
247
  if len(data) > limit:
@@ -257,10 +283,8 @@ class Project:
257
283
  Returns:
258
284
  파싱한 모듈 또는 실패.
259
285
  """
260
- if not self.is_file(relative) or not _inside(self.root, relative):
261
- return ReadFailure(relative, "unreadable or not a regular file")
262
286
  try:
263
- data = (self.root / relative).read_bytes()[: MAX_SOURCE_BYTES + 1]
287
+ data = self._read_bytes(relative, MAX_SOURCE_BYTES)
264
288
  except OSError:
265
289
  return ReadFailure(relative, "unreadable file")
266
290
  if len(data) > MAX_SOURCE_BYTES:
@@ -283,6 +307,78 @@ class Project:
283
307
  is_package=PurePosixPath(relative).name == "__init__.py",
284
308
  )
285
309
 
310
+ def _record_link(self, relative: str) -> None:
311
+ """순회와 이름 해석에서 거부한 같은 링크를 중복 없이 센다."""
312
+ if relative not in self._skipped_link_paths:
313
+ self._skipped_link_paths.add(relative)
314
+ self.skipped_links += 1
315
+
316
+ def _checked_stat(self, relative: str) -> os.stat_result:
317
+ """루트부터 모든 경로 성분을 lstat해 링크·상위 경로·비디렉터리 부모를 거부한다."""
318
+ if not _inside(relative):
319
+ raise OSError("not a project-relative path")
320
+ path = self.root
321
+ parts = PurePosixPath(relative).parts
322
+ for index in range(len(parts) + 1):
323
+ info = path.lstat()
324
+ if _is_link(info):
325
+ self._record_link(PurePosixPath(*parts[:index]).as_posix())
326
+ raise OSError("symbolic link is not followed")
327
+ if index == len(parts):
328
+ return info
329
+ if not stat.S_ISDIR(info.st_mode):
330
+ raise OSError("parent is not a directory")
331
+ path /= parts[index]
332
+ raise AssertionError("path has a final component")
333
+
334
+ @contextmanager
335
+ def _open_directory(self, relative: str) -> Iterator[int | Path]:
336
+ """부모 핸들 기준 O_NOFOLLOW로 디렉터리를 연다. 미지원 환경은 경로를 재검사한다."""
337
+ expected = self._checked_stat(relative)
338
+ if not stat.S_ISDIR(expected.st_mode):
339
+ raise OSError("not a directory")
340
+ if not _HAS_DIR_FD:
341
+ yield self.root / relative
342
+ return
343
+ flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
344
+ descriptor = os.open(self.root, flags)
345
+ try:
346
+ for part in PurePosixPath(relative).parts:
347
+ child = os.open(part, flags, dir_fd=descriptor)
348
+ os.close(descriptor)
349
+ descriptor = child
350
+ if not os.path.samestat(expected, os.fstat(descriptor)):
351
+ raise OSError("directory changed while opening")
352
+ yield descriptor
353
+ finally:
354
+ os.close(descriptor)
355
+
356
+ @contextmanager
357
+ def scan_directory(self, relative: str) -> Iterator[Iterator[os.DirEntry[str]]]:
358
+ """검증한 디렉터리만 열거하고 항목을 분류할 때까지 핸들을 유지한다."""
359
+ with self._open_directory(relative) as directory, os.scandir(directory) as entries:
360
+ yield entries
361
+
362
+ def _read_bytes(self, relative: str, limit: int) -> bytes:
363
+ """검증한 일반 파일 핸들에서만 제한된 바이트를 읽어 검사·열기 사이 링크 교체를 막는다."""
364
+ expected = self._checked_stat(relative)
365
+ if not stat.S_ISREG(expected.st_mode):
366
+ raise OSError("not a regular file")
367
+ path = PurePosixPath(relative)
368
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
369
+ with self._open_directory(path.parent.as_posix()) as directory:
370
+ if isinstance(directory, int):
371
+ descriptor = os.open(path.name, flags, dir_fd=directory)
372
+ else:
373
+ descriptor = os.open(directory / path.name, flags)
374
+ with os.fdopen(descriptor, "rb") as source:
375
+ actual = os.fstat(source.fileno())
376
+ if not stat.S_ISREG(actual.st_mode) or not os.path.samestat(expected, actual):
377
+ raise OSError("file changed while opening")
378
+ if not os.path.samestat(actual, self._checked_stat(relative)):
379
+ raise OSError("path changed while opening")
380
+ return source.read(limit + 1)
381
+
286
382
  def module_name(self, relative: str) -> str:
287
383
  """파일 경로에서 모듈 이름을 만든다. 파일을 담은 가장 긴 모듈 루트를 기준으로 한다.
288
384
 
@@ -317,23 +413,36 @@ class Project:
317
413
  relative_parts = name.replace(".", "/")
318
414
  for root in self.module_roots:
319
415
  base = f"{root}/{relative_parts}" if root else relative_parts
320
- for candidate in (f"{base}.py", f"{base}/__init__.py"):
416
+ # 같은 검색 루트에서는 일반 패키지가 같은 이름의 .py 모듈보다 우선한다.
417
+ # __init__.py 없는 namespace 디렉터리는 기존 일반 모듈을 가리지 않는다.
418
+ for candidate in (f"{base}/__init__.py", f"{base}.py"):
321
419
  if self.is_file(candidate):
322
420
  return candidate
323
421
  return None
324
422
 
325
423
 
326
- def _inside(root: Path, relative: str) -> bool:
327
- """상대 경로가 `..` 없이 프로젝트 안을 가리키는지 확인한다.
424
+ def _inside(relative: str) -> bool:
425
+ """플랫폼마다 다르게 해석될 경로와 `..`를 거부한다. 링크 검사는 별도로 한다.
328
426
 
329
427
  Args:
330
- root: 프로젝트 루트.
331
428
  relative: 상대 경로.
332
429
 
333
430
  Returns:
334
431
  안이면 True.
335
432
  """
336
- return ".." not in PurePosixPath(relative).parts and not PurePosixPath(relative).is_absolute()
433
+ return (
434
+ "\x00" not in relative
435
+ and "\\" not in relative
436
+ and ".." not in PurePosixPath(relative).parts
437
+ and not PurePosixPath(relative).is_absolute()
438
+ and not PureWindowsPath(relative).drive
439
+ )
440
+
441
+
442
+ def _is_link(info: os.stat_result) -> bool:
443
+ """심볼릭 링크와 Windows 재분석 지점(디렉터리 junction 포함)을 거부한다."""
444
+ reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
445
+ return stat.S_ISLNK(info.st_mode) or bool(getattr(info, "st_file_attributes", 0) & reparse_point)
337
446
 
338
447
 
339
448
  def _is_encodable(text: str) -> bool:
File without changes
File without changes