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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { failure, result } from '../../src/core/result.ts';
|
|
3
|
+
import { runCommand } from '../../src/core/runner.ts';
|
|
4
|
+
import { planDependencies } from '../../src/dependencies/plan.ts';
|
|
5
|
+
import { buildCompletionEvidence } from '../../src/validation/evidence.ts';
|
|
6
|
+
import { checkTdd } from '../../src/validation/tdd.ts';
|
|
7
|
+
import { messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
|
|
8
|
+
import { runScanProject } from '../../src/project/scanner.ts';
|
|
9
|
+
|
|
10
|
+
export function registerDependencyTools(pi: Pi): void {
|
|
11
|
+
pi.registerTool({
|
|
12
|
+
name: 'py_dependency_plan',
|
|
13
|
+
label: 'Python Dependency Plan',
|
|
14
|
+
description:
|
|
15
|
+
'Compare imports found with ast against declared dependencies, dev groups, and uv.lock, and preview the uv commands that would fix the drift. Read-only.',
|
|
16
|
+
promptSnippet: 'Plan Python dependency changes from declared and imported packages',
|
|
17
|
+
promptGuidelines: [
|
|
18
|
+
'Use py_dependency_plan before editing dependencies, and whenever an import fails or a package may be declared in the wrong group.',
|
|
19
|
+
'Use py_dependency_plan to detect drift between pyproject.toml and uv.lock instead of reading the lockfile by hand.',
|
|
20
|
+
],
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
path: Type.Optional(Type.String({ description: 'Project directory to analyse.' })),
|
|
23
|
+
includeUnused: Type.Optional(
|
|
24
|
+
Type.Boolean({
|
|
25
|
+
description:
|
|
26
|
+
'Also report declared packages that no file imports. Off by default because runtime plugins and console tools produce false positives.',
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
}),
|
|
30
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
31
|
+
const started = Date.now();
|
|
32
|
+
try {
|
|
33
|
+
const root = await resolveProjectRoot(ctx.cwd, params.path);
|
|
34
|
+
if (!root) {
|
|
35
|
+
return text(
|
|
36
|
+
failure(ctx.cwd, started, 'No Python project root was found.', 'PROJECT_NOT_FOUND'),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const scan = await runScanProject(ctx.cwd, { root, mode: 'all', maxFiles: 2000 }, signal);
|
|
40
|
+
if (!scan.ok || !scan.payload) {
|
|
41
|
+
return text(
|
|
42
|
+
failure(
|
|
43
|
+
ctx.cwd,
|
|
44
|
+
started,
|
|
45
|
+
scan.message ?? 'The project scanner failed.',
|
|
46
|
+
scan.code ?? 'SCANNER_FAILED',
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const plan = planDependencies(scan.payload, { includeUnused: params.includeUnused });
|
|
51
|
+
const findings = plan.undeclared.length + plan.misplaced.length;
|
|
52
|
+
|
|
53
|
+
return text(
|
|
54
|
+
result(ctx.cwd, started, {
|
|
55
|
+
ok: findings === 0,
|
|
56
|
+
summary:
|
|
57
|
+
`${plan.thirdPartyImportCount} third-party import(s) against ${plan.declaredCount} declared distribution(s); ` +
|
|
58
|
+
`${plan.undeclared.length} undeclared, ${plan.misplaced.length} declared only outside [project] dependencies, ` +
|
|
59
|
+
`${plan.drift.missingFromLock.length + plan.drift.unsatisfiedInLock.length} lockfile drift issue(s).`,
|
|
60
|
+
data: plan,
|
|
61
|
+
evidence: [
|
|
62
|
+
{
|
|
63
|
+
kind: 'dependency_plan',
|
|
64
|
+
undeclared: plan.undeclared.map((entry) => entry.import),
|
|
65
|
+
misplaced: plan.misplaced.map((entry) => ({
|
|
66
|
+
import: entry.import,
|
|
67
|
+
distribution: entry.distribution,
|
|
68
|
+
declaredIn: entry.declaredIn,
|
|
69
|
+
})),
|
|
70
|
+
lockfileDrift: plan.drift,
|
|
71
|
+
providerMappingReliable: plan.providerMappingReliable,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
warnings: plan.warnings,
|
|
75
|
+
errors: [],
|
|
76
|
+
suggestions: plan.suggestions,
|
|
77
|
+
projectRoot: root,
|
|
78
|
+
pythonVersion: scan.payload.pythonVersion,
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
pi.registerTool({
|
|
88
|
+
name: 'py_tdd_checkpoint',
|
|
89
|
+
label: 'Python TDD Checkpoint',
|
|
90
|
+
description:
|
|
91
|
+
'Check whether production Python changes have related test changes before implementation is considered complete. Read-only.',
|
|
92
|
+
promptSnippet: 'Check the Python TDD checkpoint for changed files',
|
|
93
|
+
promptGuidelines: [
|
|
94
|
+
'Use py_tdd_checkpoint before reporting Python implementation work as complete.',
|
|
95
|
+
],
|
|
96
|
+
parameters: Type.Object({
|
|
97
|
+
changedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
|
|
98
|
+
testChangedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
|
|
99
|
+
}),
|
|
100
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
let changedPaths = params.changedPaths ?? [];
|
|
103
|
+
let source = 'argument';
|
|
104
|
+
if (!changedPaths.length) {
|
|
105
|
+
const diff = await runCommand('git', ['diff', '--name-only', 'HEAD'], {
|
|
106
|
+
cwd: ctx.cwd,
|
|
107
|
+
signal,
|
|
108
|
+
timeoutMs: 10000,
|
|
109
|
+
maxBytes: 100_000,
|
|
110
|
+
});
|
|
111
|
+
changedPaths = diff.stdout
|
|
112
|
+
.split(/\r?\n/)
|
|
113
|
+
.map((line) => line.trim())
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
source = 'git diff';
|
|
116
|
+
}
|
|
117
|
+
const checkpoint = checkTdd(changedPaths, params.testChangedPaths ?? changedPaths);
|
|
118
|
+
return text(
|
|
119
|
+
result(ctx.cwd, started, {
|
|
120
|
+
ok: checkpoint.ok,
|
|
121
|
+
summary: checkpoint.ok
|
|
122
|
+
? `TDD checkpoint passed across ${changedPaths.length} changed path(s) from ${source}.`
|
|
123
|
+
: 'TDD checkpoint found production changes without a related test change.',
|
|
124
|
+
data: { ...checkpoint, changedPaths, source },
|
|
125
|
+
evidence: checkpoint.reasons.map((message) => ({ kind: 'tdd_blocker', message })),
|
|
126
|
+
warnings: checkpoint.reasons.map((message) => ({
|
|
127
|
+
code: 'TDD_CHECKPOINT',
|
|
128
|
+
message,
|
|
129
|
+
severity: 'warning' as const,
|
|
130
|
+
})),
|
|
131
|
+
errors: [],
|
|
132
|
+
suggestions: checkpoint.ok
|
|
133
|
+
? []
|
|
134
|
+
: [
|
|
135
|
+
{
|
|
136
|
+
message:
|
|
137
|
+
'Add the smallest focused test for the changed behaviour, or explain why the change needs no test.',
|
|
138
|
+
confidence: 'high' as const,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
pi.registerTool({
|
|
147
|
+
name: 'py_completion_evidence',
|
|
148
|
+
label: 'Python Completion Evidence',
|
|
149
|
+
description:
|
|
150
|
+
'Build a conservative completion report from environment sync and test execution results. Read-only.',
|
|
151
|
+
promptSnippet: 'Create evidence for a Python completion report',
|
|
152
|
+
promptGuidelines: [
|
|
153
|
+
'Use py_completion_evidence before claiming Python work is complete; a partial run is not evidence.',
|
|
154
|
+
],
|
|
155
|
+
parameters: Type.Object({
|
|
156
|
+
syncExecuted: Type.Boolean({
|
|
157
|
+
description: 'Whether uv lock --check / uv sync actually ran.',
|
|
158
|
+
}),
|
|
159
|
+
syncOk: Type.Boolean(),
|
|
160
|
+
testExecuted: Type.Boolean({ description: 'Whether pytest actually ran.' }),
|
|
161
|
+
testOk: Type.Boolean(),
|
|
162
|
+
stale: Type.Boolean({ description: 'Whether stale artifacts were detected.' }),
|
|
163
|
+
changedPaths: Type.Array(Type.String(), { maxItems: 500 }),
|
|
164
|
+
}),
|
|
165
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
166
|
+
const started = Date.now();
|
|
167
|
+
const evidence = buildCompletionEvidence(params);
|
|
168
|
+
return text(
|
|
169
|
+
result(ctx.cwd, started, {
|
|
170
|
+
ok: evidence.ok,
|
|
171
|
+
summary: evidence.ok
|
|
172
|
+
? 'Completion evidence is sufficient for the supplied checks.'
|
|
173
|
+
: 'Completion evidence is incomplete or contains failing checks.',
|
|
174
|
+
data: evidence,
|
|
175
|
+
evidence: evidence.blockers.map((message) => ({ kind: 'completion_blocker', message })),
|
|
176
|
+
warnings: evidence.blockers.map((message) => ({
|
|
177
|
+
code: 'INCOMPLETE_EVIDENCE',
|
|
178
|
+
message,
|
|
179
|
+
severity: 'warning' as const,
|
|
180
|
+
})),
|
|
181
|
+
errors: evidence.ok
|
|
182
|
+
? []
|
|
183
|
+
: [
|
|
184
|
+
{
|
|
185
|
+
code: 'COMPLETION_NOT_PROVEN',
|
|
186
|
+
message: 'The supplied evidence does not prove completion.',
|
|
187
|
+
severity: 'error' as const,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
suggestions: evidence.ok
|
|
191
|
+
? []
|
|
192
|
+
: [
|
|
193
|
+
{
|
|
194
|
+
message:
|
|
195
|
+
'Run py_validation_bundle and address every blocker before reporting completion.',
|
|
196
|
+
confidence: 'high' as const,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { result, failure } from '../../src/core/result.ts';
|
|
4
|
+
import { detectPythonEnvironment } from '../../src/environment/discovery.ts';
|
|
5
|
+
import { inspectProject } from '../../src/project/inspect.ts';
|
|
6
|
+
import { readInstalledDistributions } from '../../src/project/installed.ts';
|
|
7
|
+
import { isGitIgnored } from '../../src/project/root.ts';
|
|
8
|
+
import { runScanProject } from '../../src/project/scanner.ts';
|
|
9
|
+
import { hasDirectory, messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
|
|
10
|
+
|
|
11
|
+
export function registerEnvironmentTools(pi: Pi): void {
|
|
12
|
+
pi.registerTool({
|
|
13
|
+
name: 'py_environment',
|
|
14
|
+
label: 'Python Environment',
|
|
15
|
+
description:
|
|
16
|
+
'Inspect the active Python interpreter, virtual environment, uv availability, and project root. Read-only.',
|
|
17
|
+
promptSnippet: 'Inspect the current Python interpreter and uv environment',
|
|
18
|
+
promptGuidelines: [
|
|
19
|
+
'Use py_environment before running Python commands when the active interpreter, virtual environment, or uv availability is unknown.',
|
|
20
|
+
],
|
|
21
|
+
parameters: Type.Object({}),
|
|
22
|
+
async execute(_id, _params, signal, _update, ctx) {
|
|
23
|
+
const started = Date.now();
|
|
24
|
+
try {
|
|
25
|
+
const environment = await detectPythonEnvironment(ctx.cwd, signal);
|
|
26
|
+
const python = environment.python;
|
|
27
|
+
const ok = Boolean(environment.interpreter);
|
|
28
|
+
return text(
|
|
29
|
+
result(ctx.cwd, started, {
|
|
30
|
+
ok,
|
|
31
|
+
summary: ok
|
|
32
|
+
? `Python ${python?.version ?? 'unknown'} (${python?.inVirtualEnvironment ? 'virtual environment' : 'system interpreter'}) · uv ${environment.uv.available ? (environment.uv.version ?? 'available') : 'unavailable'} · ${environment.projectRoot ?? 'no project root'}`
|
|
33
|
+
: 'No Python 3 interpreter is available in this environment.',
|
|
34
|
+
data: environment,
|
|
35
|
+
evidence: [
|
|
36
|
+
{
|
|
37
|
+
kind: 'python_environment',
|
|
38
|
+
interpreter: environment.interpreter,
|
|
39
|
+
version: python?.version,
|
|
40
|
+
executable: python?.executable,
|
|
41
|
+
inVirtualEnvironment: python?.inVirtualEnvironment ?? false,
|
|
42
|
+
virtualEnv: python?.virtualEnv ?? null,
|
|
43
|
+
projectRoot: environment.projectRoot ?? null,
|
|
44
|
+
uv: environment.uv,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
warnings: environment.warnings,
|
|
48
|
+
errors: ok
|
|
49
|
+
? []
|
|
50
|
+
: [
|
|
51
|
+
{
|
|
52
|
+
code: 'PYTHON_NOT_FOUND',
|
|
53
|
+
message: 'No Python 3 interpreter was found on PATH.',
|
|
54
|
+
severity: 'error' as const,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
suggestions: environment.suggestions.map((message) => ({
|
|
58
|
+
message,
|
|
59
|
+
confidence: 'medium' as const,
|
|
60
|
+
})),
|
|
61
|
+
projectRoot: environment.projectRoot,
|
|
62
|
+
pythonVersion: python?.version,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
pi.registerTool({
|
|
72
|
+
name: 'py_project_inspect',
|
|
73
|
+
label: 'Python Project Inspect',
|
|
74
|
+
description:
|
|
75
|
+
'Inspect pyproject.toml, uv.lock, dependency groups, layout, and tool configuration, and report lockfile drift. Read-only.',
|
|
76
|
+
promptSnippet: 'Inspect a Python project manifest and lockfile',
|
|
77
|
+
promptGuidelines: [
|
|
78
|
+
'Use py_project_inspect before editing pyproject.toml or uv.lock, and whenever the project layout or dependency groups are unclear.',
|
|
79
|
+
],
|
|
80
|
+
parameters: Type.Object({
|
|
81
|
+
path: Type.Optional(
|
|
82
|
+
Type.String({ description: 'Project directory, pyproject.toml path, or uv.lock path.' }),
|
|
83
|
+
),
|
|
84
|
+
}),
|
|
85
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
try {
|
|
88
|
+
const root = await resolveProjectRoot(ctx.cwd, params.path);
|
|
89
|
+
if (!root) {
|
|
90
|
+
return text(
|
|
91
|
+
failure(
|
|
92
|
+
ctx.cwd,
|
|
93
|
+
started,
|
|
94
|
+
'No Python project root was found. Pass the project directory or a pyproject.toml path.',
|
|
95
|
+
'PROJECT_NOT_FOUND',
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const scan = await runScanProject(ctx.cwd, { root, mode: 'manifest' }, signal);
|
|
100
|
+
if (!scan.ok || !scan.payload) {
|
|
101
|
+
return text(
|
|
102
|
+
failure(
|
|
103
|
+
ctx.cwd,
|
|
104
|
+
started,
|
|
105
|
+
scan.message ?? 'The project scanner failed.',
|
|
106
|
+
scan.code ?? 'SCANNER_FAILED',
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const venvPath = join(root, '.venv');
|
|
111
|
+
const venvDir = (await hasDirectory(venvPath)) ? venvPath : undefined;
|
|
112
|
+
const venvIgnored = venvDir ? await isGitIgnored(root, '.venv') : undefined;
|
|
113
|
+
const hasTestsDirectory =
|
|
114
|
+
(await hasDirectory(join(root, 'tests'))) || (await hasDirectory(join(root, 'test')));
|
|
115
|
+
const installed = venvDir ? await readInstalledDistributions(venvDir) : undefined;
|
|
116
|
+
const inspection = inspectProject({
|
|
117
|
+
payload: scan.payload,
|
|
118
|
+
venvDir,
|
|
119
|
+
venvIgnored,
|
|
120
|
+
hasTestsDirectory,
|
|
121
|
+
installed,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const conformance = inspection.conformance;
|
|
125
|
+
return text(
|
|
126
|
+
result(ctx.cwd, started, {
|
|
127
|
+
ok: inspection.warnings.length === 0,
|
|
128
|
+
summary:
|
|
129
|
+
`${inspection.pyproject ? `${inspection.name ?? 'unnamed project'}${inspection.version ? ` ${inspection.version}` : ''}` : 'No pyproject.toml'} · ` +
|
|
130
|
+
`${inspection.dependencyCounts.runtime} runtime dependenc(ies) · ` +
|
|
131
|
+
`${inspection.lock.present ? `${inspection.lock.packageCount} locked package(s)` : 'no uv.lock'} · ` +
|
|
132
|
+
`${inspection.layout} layout · ` +
|
|
133
|
+
`conformance: ${conformance ? conformance.verdict : 'not scanned'}`,
|
|
134
|
+
data: inspection,
|
|
135
|
+
evidence: [
|
|
136
|
+
{
|
|
137
|
+
kind: 'project_inspection',
|
|
138
|
+
root: inspection.root,
|
|
139
|
+
pyproject: inspection.pyproject ?? null,
|
|
140
|
+
uvLock: inspection.uvLock ?? null,
|
|
141
|
+
layout: inspection.layout,
|
|
142
|
+
modules: inspection.modules,
|
|
143
|
+
runtimeDependencies: inspection.dependencyCounts.runtime,
|
|
144
|
+
lockedPackages: inspection.lock.packageCount,
|
|
145
|
+
installedPackages: inspection.installed?.count ?? null,
|
|
146
|
+
},
|
|
147
|
+
...(conformance
|
|
148
|
+
? [
|
|
149
|
+
{
|
|
150
|
+
kind: 'environment_conformance',
|
|
151
|
+
verdict: conformance.verdict,
|
|
152
|
+
complete: conformance.complete,
|
|
153
|
+
counts: conformance.counts,
|
|
154
|
+
findings: conformance.findings.map((finding) => finding.code),
|
|
155
|
+
},
|
|
156
|
+
]
|
|
157
|
+
: []),
|
|
158
|
+
],
|
|
159
|
+
warnings: inspection.warnings,
|
|
160
|
+
errors: [],
|
|
161
|
+
suggestions: inspection.suggestions,
|
|
162
|
+
projectRoot: inspection.root,
|
|
163
|
+
pythonVersion: scan.payload.pythonVersion,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|