fix: use PDM interpreter in generation and formatting scripts (#1277)

## ℹ️ Description
`pdm run generate-artifacts` could fail while creating the default
config when the shell's `python` differed from PDM's selected
interpreter. For example, Python 3.13 on PATH loaded dependencies from
`__pypackages__/3.12`, causing `ModuleNotFoundError:
pydantic_core._pydantic_core`.

Use PDM's interpreter consistently for config generation and Python
post-formatting. This fix is independent of login-entry PR #1276.

## 📋 Changes Summary

- Replace the shell-based config generator with a Python script that
invokes the CLI through `sys.executable`.
- Split `format:py` into a PDM composite so the Python post-formatter
also uses PDM's interpreter.
- Add an integration regression test that regenerates the exact config
snapshot with an unusable Python executable on PATH.
- Preserve supported Python versions, CLI generation behavior, and
generated artifacts. No dependency or user-configuration changes; no
documentation updates needed.

Validation: `pdm run format`, `pdm run lint`, `pdm run test`, `pdm run
generate-artifacts`, and generated-artifact verification passed. The
generator was also verified locally with PDM selecting Python 3.12 while
shell Python resolves to 3.13.

### ⚙️ Type of Change

- [x] 🐞 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (adds new functionality without breaking existing
usage)
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)

## ✅ Checklist

- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested my changes and ensured that all tests pass (`pdm run
test`).
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary (no documentation
changes required).

By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Improved the configuration generation workflow by using the active
Python environment directly.
* Split Python formatting into separate automatic formatting and
follow-up processing steps for more consistent results.

* **Tests**
* Added integration coverage to verify configuration generation works
reliably even when conflicting Python executables are present on the
system path.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens authored and GitHub committed 2026-09-13 22:26:25 +02:00
1 parent 387d2eb153
commit b2806e226e
3 files changed
+89 -2

No files matched your search

+4 -2
View File
@@ -93,7 +93,7 @@ debug = "python -m pdb -m kleinanzeigen_bot"
# build & packaging
generate-schemas = "python scripts/generate_schemas.py"
generate-config = { shell = "python -c \"from pathlib import Path; Path('docs/config.default.yaml').unlink(missing_ok=True)\" && python -m kleinanzeigen_bot --config docs/config.default.yaml create-config" }
generate-config = "python scripts/generate_config.py"
generate-readme-commands = "python scripts/generate_readme_commands.py"
generate-artifacts = { composite = ["generate-schemas", "generate-config", "generate-readme-commands"] }
compile.cmd = "python -O -m PyInstaller pyinstaller.spec --clean --workpath .temp"
@@ -107,7 +107,9 @@ deps = "pdm list --fields name,version,groups"
# format & lint
format = { composite = ["format:py", "format:yaml"] }
"format:py" = { shell = "autopep8 --recursive --in-place scripts src tests --verbose && python scripts/post_autopep8.py scripts src tests" }
"format:py" = { composite = ["format:autopep8", "format:post-autopep8"] }
"format:autopep8" = "autopep8 --recursive --in-place scripts src tests --verbose"
"format:post-autopep8" = "python scripts/post_autopep8.py scripts src tests"
"format:yaml" = "yamlfix scripts/ src/ tests/"
lint = { composite = ["lint:ruff", "lint:mypy", "lint:pyright", "lint:actions"] }
+25
View File
@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""Regenerate the default config using the interpreter selected by PDM."""
import subprocess # noqa: S404
import sys
from pathlib import Path
def main() -> None:
"""Replace the default config snapshot through the CLI with the current interpreter."""
repo_root = Path(__file__).resolve().parent.parent
config_path = repo_root / "docs/config.default.yaml"
config_path.unlink(missing_ok = True)
subprocess.run( # noqa: S603 trusted, static command arguments
[sys.executable, "-m", "kleinanzeigen_bot", "--config", str(config_path), "create-config"],
cwd = repo_root,
check = True,
timeout = 60,
)
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""Regression coverage for config generation with a different Python on PATH."""
import json
import os
import shutil
import subprocess # noqa: S404
import sys
from pathlib import Path
import pytest
@pytest.mark.itest
def test_generate_config_uses_current_interpreter(tmp_path:Path) -> None:
repo_root = Path(__file__).resolve().parent.parent.parent
pdm_path = shutil.which("pdm")
assert pdm_path is not None, "PDM is required to test the generate-config task"
# Expose test dependencies only to the task; PDM itself may use a different Python.
python_path = os.pathsep.join(str(Path(path).resolve()) for path in sys.path if path)
(tmp_path / "pyproject.toml").write_text(
(repo_root / "pyproject.toml").read_text(encoding = "utf-8")
+ f"\n[tool.pdm.scripts._]\nenv = {{ PYTHONPATH = {json.dumps(python_path)} }}\n",
encoding = "utf-8",
)
(tmp_path / ".pdm-python").write_text(sys.executable, encoding = "utf-8")
(tmp_path / "pdm.toml").write_text("[python]\nuse_venv = false\n", encoding = "utf-8")
script_path = tmp_path / "scripts/generate_config.py"
script_path.parent.mkdir()
script_path.write_bytes((repo_root / "scripts/generate_config.py").read_bytes())
config_path = tmp_path / "docs/config.default.yaml"
config_path.parent.mkdir()
config_path.write_text("stale config", encoding = "utf-8")
# A PATH-resolved Python cannot run; the selected interpreter must be used instead.
bin_path = tmp_path / "bin"
bin_path.mkdir()
for name in ("python", "python.exe"):
wrong_python = bin_path / name
wrong_python.write_text("invalid executable", encoding = "utf-8")
wrong_python.chmod(0o755)
result = subprocess.run( # noqa: S603 trusted test command
[pdm_path, "run", "generate-config"],
env = {
**{key: value for key, value in os.environ.items() if key != "PYTHONPATH"},
"PATH": str(bin_path),
"PDM_CHECK_UPDATE": "false",
},
cwd = tmp_path,
check = False,
capture_output = True,
text = True,
timeout = 60,
)
assert result.returncode == 0, result.stderr
assert config_path.read_bytes() == (repo_root / "docs/config.default.yaml").read_bytes()