pi-python-helper 0.1.0
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.
- package/CHANGELOG.md +60 -0
- package/CONTRIBUTING.md +77 -0
- package/LICENSE +17 -0
- package/README.md +172 -0
- package/SECURITY.md +24 -0
- package/docs/compatibility.md +64 -0
- package/docs/tools.md +518 -0
- package/extensions/index.ts +28 -0
- package/extensions/shared.ts +48 -0
- package/extensions/tools/dependencies.ts +203 -0
- package/extensions/tools/environment.ts +171 -0
- package/extensions/tools/testing.ts +350 -0
- package/extensions/tools/validation.ts +344 -0
- package/helpers/scan_project.py +777 -0
- package/package.json +73 -0
- package/skills/python-development/SKILL.md +45 -0
- package/src/build/commands.ts +65 -0
- package/src/build/discover.ts +98 -0
- package/src/build/failure.ts +452 -0
- package/src/build/pytest.ts +118 -0
- package/src/build/selection.ts +138 -0
- package/src/build/staleness.ts +117 -0
- package/src/core/result.ts +102 -0
- package/src/core/runner.ts +96 -0
- package/src/core/safety.ts +181 -0
- package/src/core/version.ts +20 -0
- package/src/dependencies/plan.ts +398 -0
- package/src/environment/discovery.ts +166 -0
- package/src/environment/tools.ts +141 -0
- package/src/project/conformance.ts +343 -0
- package/src/project/inspect.ts +351 -0
- package/src/project/installed.ts +225 -0
- package/src/project/paths.ts +74 -0
- package/src/project/root.ts +72 -0
- package/src/project/scanner.ts +243 -0
- package/src/validation/bundle.ts +65 -0
- package/src/validation/evidence.ts +33 -0
- package/src/validation/tdd.ts +62 -0
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-python-helper",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Python (uv) development tools for the pi coding agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"python",
|
|
10
|
+
"uv",
|
|
11
|
+
"pytest"
|
|
12
|
+
],
|
|
13
|
+
"license": "Apache-2.0",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/wkqco33/pi-python-helper.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/wkqco33/pi-python-helper/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/wkqco33/pi-python-helper#readme",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"extensions",
|
|
27
|
+
"src",
|
|
28
|
+
"helpers",
|
|
29
|
+
"skills",
|
|
30
|
+
"docs/compatibility.md",
|
|
31
|
+
"docs/tools.md",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"SECURITY.md",
|
|
35
|
+
"CONTRIBUTING.md",
|
|
36
|
+
"CHANGELOG.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "tsx --test test/*.test.ts",
|
|
41
|
+
"test:coverage": "tsx --test --experimental-test-coverage test/*.test.ts",
|
|
42
|
+
"test:e2e": "tsx test/manual/e2e-uv.ts",
|
|
43
|
+
"docs": "tsx scripts/generate-api-surface.ts && tsx scripts/generate-tools-doc.ts",
|
|
44
|
+
"docs:check": "tsx scripts/check-docs.ts",
|
|
45
|
+
"format": "prettier --write \"extensions/**/*.ts\" \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.ts\"",
|
|
46
|
+
"format:check": "prettier --check \"extensions/**/*.ts\" \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.ts\"",
|
|
47
|
+
"pack-check": "npm pack --dry-run",
|
|
48
|
+
"check": "npm test && npm run typecheck && npm run format:check && npm run docs:check && npm run pack-check"
|
|
49
|
+
},
|
|
50
|
+
"pi": {
|
|
51
|
+
"extensions": [
|
|
52
|
+
"./extensions"
|
|
53
|
+
],
|
|
54
|
+
"skills": [
|
|
55
|
+
"./skills"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@earendil-works/pi-ai": "*",
|
|
60
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
61
|
+
"@earendil-works/pi-tui": "*",
|
|
62
|
+
"typebox": "*"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/node": "^26.6.2",
|
|
69
|
+
"prettier": "^3.9.8",
|
|
70
|
+
"tsx": "^4.23.13",
|
|
71
|
+
"typescript": "^7.0.2"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: python-development
|
|
3
|
+
description: pi-python-helper를 활용한 uv 기반 Python 개발 워크플로. 인터프리터/가상환경 점검, pyproject.toml 및 uv.lock 분석, 의존성 드리프트 탐지, pytest 테스트 선별/실행, traceback 및 uv 실패 진단, 완료 게이트 검증 시 사용합니다.
|
|
4
|
+
license: Apache-2.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# pi-python-helper를 활용한 uv 기반 Python 개발 워크플로
|
|
8
|
+
|
|
9
|
+
원시 쉘 명령어(`grep`, `python -c`, `pytest`)를 직접 실행하기 전에 Python 전용 도구를 우선 사용하세요.
|
|
10
|
+
|
|
11
|
+
## 조사 및 작업 순서 (Investigation order)
|
|
12
|
+
|
|
13
|
+
1. 인터프리터나 가상환경 상태가 불확실할 때는 `py_environment`를 실행하세요. 잘못된 Python으로 테스트를 실행하는 것이 가장 흔한 실패 원인입니다.
|
|
14
|
+
2. `pyproject.toml`이나 `uv.lock`을 편집하기 전에 `py_project_inspect`로 레이아웃(src/flat), 의존성 그룹, lockfile 드리프트, **환경 정합성**(선언 ↔ lock ↔ 실제 설치본)을 확인하세요. 정합성은 `consistent` / `drifted` / `unverifiable` 중 하나이며, `unverifiable`을 일치로 해석하지 마세요.
|
|
15
|
+
3. 의존성을 추가/이동하기 전에 `py_dependency_plan`을 사용하세요. `ast`로 실제 import를 스캔하여 다음을 구분합니다:
|
|
16
|
+
- 선언되지 않은 import (런타임 오류로 이어짐)
|
|
17
|
+
- `[project] dependencies`가 아니라 dev 그룹/extra에만 선언된 런타임 import
|
|
18
|
+
- `uv.lock`에 없거나 스펙을 만족하지 않는 버전
|
|
19
|
+
4. import 이름과 배포 이름은 다를 수 있습니다(`PIL`/`pillow`, `yaml`/`PyYAML`). `py_dependency_plan`의 제안을 `uv add`로 적용하세요.
|
|
20
|
+
5. 소스 코드를 수정한 후에는 `py_test_select`로 변경 파일과 연관된 테스트를 선별하세요. 매칭이 실패하면 전체 스위트가 범위가 되며, 선택 이유가 함께 반환됩니다.
|
|
21
|
+
6. 테스트 실행은 `py_test`를 사용하세요. `execute=false`로 먼저 미리보기하고, 실제 실행 시에만 `execute=true`를 전달합니다.
|
|
22
|
+
7. 실패 출력이 있을 때는 `py_failure_diagnose`를 사용하세요. `site-packages` 내부 프레임은 원인이 아니며, 도구는 첫 번째 프로젝트 프레임을 지목합니다.
|
|
23
|
+
8. 의존성이 바뀌었거나 `.venv`가 오래된 경우 `py_sync`로 `uv lock --check` 또는 `uv sync --frozen`을 미리보기/실행하세요.
|
|
24
|
+
9. 재현이 어려운 실패는 `py_test`의 `lastFailed=true`(`--lf`)로 직전 실패만 다시 실행하세요.
|
|
25
|
+
10. 작업 완료를 보고하기 전에 `py_validation_bundle`(lock 검사 → sync → pytest → 환경 정합성 → 오래된 아티팩트 검사)을 실행하고, `py_completion_evidence`로 근거가 충분한지 확인하세요. 정합성이 `drifted`나 `unverifiable`이면 테스트가 통과했어도 게이트는 실패합니다.
|
|
26
|
+
11. `py_tdd_checkpoint`로 프로덕션 변경에 대응하는 테스트 변경이 있는지 확인하세요.
|
|
27
|
+
|
|
28
|
+
## 안전 규칙 (Safety)
|
|
29
|
+
|
|
30
|
+
- `py_sync`와 `py_validation_bundle`은 `execute: true`가 명시적으로 전달되기 전까지 명령을 실행하지 않고 미리보기만 반환합니다. `execute=true`는 `.venv`를 생성/갱신하므로 사용자 확인 없이 반복 실행하지 마세요.
|
|
31
|
+
- Python에는 ROS의 `cmd_vel`처럼 위험을 결정론적으로 알려주는 이름이 없습니다. 다음은 되돌릴 수 없는 작업으로 취급하세요: `uv publish`/`twine upload`(공개 불가 회수), `git push --force`, `git reset --hard`/`git clean -fd`, `alembic downgrade`, `DROP`/`DELETE`(WHERE 없는), `rm -rf`, `conda env remove`.
|
|
32
|
+
- 가상환경을 파괴하는 명령(`rm -rf .venv`, `uv venv --clear`)이나 전역 Python에 패키지를 설치하는 명령(`pip install` without a venv)을 임의로 실행하지 마세요.
|
|
33
|
+
- 도구는 파일을 쓰지 않습니다. `pyproject.toml`/`uv.lock` 수정은 항상 명시적인 편집 도구로 수행하세요.
|
|
34
|
+
|
|
35
|
+
## 해석 규칙 (Interpretation rules)
|
|
36
|
+
|
|
37
|
+
- `PROJECT_INSTALLED_NOT_EDITABLE`는 프로젝트가 환경에 live link가 아니라 복사본으로 설치되어, 소스 변경이 테스트에 반영되지 않음을 의미합니다. `uv sync`로 해결하세요.
|
|
38
|
+
- `PROJECT_NOT_INSTALLED`는 lock이 editable 설치를 기대하는데 `.venv`에 프로젝트가 없다는 뜻입니다. `uv sync`를 실행하고, 그래도 실패하면 빌드 백엔드가 패키지를 찾지 못한 것입니다(`[project] name`과 모듈 디렉터리 이름이 일치하는지 확인).
|
|
39
|
+
- `INSTALLED_VERSION_MISMATCH`는 `uv add`/`uv lock` 후 `uv sync`를 잊은 상태입니다. `uv lock --check`는 이걸 잡지 못하니(락은 최신) 테스트를 신뢰하기 전에 `uv sync --frozen`을 실행하세요.
|
|
40
|
+
- `INSTALLED_PACKAGE_UNTRACKED`는 `.venv`에만 있고 lock에 없는 패키지입니다. 에이전트가 `uv pip install`로 임의 설치했을 가능성을 의심하세요.
|
|
41
|
+
- `CONDITIONAL_PACKAGES_ABSENT`는 정상입니다. `sys_platform == 'win32'`나 `python_version < '3.11'` 같은 마커 때문에 해당 플랫폼에 설치되지 않은 항목이며, 드리프트로 취급하지 마세요.
|
|
42
|
+
- `STALE_COVERAGE_DATA`는 커버리지 결과가 현재 소스보다 오래되었음을 의미합니다. 커버리지 수치를 근거로 사용하지 마세요.
|
|
43
|
+
- `RUNTIME_DEPENDENCY_IN_DEV_GROUP`은 프로덕션 코드가 dev 그룹 의존성을 import한다는 뜻이며, 배포 시 `ModuleNotFoundError`로 이어집니다.
|
|
44
|
+
- `LOCKFILE_DRIFT`가 있으면 `uv lock` 전에는 어떤 테스트 결과도 신뢰하지 마세요.
|
|
45
|
+
- `TYPE_CHECKING` 블록 안의 import는 런타임 의존성이 아니므로 dev 그룹 선언이 정상입니다.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { CommandPreview } from '../core/result.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* uv invocations are built as argument arrays rather than shell strings so user
|
|
5
|
+
* supplied paths can never be interpreted by a shell. `--frozen` is used
|
|
6
|
+
* everywhere a lockfile exists: it forbids implicit re-resolution so a test run
|
|
7
|
+
* cannot silently rewrite uv.lock.
|
|
8
|
+
*/
|
|
9
|
+
export function uvLockCheck(cwd: string, frozen = true): CommandPreview {
|
|
10
|
+
return {
|
|
11
|
+
executable: 'uv',
|
|
12
|
+
args: frozen ? ['lock', '--check', '--offline'] : ['lock', '--check'],
|
|
13
|
+
cwd,
|
|
14
|
+
risk: 'read',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function uvLock(cwd: string): CommandPreview {
|
|
19
|
+
return { executable: 'uv', args: ['lock'], cwd, risk: 'mutating' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function uvSyncFrozen(cwd: string): CommandPreview {
|
|
23
|
+
return { executable: 'uv', args: ['sync', '--frozen', '--all-groups'], cwd, risk: 'mutating' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function uvRun(cwd: string, args: string[]): CommandPreview {
|
|
27
|
+
return { executable: 'uv', args: ['run', '--frozen', ...args], cwd, risk: 'read' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PytestOptions {
|
|
31
|
+
targets?: string[];
|
|
32
|
+
lastFailed?: boolean;
|
|
33
|
+
keyword?: string;
|
|
34
|
+
extraArgs?: string[];
|
|
35
|
+
maxFail?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const PYTEST_BASE_ARGS = ['-q', '--tb=short', '-rf', '--no-header'];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* pytest always runs through `uv run --frozen` so the project environment is
|
|
42
|
+
* used even when the shell was never activated.
|
|
43
|
+
*/
|
|
44
|
+
export function pytestCommand(cwd: string, options: PytestOptions = {}): CommandPreview {
|
|
45
|
+
const args = [...PYTEST_BASE_ARGS];
|
|
46
|
+
if (options.lastFailed) args.push('--lf');
|
|
47
|
+
if (options.keyword) args.push('-k', options.keyword);
|
|
48
|
+
if (options.maxFail !== undefined) args.push('--maxfail', String(options.maxFail));
|
|
49
|
+
args.push(...(options.extraArgs ?? []));
|
|
50
|
+
args.push(...(options.targets ?? []));
|
|
51
|
+
return uvRun(cwd, ['pytest', ...args]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function gitDiffNames(cwd: string, args: string[] = ['--name-only']): CommandPreview {
|
|
55
|
+
return { executable: 'git', args: ['diff', ...args, 'HEAD'], cwd, risk: 'read' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function gitStatusPorcelain(cwd: string): CommandPreview {
|
|
59
|
+
return {
|
|
60
|
+
executable: 'git',
|
|
61
|
+
args: ['status', '--porcelain', '--untracked-files=all'],
|
|
62
|
+
cwd,
|
|
63
|
+
risk: 'read',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runCommand } from '../core/runner.ts';
|
|
4
|
+
import { gitDiffNames, gitStatusPorcelain } from './commands.ts';
|
|
5
|
+
|
|
6
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
7
|
+
'.git',
|
|
8
|
+
'.venv',
|
|
9
|
+
'venv',
|
|
10
|
+
'.tox',
|
|
11
|
+
'.nox',
|
|
12
|
+
'__pycache__',
|
|
13
|
+
'.mypy_cache',
|
|
14
|
+
'.ruff_cache',
|
|
15
|
+
'.pytest_cache',
|
|
16
|
+
'node_modules',
|
|
17
|
+
'build',
|
|
18
|
+
'dist',
|
|
19
|
+
'.eggs',
|
|
20
|
+
'site-packages',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const MAX_WALKED_FILES = 5000;
|
|
24
|
+
|
|
25
|
+
/** Walk the project for Python files, skipping environments and caches. */
|
|
26
|
+
export async function listPythonFiles(root: string): Promise<string[]> {
|
|
27
|
+
const files: string[] = [];
|
|
28
|
+
const stack = [root];
|
|
29
|
+
while (stack.length > 0 && files.length < MAX_WALKED_FILES) {
|
|
30
|
+
const directory = stack.pop() as string;
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (files.length >= MAX_WALKED_FILES) break;
|
|
39
|
+
const path = join(directory, entry.name);
|
|
40
|
+
if (entry.isDirectory()) {
|
|
41
|
+
if (IGNORED_DIRECTORIES.has(entry.name) || entry.name.endsWith('.egg-info')) continue;
|
|
42
|
+
stack.push(path);
|
|
43
|
+
} else if (entry.isFile() && entry.name.endsWith('.py')) {
|
|
44
|
+
files.push(
|
|
45
|
+
path
|
|
46
|
+
.slice(root.length)
|
|
47
|
+
.replace(/^[/\\]/, '')
|
|
48
|
+
.replace(/\\/g, '/'),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return files.sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function listTestFiles(root: string): Promise<string[]> {
|
|
57
|
+
const { isTestFile } = await import('../project/paths.ts');
|
|
58
|
+
return (await listPythonFiles(root)).filter(isTestFile);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ChangedPathsResult {
|
|
62
|
+
paths: string[];
|
|
63
|
+
source: 'git' | 'none';
|
|
64
|
+
error?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Collect changed paths from git, including untracked files so a brand new test
|
|
69
|
+
* file is considered. Falls back to an empty list outside a repository.
|
|
70
|
+
*/
|
|
71
|
+
export async function changedPaths(cwd: string, signal?: AbortSignal): Promise<ChangedPathsResult> {
|
|
72
|
+
const diff = await runCommand('git', gitDiffNames(cwd).args, {
|
|
73
|
+
cwd,
|
|
74
|
+
signal,
|
|
75
|
+
timeoutMs: 10000,
|
|
76
|
+
maxBytes: 200_000,
|
|
77
|
+
});
|
|
78
|
+
if (diff.code !== 0) {
|
|
79
|
+
return { paths: [], source: 'none', error: diff.stderr.trim() || 'git diff failed' };
|
|
80
|
+
}
|
|
81
|
+
const status = await runCommand('git', gitStatusPorcelain(cwd).args, {
|
|
82
|
+
cwd,
|
|
83
|
+
signal,
|
|
84
|
+
timeoutMs: 10000,
|
|
85
|
+
maxBytes: 200_000,
|
|
86
|
+
});
|
|
87
|
+
const untracked =
|
|
88
|
+
status.code === 0
|
|
89
|
+
? status.stdout
|
|
90
|
+
.split(/\r?\n/)
|
|
91
|
+
.filter((line) => line.startsWith('??'))
|
|
92
|
+
.map((line) => line.slice(3).trim())
|
|
93
|
+
: [];
|
|
94
|
+
const paths = [...diff.stdout.split(/\r?\n/), ...untracked]
|
|
95
|
+
.map((line) => line.trim())
|
|
96
|
+
.filter((line) => line.length > 0);
|
|
97
|
+
return { paths: [...new Set(paths)], source: 'git' };
|
|
98
|
+
}
|