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.
- {pythograph-0.2.0 → pythograph-0.2.2}/CHANGELOG.md +25 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/PKG-INFO +18 -1
- {pythograph-0.2.0 → pythograph-0.2.2}/README.ko.md +15 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/README.md +17 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/pyproject.toml +1 -1
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/__init__.py +1 -1
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/exposure.py +10 -2
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/limitations.py +5 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/command.py +5 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sql.py +6 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/extract.py +4 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/command.py +5 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/versions.py +10 -9
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/source/project.py +135 -26
- {pythograph-0.2.0 → pythograph-0.2.2}/.gitignore +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/LICENSE +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/__main__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/cli/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/cli/common.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/cli/graph_commands.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/cli/main.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/client_document.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/document.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/order.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/persistence.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/scope.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/exchange/template.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/bound.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/build.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/calls.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/collect.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/document.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/flow.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/framework.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/framework_table.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/index.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/model.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/mro.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/revision.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/roots.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/scope.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/traversal.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/graph/values.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/apps.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/catalog.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/declarations.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/fields.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/queries.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/django/settings.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/location.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/model.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/names.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/scope.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sqlalchemy/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sqlalchemy/catalog.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sqlalchemy/queries.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/persistence/sqltext.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/py.typed +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/classes.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/compose.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/constants.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/formats.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/libraries.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/model.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/parts.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/client/wrappers.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/drf.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/extract.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/settings.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/urlconf.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/django/views.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/flask/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/flask/extract.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/flask/rules.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/model.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/routes/pattern.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/source/__init__.py +0 -0
- {pythograph-0.2.0 → pythograph-0.2.2}/src/pythograph/source/evaluate.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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
|
|
342
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
relative = PurePosixPath(root,
|
|
106
|
-
if
|
|
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
|
|
108
|
+
elif name == "requirements" and project.is_directory(relative):
|
|
109
109
|
try:
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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[
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
158
|
-
|
|
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((
|
|
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 =
|
|
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 =
|
|
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
|
|
241
|
+
if limit < 0:
|
|
216
242
|
return None
|
|
217
243
|
try:
|
|
218
|
-
data =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|