@rtorcato/repo-tooling 3.5.0 → 3.7.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/AGENTS.md +1 -1
- package/dist/base/checks.js +15 -1
- package/dist/base/fixers.js +9 -1
- package/dist/base/git-hooks.js +43 -0
- package/dist/cli/commands/doctor.js +64 -8
- package/dist/cli/commands/fix-targets.js +38 -4
- package/dist/cli/commands/fix.js +19 -6
- package/dist/cli/index.js +30 -0
- package/dist/cli/utils/copy-preset.js +25 -0
- package/dist/languages/js/fixers.js +15 -15
- package/dist/languages/perl/checks.js +177 -0
- package/dist/languages/perl/ci.js +191 -0
- package/dist/languages/perl/fixers.js +93 -0
- package/dist/languages/perl/git-hooks.js +53 -0
- package/dist/languages/perl/gitignore.js +65 -0
- package/dist/languages/perl/sources.js +22 -0
- package/dist/languages/python/checks.js +234 -0
- package/dist/languages/python/ci.js +153 -0
- package/dist/languages/python/fixers.js +104 -0
- package/dist/languages/python/git-hooks.js +35 -0
- package/dist/languages/python/gitignore.js +55 -0
- package/dist/languages/registry.js +2 -2
- package/dist/languages/swift/fixers.js +2 -2
- package/dist/languages/swift/git-hooks.js +7 -35
- package/package.json +5 -1
- package/tooling/perl/perlcriticrc +35 -0
- package/tooling/perl/perltidyrc +23 -0
- package/tooling/python/mypy.ini +21 -0
- package/tooling/python/pytest.ini +15 -0
- package/tooling/python/ruff.toml +32 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python language module — checks (#290), on the same template as Swift (#286).
|
|
3
|
+
*
|
|
4
|
+
* The standard encoded here is ruff (lint *and* format), mypy and pytest.
|
|
5
|
+
* Deliberately *not* checked:
|
|
6
|
+
*
|
|
7
|
+
* - **black** — ruff's formatter is black-compatible and already runs in the
|
|
8
|
+
* pre-commit hook and the lint job. A second *rewriting* formatter fights the
|
|
9
|
+
* first, which is the same reason the Swift module checks SwiftLint's `--fix`
|
|
10
|
+
* rather than SwiftFormat. If a repo wants black instead, it configures black
|
|
11
|
+
* in its own pyproject.toml — nothing here objects.
|
|
12
|
+
* - **flake8 / isort / pyupgrade** — the `select` list in the shipped ruff.toml
|
|
13
|
+
* covers all three (E/W, I, UP), so a separate config would be a second place
|
|
14
|
+
* to keep the same rules.
|
|
15
|
+
* - **A pinned interpreter file** (`.python-version`) — the supported range is
|
|
16
|
+
* declared once in pyproject's `requires-python`, and CI derives its matrix
|
|
17
|
+
* from that. A root file would be a second thing to keep in sync.
|
|
18
|
+
*/
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import fs from 'fs-extra';
|
|
21
|
+
import { checkFile } from '../../base/checks.js';
|
|
22
|
+
import { PYTHON_GITIGNORE_SENTINELS } from './gitignore.js';
|
|
23
|
+
import { PYTHON_HOOKS_DIR } from './git-hooks.js';
|
|
24
|
+
const PYTHON_TOOL_CHECKS = [
|
|
25
|
+
{
|
|
26
|
+
dedicated: {
|
|
27
|
+
check: 'Ruff',
|
|
28
|
+
candidates: ['ruff.toml', '.ruff.toml'],
|
|
29
|
+
expected: 'is a valid Ruff configuration',
|
|
30
|
+
// `[tool.ruff...]` is the pyproject form; the rest are ruff.toml's own
|
|
31
|
+
// top-level keys and sections.
|
|
32
|
+
matcher: /^\[tool\.ruff|^\[(lint|format)\]|^(line-length|target-version)\s*=/m,
|
|
33
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix ruff` to scaffold (or add a `[tool.ruff]` table to pyproject.toml)',
|
|
34
|
+
},
|
|
35
|
+
shared: ['pyproject.toml'],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
dedicated: {
|
|
39
|
+
check: 'mypy',
|
|
40
|
+
candidates: ['mypy.ini', '.mypy.ini'],
|
|
41
|
+
expected: 'is a valid mypy configuration',
|
|
42
|
+
// `[mypy]` in mypy.ini/setup.cfg, `[tool.mypy]` in pyproject.
|
|
43
|
+
matcher: /^\[(tool\.)?mypy\]/m,
|
|
44
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix mypy` to scaffold (or add a `[tool.mypy]` table to pyproject.toml)',
|
|
45
|
+
},
|
|
46
|
+
shared: ['setup.cfg', 'pyproject.toml'],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
dedicated: {
|
|
50
|
+
check: 'pytest',
|
|
51
|
+
candidates: ['pytest.ini'],
|
|
52
|
+
expected: 'is a valid pytest configuration',
|
|
53
|
+
// `[pytest]` in pytest.ini/tox.ini, `[tool:pytest]` in setup.cfg,
|
|
54
|
+
// `[tool.pytest.ini_options]` in pyproject.
|
|
55
|
+
matcher: /^\[(pytest|tool:pytest|tool\.pytest\.ini_options)\]/m,
|
|
56
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix pytest` to scaffold (or add a `[tool.pytest.ini_options]` table to pyproject.toml)',
|
|
57
|
+
},
|
|
58
|
+
shared: ['tox.ini', 'setup.cfg', 'pyproject.toml'],
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
async function checkPythonTool(dir, spec) {
|
|
62
|
+
const result = await checkFile(dir, spec.dedicated);
|
|
63
|
+
// A dedicated file that exists takes precedence over any shared table — all
|
|
64
|
+
// three tools read it first — so `ok` and `drift` are both final answers.
|
|
65
|
+
if (result.status !== 'missing')
|
|
66
|
+
return result;
|
|
67
|
+
for (const candidate of spec.shared) {
|
|
68
|
+
const filepath = path.join(dir, candidate);
|
|
69
|
+
if (!(await fs.pathExists(filepath)))
|
|
70
|
+
continue;
|
|
71
|
+
if (spec.dedicated.matcher.test(await fs.readFile(filepath, 'utf-8'))) {
|
|
72
|
+
return {
|
|
73
|
+
check: spec.dedicated.check,
|
|
74
|
+
status: 'ok',
|
|
75
|
+
detail: `${candidate} ${spec.dedicated.expected}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
export async function checkPythonGitignore(dir) {
|
|
82
|
+
const check = 'Python .gitignore';
|
|
83
|
+
const filepath = path.join(dir, '.gitignore');
|
|
84
|
+
if (!(await fs.pathExists(filepath))) {
|
|
85
|
+
return {
|
|
86
|
+
check,
|
|
87
|
+
status: 'missing',
|
|
88
|
+
detail: 'no .gitignore',
|
|
89
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix python-gitignore` to scaffold the Python template',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const contents = await fs.readFile(filepath, 'utf-8');
|
|
93
|
+
const missing = PYTHON_GITIGNORE_SENTINELS.filter((entry) => !contents.includes(entry));
|
|
94
|
+
if (missing.length > 0) {
|
|
95
|
+
return {
|
|
96
|
+
check,
|
|
97
|
+
status: 'drift',
|
|
98
|
+
detail: `.gitignore missing Python artefacts: ${missing.join(', ')}`,
|
|
99
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix python-gitignore` to append the missing entries',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
check,
|
|
104
|
+
status: 'ok',
|
|
105
|
+
detail: `.gitignore covers ${PYTHON_GITIGNORE_SENTINELS.join(', ')}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* pyproject.toml hygiene, the Python shape of the `Package.swift` check. Both
|
|
110
|
+
* signals are things no tool infers for you: without a metadata table there is
|
|
111
|
+
* nothing to build or publish, and without a `requires-python` floor pip will
|
|
112
|
+
* happily install the package on an interpreter it cannot run on — the failure
|
|
113
|
+
* lands on the user, at import time.
|
|
114
|
+
*/
|
|
115
|
+
export async function checkPyproject(dir) {
|
|
116
|
+
const check = 'pyproject.toml';
|
|
117
|
+
const filepath = path.join(dir, 'pyproject.toml');
|
|
118
|
+
if (!(await fs.pathExists(filepath))) {
|
|
119
|
+
// detect-language also accepts setup.py, so a repo can reach this module
|
|
120
|
+
// without a pyproject at all. That's drift rather than "not a Python repo".
|
|
121
|
+
const legacy = await fs.pathExists(path.join(dir, 'setup.py'));
|
|
122
|
+
return {
|
|
123
|
+
check,
|
|
124
|
+
status: 'missing',
|
|
125
|
+
detail: legacy ? 'setup.py only — no pyproject.toml' : 'no pyproject.toml',
|
|
126
|
+
hint: 'Add a PEP 621 pyproject.toml with a `[project]` table — setup.py is legacy and no longer the packaging standard',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const contents = await fs.readFile(filepath, 'utf-8');
|
|
130
|
+
// PEP 621 `[project]` or Poetry's `[tool.poetry]` — the two forms in the wild.
|
|
131
|
+
const poetry = /^\[tool\.poetry\]/m.test(contents);
|
|
132
|
+
if (!/^\[project\]/m.test(contents) && !poetry) {
|
|
133
|
+
return {
|
|
134
|
+
check,
|
|
135
|
+
status: 'drift',
|
|
136
|
+
detail: 'pyproject.toml declares no `[project]` (or `[tool.poetry]`) metadata',
|
|
137
|
+
hint: 'Add a `[project]` table with `name`, `version` and `requires-python` — see PEP 621',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Poetry spells the same constraint `python = "^3.11"` under its dependencies.
|
|
141
|
+
const hasFloor = poetry
|
|
142
|
+
? /^\s*python\s*=/m.test(contents)
|
|
143
|
+
: /^requires-python\s*=/m.test(contents);
|
|
144
|
+
if (!hasFloor) {
|
|
145
|
+
return {
|
|
146
|
+
check,
|
|
147
|
+
status: 'drift',
|
|
148
|
+
detail: `pyproject.toml declares no ${poetry ? '`python` constraint' : '`requires-python`'}`,
|
|
149
|
+
hint: 'Add `requires-python = ">=3.10"` — without it pip installs the package on interpreters it cannot run on',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
check,
|
|
154
|
+
status: 'ok',
|
|
155
|
+
detail: `pyproject.toml declares ${poetry ? 'Poetry' : 'PEP 621'} metadata and a Python floor`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** CI files that could be running the test suite, most likely first. */
|
|
159
|
+
async function ciFiles(dir) {
|
|
160
|
+
const candidates = ['.gitlab-ci.yml', '.gitlab-ci.yaml'];
|
|
161
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
162
|
+
if (await fs.pathExists(workflowsDir)) {
|
|
163
|
+
const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
164
|
+
candidates.unshift(...files.map((f) => path.join('.github', 'workflows', f)));
|
|
165
|
+
}
|
|
166
|
+
return candidates;
|
|
167
|
+
}
|
|
168
|
+
/** `tests/`, `test/`, or a root-level `test_*.py` — the three layouts pytest finds. */
|
|
169
|
+
async function hasTestFiles(dir) {
|
|
170
|
+
for (const candidate of ['tests', 'test']) {
|
|
171
|
+
if (await fs.pathExists(path.join(dir, candidate)))
|
|
172
|
+
return `${candidate}/`;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const found = (await fs.readdir(dir)).find((f) => /^test_.*\.py$/.test(f));
|
|
176
|
+
return found ?? null;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The test setup. Same two halves as the Swift version: a suite has to exist,
|
|
184
|
+
* and CI has to run it. A green pipeline over a repo with no tests proves
|
|
185
|
+
* nothing, and a tests/ directory nothing executes is worse than none at all —
|
|
186
|
+
* it reads as covered.
|
|
187
|
+
*/
|
|
188
|
+
export async function checkPythonTests(dir) {
|
|
189
|
+
const check = 'Python tests';
|
|
190
|
+
const tests = await hasTestFiles(dir);
|
|
191
|
+
if (!tests) {
|
|
192
|
+
return {
|
|
193
|
+
check,
|
|
194
|
+
status: 'missing',
|
|
195
|
+
detail: 'no tests/ directory or test_*.py files',
|
|
196
|
+
hint: 'Add a `tests/` directory — the shipped pytest.ini sets `testpaths = tests`',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
for (const candidate of await ciFiles(dir)) {
|
|
200
|
+
const filepath = path.join(dir, candidate);
|
|
201
|
+
if (!(await fs.pathExists(filepath)))
|
|
202
|
+
continue;
|
|
203
|
+
if (/\bpytest\b/.test(await fs.readFile(filepath, 'utf-8'))) {
|
|
204
|
+
return { check, status: 'ok', detail: `${tests} found and run by ${candidate}` };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
check,
|
|
209
|
+
status: 'drift',
|
|
210
|
+
detail: `${tests} found but no CI job runs pytest`,
|
|
211
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix python-ci` to regenerate a workflow that runs pytest',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The Python shape of the base `Git hooks` / `Pre-push hook` checks (#309).
|
|
216
|
+
* `install` is null for the same reason Swift's is: the wiring —
|
|
217
|
+
* `git config core.hooksPath` — is per-clone local state that nothing commits,
|
|
218
|
+
* so flagging its absence would fail every fresh CI checkout.
|
|
219
|
+
*/
|
|
220
|
+
export const PYTHON_GIT_HOOKS = {
|
|
221
|
+
dir: PYTHON_HOOKS_DIR,
|
|
222
|
+
install: null,
|
|
223
|
+
verifyCommand: 'pytest',
|
|
224
|
+
fixTarget: 'python-git-hooks',
|
|
225
|
+
};
|
|
226
|
+
/** The Python module's suite, layered on top of the base checks by doctor. */
|
|
227
|
+
export async function runPythonChecks(dir) {
|
|
228
|
+
return [
|
|
229
|
+
await checkPyproject(dir),
|
|
230
|
+
...(await Promise.all(PYTHON_TOOL_CHECKS.map((spec) => checkPythonTool(dir, spec)))),
|
|
231
|
+
await checkPythonGitignore(dir),
|
|
232
|
+
await checkPythonTests(dir),
|
|
233
|
+
];
|
|
234
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python CI generation (#290), built on the language-agnostic skeleton in
|
|
3
|
+
* `src/base/ci.ts`. No `setup-node`, no `pnpm` — the Python path runs on Linux
|
|
4
|
+
* with `actions/setup-python` and pip.
|
|
5
|
+
*
|
|
6
|
+
* Like the Swift path there's no `ProjectConfig` to read: the one fact CI needs
|
|
7
|
+
* beyond the fixed job shapes — which interpreters to test on — is declared in
|
|
8
|
+
* pyproject.toml, so the matrix is derived from the repo itself.
|
|
9
|
+
*/
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import { renderGitHubWorkflow, renderGitLabCI } from '../../base/ci.js';
|
|
13
|
+
/**
|
|
14
|
+
* The newest interpreter the emitted matrix tests against, and the one the
|
|
15
|
+
* single-interpreter jobs (lint, typecheck) run on.
|
|
16
|
+
*
|
|
17
|
+
* ponytail: a constant, bumped by hand once a year when CPython ships a minor.
|
|
18
|
+
* Resolving "latest" at generation time would need a network call from a
|
|
19
|
+
* generator whose whole job is emitting text.
|
|
20
|
+
*/
|
|
21
|
+
const LATEST_PYTHON = '3.13';
|
|
22
|
+
/** The oldest interpreter to assume when pyproject declares no floor. */
|
|
23
|
+
const DEFAULT_FLOOR = '3.10';
|
|
24
|
+
const minorOf = (version) => Number.parseInt(version.split('.')[1] ?? '0', 10);
|
|
25
|
+
/**
|
|
26
|
+
* Pull the supported-Python floor out of a pyproject.toml. A regex rather than
|
|
27
|
+
* a TOML parser: this is one key, and a parser is a dependency the CLI would
|
|
28
|
+
* carry solely to read it.
|
|
29
|
+
*/
|
|
30
|
+
export function parsePyproject(contents) {
|
|
31
|
+
// PEP 621 `requires-python = ">=3.10"`, or Poetry's
|
|
32
|
+
// `python = "^3.11"` under [tool.poetry.dependencies].
|
|
33
|
+
const constraint = contents.match(/^requires-python\s*=\s*["']([^"']+)["']/m)?.[1] ??
|
|
34
|
+
contents.match(/^\s*python\s*=\s*["']([^"']+)["']/m)?.[1] ??
|
|
35
|
+
'';
|
|
36
|
+
const floor = constraint.match(/(\d+\.\d+)/)?.[1] ?? DEFAULT_FLOOR;
|
|
37
|
+
// A floor at or above the newest release we know about collapses the matrix
|
|
38
|
+
// to one entry rather than emitting a version that doesn't exist yet.
|
|
39
|
+
const versions = minorOf(floor) >= minorOf(LATEST_PYTHON) ? [floor] : [floor, LATEST_PYTHON];
|
|
40
|
+
return { pythonVersions: versions };
|
|
41
|
+
}
|
|
42
|
+
export async function readPyproject(dir) {
|
|
43
|
+
const filepath = path.join(dir, 'pyproject.toml');
|
|
44
|
+
if (!(await fs.pathExists(filepath)))
|
|
45
|
+
return { pythonVersions: [DEFAULT_FLOOR, LATEST_PYTHON] };
|
|
46
|
+
return parsePyproject(await fs.readFile(filepath, 'utf-8'));
|
|
47
|
+
}
|
|
48
|
+
/** Checkout + interpreter. `cache: pip` keys off the manifests pip resolves from. */
|
|
49
|
+
function pythonSetup(version) {
|
|
50
|
+
return ` - name: 📦 Checkout repository
|
|
51
|
+
uses: actions/checkout@v7
|
|
52
|
+
|
|
53
|
+
- name: 🐍 Set up Python ${version}
|
|
54
|
+
uses: actions/setup-python@v6
|
|
55
|
+
with:
|
|
56
|
+
python-version: '${version}'
|
|
57
|
+
cache: pip`;
|
|
58
|
+
}
|
|
59
|
+
export function pythonGithubJobs(project) {
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
id: 'lint',
|
|
63
|
+
steps: `${pythonSetup(LATEST_PYTHON)}
|
|
64
|
+
|
|
65
|
+
- name: 📦 Install Ruff
|
|
66
|
+
run: pip install ruff
|
|
67
|
+
|
|
68
|
+
- name: 🔍 ruff check
|
|
69
|
+
run: ruff check --output-format=github
|
|
70
|
+
|
|
71
|
+
- name: 🎨 ruff format --check
|
|
72
|
+
run: ruff format --check`,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'typecheck',
|
|
76
|
+
// mypy needs the package's own dependencies importable to resolve their
|
|
77
|
+
// types — running it against bare source reports every third-party import
|
|
78
|
+
// as missing.
|
|
79
|
+
steps: `${pythonSetup(LATEST_PYTHON)}
|
|
80
|
+
|
|
81
|
+
- name: 📦 Install project and mypy
|
|
82
|
+
run: |
|
|
83
|
+
pip install --upgrade pip
|
|
84
|
+
pip install -e .
|
|
85
|
+
pip install mypy
|
|
86
|
+
|
|
87
|
+
- name: 🔎 mypy
|
|
88
|
+
run: mypy .`,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'test',
|
|
92
|
+
extra: ` name: pytest \${{ matrix.python-version }}
|
|
93
|
+
strategy:
|
|
94
|
+
fail-fast: false
|
|
95
|
+
matrix:
|
|
96
|
+
python-version:
|
|
97
|
+
${project.pythonVersions.map((v) => ` - '${v}'`).join('\n')}`,
|
|
98
|
+
steps: ` - name: 📦 Checkout repository
|
|
99
|
+
uses: actions/checkout@v7
|
|
100
|
+
|
|
101
|
+
- name: 🐍 Set up Python \${{ matrix.python-version }}
|
|
102
|
+
uses: actions/setup-python@v6
|
|
103
|
+
with:
|
|
104
|
+
python-version: \${{ matrix.python-version }}
|
|
105
|
+
cache: pip
|
|
106
|
+
|
|
107
|
+
- name: 📦 Install project and pytest
|
|
108
|
+
run: |
|
|
109
|
+
pip install --upgrade pip
|
|
110
|
+
pip install -e .
|
|
111
|
+
pip install pytest
|
|
112
|
+
|
|
113
|
+
- name: 🧪 pytest
|
|
114
|
+
run: pytest`,
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
export function renderPythonWorkflow(project) {
|
|
119
|
+
return renderGitHubWorkflow(pythonGithubJobs(project));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* GitLab runs the same three commands in the official Python image. The matrix
|
|
123
|
+
* is dropped: GitLab's `parallel:matrix` would need a per-job image override,
|
|
124
|
+
* and a mirrored repo is a secondary pipeline — the version sweep lives on the
|
|
125
|
+
* GitHub side.
|
|
126
|
+
*/
|
|
127
|
+
function pythonGitlabSpec(project) {
|
|
128
|
+
const image = `python:${project.pythonVersions.at(-1) ?? LATEST_PYTHON}`;
|
|
129
|
+
return {
|
|
130
|
+
image,
|
|
131
|
+
preamble: `variables:
|
|
132
|
+
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
|
133
|
+
|
|
134
|
+
cache:
|
|
135
|
+
paths:
|
|
136
|
+
- .cache/pip`,
|
|
137
|
+
jobs: [
|
|
138
|
+
{
|
|
139
|
+
id: 'lint',
|
|
140
|
+
stage: 'lint',
|
|
141
|
+
script: ['pip install ruff', 'ruff check', 'ruff format --check'],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'test',
|
|
145
|
+
stage: 'test',
|
|
146
|
+
script: ['pip install -e .', 'pip install pytest', 'pytest'],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export function renderPythonGitLabCI(project) {
|
|
152
|
+
return renderGitLabCI(pythonGitlabSpec(project));
|
|
153
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python language module — fixers (#290). One per check in ./checks.ts.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: no `python-lockfile` fixer. Swift's writes the config that
|
|
5
|
+
* `setup --preset swift-library` would have produced, and there is no
|
|
6
|
+
* `python-library` preset yet (the Swift one landed in its own issue, #288).
|
|
7
|
+
* Inventing a ProjectConfig here just to have something to record would put a
|
|
8
|
+
* fabricated set of JS tool choices in a Python repo's lockfile — worse than
|
|
9
|
+
* doctor saying the lockfile is absent, which is true.
|
|
10
|
+
*/
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import fs from 'fs-extra';
|
|
14
|
+
import { copyPreset } from '../../cli/utils/copy-preset.js';
|
|
15
|
+
import { readPyproject, renderPythonGitLabCI, renderPythonWorkflow } from './ci.js';
|
|
16
|
+
import { PYTHON_HOOKS_DIR, installPythonGitHooks } from './git-hooks.js';
|
|
17
|
+
import { ensurePythonGitignore } from './gitignore.js';
|
|
18
|
+
export const PYTHON_FIXERS = [
|
|
19
|
+
{
|
|
20
|
+
target: 'ruff',
|
|
21
|
+
description: 'Scaffold ruff.toml (lint + format — the linter and the formatter in one tool)',
|
|
22
|
+
appliesTo: ['Ruff'],
|
|
23
|
+
outputs: ['ruff.toml'],
|
|
24
|
+
canFixDrift: true,
|
|
25
|
+
async run({ targetDir }) {
|
|
26
|
+
const result = await copyPreset('ruff', targetDir);
|
|
27
|
+
return { filesWritten: [result.target] };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
target: 'mypy',
|
|
32
|
+
description: 'Scaffold mypy.ini (strict on your code, lenient on untyped dependencies)',
|
|
33
|
+
appliesTo: ['mypy'],
|
|
34
|
+
outputs: ['mypy.ini'],
|
|
35
|
+
canFixDrift: true,
|
|
36
|
+
async run({ targetDir }) {
|
|
37
|
+
const result = await copyPreset('mypy', targetDir);
|
|
38
|
+
return { filesWritten: [result.target] };
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
target: 'pytest',
|
|
43
|
+
description: 'Scaffold pytest.ini (strict markers and config, warnings as errors)',
|
|
44
|
+
appliesTo: ['pytest'],
|
|
45
|
+
outputs: ['pytest.ini'],
|
|
46
|
+
canFixDrift: true,
|
|
47
|
+
async run({ targetDir }) {
|
|
48
|
+
const result = await copyPreset('pytest', targetDir);
|
|
49
|
+
return { filesWritten: [result.target] };
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
target: 'python-gitignore',
|
|
54
|
+
description: 'Add the Python artefacts (__pycache__, .venv, tool caches, build output) to .gitignore',
|
|
55
|
+
appliesTo: ['Python .gitignore'],
|
|
56
|
+
// Appends what's missing; never clobbers a project's own entries.
|
|
57
|
+
riskLevel: 'safe-merge',
|
|
58
|
+
outputs: ['.gitignore'],
|
|
59
|
+
canFixDrift: true,
|
|
60
|
+
async run({ targetDir }) {
|
|
61
|
+
return { filesWritten: await ensurePythonGitignore(targetDir) };
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
target: 'python-git-hooks',
|
|
66
|
+
description: `Scaffold ${PYTHON_HOOKS_DIR}/pre-commit + pre-push (ruff, mypy, pytest) and point git at them via core.hooksPath`,
|
|
67
|
+
appliesTo: ['Git hooks', 'Pre-push hook'],
|
|
68
|
+
outputs: [`${PYTHON_HOOKS_DIR}/pre-commit`, `${PYTHON_HOOKS_DIR}/pre-push`],
|
|
69
|
+
canFixDrift: true,
|
|
70
|
+
async run({ targetDir }) {
|
|
71
|
+
const { filesWritten, hooksPathSet } = await installPythonGitHooks(targetDir);
|
|
72
|
+
console.error(hooksPathSet
|
|
73
|
+
? chalk.dim(` git config core.hooksPath ${PYTHON_HOOKS_DIR}`)
|
|
74
|
+
: chalk.yellow(` run \`git config core.hooksPath ${PYTHON_HOOKS_DIR}\` once per clone — it's local git config, not a committed file`));
|
|
75
|
+
return { filesWritten };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
target: 'python-ci',
|
|
80
|
+
description: 'Scaffold .github/workflows/ci.yml for Python (ruff, mypy, pytest across the supported interpreters)',
|
|
81
|
+
appliesTo: ['GitHub Actions'],
|
|
82
|
+
outputs: ['.github/workflows/ci.yml'],
|
|
83
|
+
canFixDrift: true,
|
|
84
|
+
async run({ targetDir }) {
|
|
85
|
+
const workflowsDir = path.join(targetDir, '.github', 'workflows');
|
|
86
|
+
await fs.ensureDir(workflowsDir);
|
|
87
|
+
const workflow = renderPythonWorkflow(await readPyproject(targetDir));
|
|
88
|
+
await fs.writeFile(path.join(workflowsDir, 'ci.yml'), workflow);
|
|
89
|
+
return { filesWritten: ['.github/workflows/ci.yml'] };
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
target: 'python-gitlab-ci',
|
|
94
|
+
description: 'Scaffold .gitlab-ci.yml for Python (ruff + pytest on the official Python image)',
|
|
95
|
+
appliesTo: ['GitLab CI'],
|
|
96
|
+
outputs: ['.gitlab-ci.yml'],
|
|
97
|
+
canFixDrift: true,
|
|
98
|
+
async run({ targetDir }) {
|
|
99
|
+
const ci = renderPythonGitLabCI(await readPyproject(targetDir));
|
|
100
|
+
await fs.writeFile(path.join(targetDir, '.gitlab-ci.yml'), ci);
|
|
101
|
+
return { filesWritten: ['.gitlab-ci.yml'] };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python git hooks (#290). Same shape as the Swift ones (#309) and for the same
|
|
3
|
+
* reason: Husky is an npm package, and a Python repo has no node on the path to
|
|
4
|
+
* run it. The committed `.githooks/` directory plus `core.hooksPath` is the
|
|
5
|
+
* node-free equivalent; the mechanics live in `src/base/git-hooks.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately *not* `pre-commit` (the Python framework of that name): it is a
|
|
8
|
+
* second config file, a second pinned-tool source, and a second place the CI
|
|
9
|
+
* commands have to be kept in sync with. The hooks run ruff/mypy/pytest
|
|
10
|
+
* directly, exactly as CI does.
|
|
11
|
+
*/
|
|
12
|
+
import { installGitHooks } from '../../base/git-hooks.js';
|
|
13
|
+
export const PYTHON_HOOKS_DIR = '.githooks';
|
|
14
|
+
// `ruff format` before `ruff check --fix` so the lint pass sees final layout.
|
|
15
|
+
// Neither rewrites anything the other undoes — one tool owns both jobs.
|
|
16
|
+
const PYTHON_PRE_COMMIT = `#!/bin/sh
|
|
17
|
+
set -e
|
|
18
|
+
ruff format
|
|
19
|
+
ruff check --fix
|
|
20
|
+
`;
|
|
21
|
+
// mypy before pytest: a type error is cheaper to surface than a test run.
|
|
22
|
+
const PYTHON_PRE_PUSH = `#!/bin/sh
|
|
23
|
+
set -e
|
|
24
|
+
echo "🔍 Running pre-push verify..."
|
|
25
|
+
ruff check
|
|
26
|
+
mypy .
|
|
27
|
+
pytest
|
|
28
|
+
echo "✅ Verify passed — pushing."
|
|
29
|
+
`;
|
|
30
|
+
export function installPythonGitHooks(targetDir) {
|
|
31
|
+
return installGitHooks(targetDir, PYTHON_HOOKS_DIR, {
|
|
32
|
+
preCommit: PYTHON_PRE_COMMIT,
|
|
33
|
+
prePush: PYTHON_PRE_PUSH,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Python .gitignore block, shared by the `python-gitignore` fixer and the
|
|
3
|
+
* check that reports it. Its own module for the same reason Swift's is (#286):
|
|
4
|
+
* nothing else in the module has to import from `fixers.ts` to reuse it.
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'fs-extra';
|
|
8
|
+
/**
|
|
9
|
+
* The Python artefacts that must stay out of git. Appended to an existing
|
|
10
|
+
* .gitignore rather than replacing it — a Python repo's ignore file usually
|
|
11
|
+
* carries project-specific entries worth keeping.
|
|
12
|
+
*/
|
|
13
|
+
const PYTHON_GITIGNORE_BLOCK = `# Python
|
|
14
|
+
__pycache__/
|
|
15
|
+
*.py[cod]
|
|
16
|
+
*.egg-info/
|
|
17
|
+
.eggs/
|
|
18
|
+
build/
|
|
19
|
+
dist/
|
|
20
|
+
|
|
21
|
+
# Environments
|
|
22
|
+
.venv/
|
|
23
|
+
venv/
|
|
24
|
+
.env
|
|
25
|
+
|
|
26
|
+
# Tool caches
|
|
27
|
+
.pytest_cache/
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
.ruff_cache/
|
|
30
|
+
.coverage
|
|
31
|
+
htmlcov/
|
|
32
|
+
`;
|
|
33
|
+
/**
|
|
34
|
+
* Entries whose presence means the block (or an equivalent) is already there.
|
|
35
|
+
* `__pycache__` and `.venv` are the expensive ones — either committed by
|
|
36
|
+
* accident buries the diff — and a tool cache stands in for the rest.
|
|
37
|
+
*/
|
|
38
|
+
export const PYTHON_GITIGNORE_SENTINELS = ['__pycache__', '.venv', '.pytest_cache'];
|
|
39
|
+
export async function ensurePythonGitignore(targetDir) {
|
|
40
|
+
const filepath = path.join(targetDir, '.gitignore');
|
|
41
|
+
if (!(await fs.pathExists(filepath))) {
|
|
42
|
+
await fs.writeFile(filepath, PYTHON_GITIGNORE_BLOCK);
|
|
43
|
+
return ['.gitignore'];
|
|
44
|
+
}
|
|
45
|
+
const existing = await fs.readFile(filepath, 'utf-8');
|
|
46
|
+
const missing = PYTHON_GITIGNORE_SENTINELS.filter((entry) => !existing.includes(entry));
|
|
47
|
+
if (missing.length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
// Append only what's absent, so a repo that already ignores __pycache__
|
|
50
|
+
// doesn't get a duplicate entry for it.
|
|
51
|
+
const additions = PYTHON_GITIGNORE_BLOCK.split('\n').filter((line) => line.length > 0 && !existing.includes(line));
|
|
52
|
+
const separator = existing.endsWith('\n') ? '' : '\n';
|
|
53
|
+
await fs.writeFile(filepath, `${existing}${separator}\n${additions.join('\n')}\n`);
|
|
54
|
+
return ['.gitignore'];
|
|
55
|
+
}
|
|
@@ -18,14 +18,14 @@ export const LANGUAGES = {
|
|
|
18
18
|
label: 'Python',
|
|
19
19
|
codeqlLanguages: ['python'],
|
|
20
20
|
dependabotEcosystem: 'pip',
|
|
21
|
-
supported:
|
|
21
|
+
supported: true,
|
|
22
22
|
},
|
|
23
23
|
perl: {
|
|
24
24
|
id: 'perl',
|
|
25
25
|
label: 'Perl',
|
|
26
26
|
codeqlLanguages: [],
|
|
27
27
|
dependabotEcosystem: null,
|
|
28
|
-
supported:
|
|
28
|
+
supported: true,
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
31
|
/**
|
|
@@ -93,7 +93,7 @@ export const SWIFT_FIXERS = [
|
|
|
93
93
|
if (await fs.pathExists(path.join(targetDir, file)))
|
|
94
94
|
return { filesWritten: [] };
|
|
95
95
|
await fs.outputFile(path.join(targetDir, file), doccLandingPage(target));
|
|
96
|
-
console.
|
|
96
|
+
console.error(chalk.yellow(' add swift-docc-plugin to Package.swift to build it: .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0")'));
|
|
97
97
|
return { filesWritten: [file] };
|
|
98
98
|
},
|
|
99
99
|
},
|
|
@@ -117,7 +117,7 @@ export const SWIFT_FIXERS = [
|
|
|
117
117
|
canFixDrift: true,
|
|
118
118
|
async run({ targetDir }) {
|
|
119
119
|
const { filesWritten, hooksPathSet } = await installSwiftGitHooks(targetDir);
|
|
120
|
-
console.
|
|
120
|
+
console.error(hooksPathSet
|
|
121
121
|
? chalk.dim(` git config core.hooksPath ${SWIFT_HOOKS_DIR}`)
|
|
122
122
|
: chalk.yellow(` run \`git config core.hooksPath ${SWIFT_HOOKS_DIR}\` once per clone — it's local git config, not a committed file`));
|
|
123
123
|
return { filesWritten };
|
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
* Swift git hooks (#309). Husky is an npm package, so a SwiftPM repo can't use
|
|
3
3
|
* it without dragging node into a toolchain that otherwise has none. The
|
|
4
4
|
* node-free equivalent is a committed `.githooks/` directory that git is pointed
|
|
5
|
-
* at with `core.hooksPath
|
|
5
|
+
* at with `core.hooksPath` — the mechanics live in `src/base/git-hooks.ts`; only
|
|
6
|
+
* the hook bodies are Swift's.
|
|
6
7
|
*
|
|
7
8
|
* The hooks run the tools directly rather than a `verify` indirection: SwiftPM
|
|
8
9
|
* has no scripts field, and inventing a Makefile target would be a third place
|
|
9
10
|
* to keep the CI commands in sync (they already live in .github/workflows/ci.yml
|
|
10
11
|
* and .swiftlint.yml).
|
|
11
12
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import fs from 'fs-extra';
|
|
13
|
+
import { installGitHooks } from '../../base/git-hooks.js';
|
|
15
14
|
export const SWIFT_HOOKS_DIR = '.githooks';
|
|
16
15
|
// SwiftLint's --fix is the formatter (see ./checks.ts), so pre-commit formats
|
|
17
16
|
// then lints. --quiet keeps a clean commit from printing a wall of nothing.
|
|
@@ -28,36 +27,9 @@ swift test
|
|
|
28
27
|
swiftlint lint --strict
|
|
29
28
|
echo "✅ Verify passed — pushing."
|
|
30
29
|
`;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
export function installSwiftGitHooks(targetDir) {
|
|
31
|
+
return installGitHooks(targetDir, SWIFT_HOOKS_DIR, {
|
|
32
|
+
preCommit: SWIFT_PRE_COMMIT,
|
|
33
|
+
prePush: SWIFT_PRE_PUSH,
|
|
35
34
|
});
|
|
36
35
|
}
|
|
37
|
-
/**
|
|
38
|
-
* Writes both hooks and points git at them. Returns the files written —
|
|
39
|
-
* `core.hooksPath` is per-clone local config, not a file, so it's reported
|
|
40
|
-
* separately rather than pretending to be a repo change.
|
|
41
|
-
*/
|
|
42
|
-
export async function installSwiftGitHooks(targetDir) {
|
|
43
|
-
const hooksDir = path.join(targetDir, SWIFT_HOOKS_DIR);
|
|
44
|
-
await fs.ensureDir(hooksDir);
|
|
45
|
-
const filesWritten = [];
|
|
46
|
-
for (const [name, content] of [
|
|
47
|
-
['pre-commit', SWIFT_PRE_COMMIT],
|
|
48
|
-
['pre-push', SWIFT_PRE_PUSH],
|
|
49
|
-
]) {
|
|
50
|
-
const hookPath = path.join(hooksDir, name);
|
|
51
|
-
await fs.writeFile(hookPath, content);
|
|
52
|
-
await fs.chmod(hookPath, 0o755);
|
|
53
|
-
filesWritten.push(`${SWIFT_HOOKS_DIR}/${name}`);
|
|
54
|
-
}
|
|
55
|
-
// Guard on .git: `fix --diff` shadow-runs fixers in a temp copy that excludes
|
|
56
|
-
// .git, and that copy can land inside another repo — an unguarded `git config`
|
|
57
|
-
// there would rewrite the *parent* repo's hooksPath during a mere preview.
|
|
58
|
-
const isRepo = await fs.pathExists(path.join(targetDir, '.git'));
|
|
59
|
-
const hooksPathSet = isRepo
|
|
60
|
-
? await gitConfig(targetDir, 'core.hooksPath', SWIFT_HOOKS_DIR)
|
|
61
|
-
: false;
|
|
62
|
-
return { filesWritten, hooksPathSet };
|
|
63
|
-
}
|