Files
gh-jamie-bitflight-claude-s…/skills/python3-development/references/user-project-conventions.md
2025-11-29 18:49:58 +08:00

22 KiB

title, date, source, projects_analyzed
title date source projects_analyzed
User Project Conventions 2025-11-17 Extracted from user's production projects
pre-commit-pep723-linter-wrapper (PyPI/GitHub)
python_picotool (GitLab)
usb_powertools (GitLab)
picod (GitLab)
i2c_analyzer (GitLab)

User Project Conventions

Conventions extracted from actual production projects. The model MUST follow these patterns when creating new Python projects.

Asset Files Available

The following template files are available in the skill's assets/ directory for use in new projects:

File Purpose Usage
version.py Dual-mode version management (hatch-vcs + fallback) Copy to packages/{package_name}/version.py
hatch_build.py Build hook for binary/asset handling Copy to scripts/hatch_build.py
.markdownlint.json Markdown linting configuration (most rules disabled) Copy to project root
.pre-commit-config.yaml Standard pre-commit hooks configuration Copy to project root, run uv run pre-commit install
.editorconfig Editor formatting settings Copy to project root

The model MUST copy these files when creating new Python projects to ensure consistency with established conventions documented below.

1. Version Management

Pattern: Dual-mode version.py (STANDARD - 5/5 projects)

Location: packages/{package_name}/version.py

Pattern: Hatch-VCS with importlib.metadata fallback

Implementation:

"""Compute the version number and store it in the `__version__` variable.

Based on <https://github.com/maresb/hatch-vcs-footgun-example>.
"""

# /// script
# List dependencies for linting only
# dependencies = [
#   "hatchling>=1.14.0",
# ]
# ///
import os


def _get_hatch_version() -> str | None:
    """Compute the most up-to-date version number in a development environment.

    Returns `None` if Hatchling is not installed, e.g. in a production environment.

    For more details, see <https://github.com/maresb/hatch-vcs-footgun-example/>.
    """
    try:
        from hatchling.metadata.core import ProjectMetadata
        from hatchling.plugin.manager import PluginManager
        from hatchling.utils.fs import locate_file
    except ImportError:
        # Hatchling is not installed, so probably we are not in
        # a development environment.
        return None

    pyproject_toml = locate_file(__file__, "pyproject.toml")
    if pyproject_toml is None:
        raise RuntimeError("pyproject.toml not found although hatchling is installed")
    root = os.path.dirname(pyproject_toml)
    metadata = ProjectMetadata(root=root, plugin_manager=PluginManager())
    # Version can be either statically set in pyproject.toml or computed dynamically:
    return str(metadata.core.version or metadata.hatch.version.cached)


def _get_importlib_metadata_version() -> str:
    """Compute the version number using importlib.metadata.

    This is the official Pythonic way to get the version number of an installed
    package. However, it is only updated when a package is installed. Thus, if a
    package is installed in editable mode, and a different version is checked out,
    then the version number will not be updated.
    """
    from importlib.metadata import version

    __version__ = version(__package__ or __name__)
    return __version__


__version__ = _get_hatch_version() or _get_importlib_metadata_version()

pyproject.toml Configuration (STANDARD - 5/5 projects):

[project]
dynamic = ["version"]

[tool.hatch.version]
source = "vcs"

[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

**init.py Export Pattern** (STANDARD - 5/5 projects):

from .version import __version__

__all__ = ["__version__"]  # Plus other exports

2. Package Structure

Pattern: src-layout with packages/ directory (STANDARD - 5/5 projects)

Directory Structure:

project_root/
├── packages/
│   └── {package_name}/
│       ├── __init__.py        # Exports public API + __version__
│       ├── version.py         # Version management
│       ├── {modules}.py
│       └── tests/             # Co-located tests
├── scripts/
│   └── hatch_build.py         # Custom build hook (if needed)
├── pyproject.toml
└── README.md

pyproject.toml Package Mapping (STANDARD - 5/5 projects):

[tool.hatch.build.targets.wheel]
packages = ["packages/{package_name}"]

[tool.hatch.build.targets.wheel.sources]
"packages/{package_name}" = "{package_name}"

Pattern: init.py exports with all (STANDARD - 5/5 projects)

The model must export public API + __version__ in __init__.py with explicit __all__ list.

Minimal Example (usb_powertools):

"""Package docstring."""

from .version import __version__

__all__ = ["__version__"]

Full API Example (pep723_loader):

"""Package docstring."""

from .pep723_checker import Pep723Checker
from .version import __version__

__all__ = ["Pep723Checker", "__version__"]

Evidence: All 5 projects use this pattern consistently.

3. Build Configuration

Pattern: Custom hatch_build.py Hook (STANDARD - 3/5 projects with binaries)

Location: scripts/hatch_build.py

Purpose: Execute binary build scripts (build-binaries.sh or build-binaries.py) before packaging.

Standard Implementation (usb_powertools, picod, i2c_analyzer identical):

"""Custom hatchling build hook for binary compilation.

This hook runs before the build process to compile platform-specific binaries
if build scripts are present in the project.
"""

from __future__ import annotations

import shutil
import subprocess
from pathlib import Path
from typing import Any

from hatchling.builders.config import BuilderConfig
from hatchling.builders.hooks.plugin.interface import BuildHookInterface


class BinaryBuildHook(BuildHookInterface[BuilderConfig]):
    """Build hook that runs binary compilation scripts before packaging.

    This hook checks for the following scripts in order:
    1. scripts/build-binaries.sh
    2. scripts/build-binaries.py

    If either script exists, it is executed before the build process.
    If neither exists, the hook silently continues without error.
    """

    PLUGIN_NAME = "binary-build"

    def initialize(self, version: str, build_data: dict[str, Any]) -> None:
        """Run binary build scripts if they exist."""
        shell_script = Path(self.root) / "scripts" / "build-binaries.sh"
        if shell_script.exists() and shell_script.is_file():
            self._run_shell_script(shell_script)
            return

        python_script = Path(self.root) / "scripts" / "build-binaries.py"
        if python_script.exists() and python_script.is_file():
            self._run_python_script(python_script)
            return

        self.app.display_info("No binary build scripts found, skipping binary compilation")

    def _run_shell_script(self, script_path: Path) -> None:
        """Execute a shell script for binary building."""
        self.app.display_info(f"Running binary build script: {script_path}")

        if not (bash := shutil.which("bash")):
            raise RuntimeError("bash not found - cannot execute shell script")

        try:
            result = subprocess.run([bash, str(script_path)], cwd=self.root, capture_output=True, text=True, check=True)
            if result.stdout:
                self.app.display_info(result.stdout)
            if result.stderr:
                self.app.display_warning(result.stderr)
        except subprocess.CalledProcessError as e:
            self.app.display_error(f"Binary build script failed with exit code {e.returncode}")
            if e.stdout:
                self.app.display_info(f"stdout: {e.stdout}")
            if e.stderr:
                self.app.display_error(f"stderr: {e.stderr}")
            raise

    def _run_python_script(self, script_path: Path) -> None:
        """Execute a Python script for binary building.

        Executes the script directly using its shebang, which honors PEP 723
        inline metadata for dependency management via uv.
        """
        self.app.display_info(f"Running binary build script: {script_path}")

        try:
            result = subprocess.run([script_path, "--clean"], cwd=self.root, capture_output=True, text=True, check=True)
            if result.stdout:
                self.app.display_info(result.stdout)
            if result.stderr:
                self.app.display_warning(result.stderr)
        except subprocess.CalledProcessError as e:
            self.app.display_error(f"Binary build script failed with exit code {e.returncode}")
            if e.stdout:
                self.app.display_info(f"stdout: {e.stdout}")
            if e.stderr:
                self.app.display_error(f"stderr: {e.stderr}")
            raise

pyproject.toml Configuration:

[tool.hatch.build.targets.sdist.hooks.custom]
path = "scripts/hatch_build.py"

[tool.hatch.build]
artifacts = ["builds/*/binary_name"]  # If binaries included

4. Pre-commit Configuration

Standard Hook Set (STANDARD - 5/5 projects)

File: .pre-commit-config.yaml

Core Hooks (appear in all projects):

repos:
  - repo: https://github.com/mxr/sync-pre-commit-deps
    rev: v0.0.3
    hooks:
      - id: sync-pre-commit-deps

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
        exclude: \.lock$
      - id: end-of-file-fixer
        exclude: \.lock$
      - id: check-yaml
      - id: check-json
      - id: check-toml
      - id: check-added-large-files
        args: ["--maxkb=10000"] # 10MB limit
      - id: check-case-conflict
      - id: check-merge-conflict
      - id: check-symlinks
      - id: mixed-line-ending
        args: ["--fix=lf"]
      - id: check-executables-have-shebangs
      - id: check-shebang-scripts-are-executable

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.13.3+
    hooks:
      - id: ruff
        name: Lint Python with ruff
        args: [--fix, --exit-non-zero-on-fix]
      - id: ruff-format
        name: Format Python with ruff

  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v4.0.0-alpha.8
    hooks:
      - id: prettier
        name: Format YAML, JSON, and Markdown files
        types_or: [yaml, json, markdown]
        exclude: \.lock$

  - repo: https://github.com/pecigonzalo/pre-commit-shfmt
    rev: v2.2.0
    hooks:
      - id: shell-fmt-go
        args: ["--apply-ignore", -w, -i, "4", -ci]

  - repo: https://github.com/shellcheck-py/shellcheck-py
    rev: v0.11.0.1
    hooks:
      - id: shellcheck

default_language_version:
  python: python3

exclude: |
  (?x)^(
    \.git/|
    \.venv/|
    __pycache__/|
    \.mypy_cache/|
    \.cache/|
    \.pytest_cache/|
    \.lock$|
    typings/
  )

Pattern: pep723-loader for Type Checking (STANDARD - 3/5 projects)

Projects using pep723-loader wrapper for mypy/basedpyright:

- repo: local
  hooks:
    - id: mypy
      name: mypy
      entry: uv run -q --no-sync --with pep723-loader --with mypy pep723-loader mypy
      language: system
      types: [python]
      pass_filenames: true

    - id: pyright
      name: basedpyright
      entry: uv run -q --no-sync --with pep723-loader --with basedpyright pep723-loader basedpyright
      language: system
      types: [python]
      pass_filenames: true
      require_serial: true

Pattern: Markdown Linting (STANDARD - 4/5 projects)

- repo: https://github.com/DavidAnson/markdownlint-cli2
  rev: v0.18.1
  hooks:
    - id: markdownlint-cli2
      language_version: "latest"
      args: ["--fix"]

Evidence: pre-commit-pep723-linter-wrapper, usb_powertools, picod all use this pattern.

5. Ruff Configuration

Standard Configuration (STANDARD - 5/5 projects)

pyproject.toml Section:

[tool.ruff]
target-version = "py311"
line-length = 120
fix = true
preview = true  # Optional, 3/5 projects use

[tool.ruff.format]
docstring-code-format = true
quote-style = "double"
line-ending = "lf"
skip-magic-trailing-comma = true
preview = true

[tool.ruff.lint]
extend-select = [
    "E",   # pycodestyle errors
    "W",   # pycodestyle warnings
    "F",   # pyflakes
    "I",   # isort
    "UP",  # pyupgrade
    "YTT", # flake8-2020
    "S",   # flake8-bandit
    "B",   # flake8-bugbear
    "A",   # flake8-builtins
    "C4",  # flake8-comprehensions
    "T10", # flake8-debugger
    "SIM", # flake8-simplify
    "C90", # mccabe
    "PGH", # pygrep-hooks
    "RUF", # ruff-specific
    "TRY", # tryceratops
    "DOC", # pydocstyle docstrings (4/5 projects)
    "D",   # pydocstyle (4/5 projects)
]

ignore = [
    "COM812",  # Missing trailing comma
    "COM819",  # Missing trailing comma
    "D107",    # Missing docstring in __init__
    "D415",    # First line should end with a period
    "E111",    # Indentation is not a multiple of four
    "E117",    # Over-indented for visual indent
    "E203",    # whitespace before ':'
    "E402",    # Module level import not at top of file
    "E501",    # Line length exceeds maximum limit
    "ISC001",  # isort configuration is missing
    "ISC002",  # isort configuration is missing
    "Q000",    # Remove bad quotes
    "Q001",    # Remove bad quotes
    "Q002",    # Remove bad quotes
    "Q003",    # Remove bad quotes
    "TRY003",  # Exception message should not be too long
    "S404",    # module is possibly insecure
    "S603",    # subprocess-without-shell-equals-true
    "S606",    # start-process-with-no-shell
    "DOC201",  # Missing return section in docstring
    "DOC501",  # Missing raises section
    "DOC502",  # Missing raises section
    "T201",    # Allow print statements (4/5 projects)
]

unfixable = ["F401", "S404", "S603", "S606", "DOC501"]

[tool.ruff.lint.pycodestyle]
max-line-length = 120

[tool.ruff.lint.isort]
combine-as-imports = true
split-on-trailing-comma = false
force-single-line = false
force-wrap-aliases = false

[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"

[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.ruff.lint.mccabe]
max-complexity = 10

[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S101", "S603", "S607", "D102", "D200", "D100"]
"**/test_*.py" = ["S101", "S603", "S607", "D102", "D200", "D100"]

Evidence: All 5 projects use this exact configuration with minor variations.

6. Mypy Configuration

Standard Configuration (STANDARD - 5/5 projects)

[tool.mypy]
python_version = "3.11"
strict = true
strict_equality = true
extra_checks = true
warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
ignore_missing_imports = true
show_error_codes = true
pretty = true
disable_error_code = ["call-arg"]

Per-module overrides pattern:

[[tool.mypy.overrides]]
module = "tests.*"
disable_error_code = ["misc"]

7. Basedpyright Configuration

Standard Configuration (STANDARD - 5/5 projects)

[tool.basedpyright]
pythonVersion = "3.11"
typeCheckingMode = "standard"
reportMissingImports = false
reportMissingTypeStubs = false
reportUnnecessaryTypeIgnoreComment = "error"
reportPrivateImportUsage = false
include = ["packages"]
extraPaths = ["packages", "scripts", "tests", "."]
exclude = ["**/node_modules", "**/__pycache__", ".*", "__*", "**/typings"]
ignore = ["**/typings"]
venvPath = "."
venv = ".venv"

Evidence: All 5 projects use this configuration.

8. Pytest Configuration

Standard Configuration (STANDARD - 5/5 projects)

[tool.pytest.ini_options]
addopts = [
    "--cov=packages/{package_name}",
    "--cov-report=term-missing",
    "-v",
]
testpaths = ["packages/{package_name}/tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
pythonpath = [".", "packages/"]
markers = [
    "hardware: tests that require USB hardware",
    "slow: tests that take significant time to run",
    "integration: integration tests",
]

[tool.coverage.run]
omit = ["*/tests/*"]

[tool.coverage.report]
show_missing = true
fail_under = 70

Evidence: All projects follow this pattern with minor marker variations.

9. Formatting Configuration Files

.markdownlint.json (STANDARD - 5/5 projects)

All projects use identical configuration:

{
  "MD003": false,
  "MD007": { "indent": 2 },
  "MD001": false,
  "MD022": false,
  "MD024": false,
  "MD013": false,
  "MD036": false,
  "MD025": false,
  "MD031": false,
  "MD041": false,
  "MD029": false,
  "MD033": false,
  "MD046": false,
  "blanks-around-fences": false,
  "blanks-around-headings": false,
  "blanks-around-lists": false,
  "code-fence-style": false,
  "emphasis-style": false,
  "heading-start-left": false,
  "heading-style": false,
  "hr-style": false,
  "line-length": false,
  "list-indent": false,
  "list-marker-space": false,
  "no-blanks-blockquote": false,
  "no-hard-tabs": false,
  "no-missing-space-atx": false,
  "no-missing-space-closed-atx": false,
  "no-multiple-blanks": false,
  "no-multiple-space-atx": false,
  "no-multiple-space-blockquote": false,
  "no-multiple-space-closed-atx": false,
  "no-trailing-spaces": false,
  "ol-prefix": false,
  "strong-style": false,
  "ul-indent": false
}

Evidence: Identical across all 5 projects.

.editorconfig (COMMON - 2/5 projects have it)

Standard Pattern (python_picotool, picod):

# EditorConfig: https://editorconfig.org/

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 120

[*.md]
indent_style = space
indent_size = 4
trim_trailing_whitespace = false

[*.py]
indent_style = space
indent_size = 4

[*.{yml,yaml}]
indent_style = space
indent_size = 2

[*.sh]
indent_style = space
indent_size = 4

[*.toml]
indent_style = space
indent_size = 2

[*.json]
indent_style = space
indent_size = 2

[COMMIT_EDITMSG]
max_line_length = 72

Evidence:

10. Semantic Release Configuration

Standard Configuration (STANDARD - 5/5 projects)

[tool.semantic_release]
version_toml = []
major_on_zero = true
allow_zero_version = true
tag_format = "v{version}"
build_command = "uv build"

[tool.semantic_release.branches.main]
match = "(main|master)"
prerelease = false

[tool.semantic_release.commit_parser_options]
allowed_tags = [
    "build",
    "chore",
    "ci",
    "docs",
    "feat",
    "fix",
    "perf",
    "style",
    "refactor",
    "test",
]
minor_tags = ["feat"]
patch_tags = ["fix", "perf", "refactor"]

Evidence: All 5 projects use this configuration identically.

11. Dependency Groups

Standard dev Dependencies (STANDARD - 5/5 projects)

[dependency-groups]
dev = [
    "basedpyright>=1.21.1",
    "hatch-vcs>=0.5.0",
    "hatchling>=1.14.0",
    "mypy>=1.18.2",
    "pre-commit>=4.3.0",
    "pytest>=8.4.2",
    "pytest-asyncio>=1.2.0",
    "pytest-cov>=6.0.0",
    "pytest-mock>=3.14.0",
    "ruff>=0.9.4",
    "python-semantic-release>=10.4.1",
    "generate-changelog>=0.16.0",
]

Common Pattern: All projects include mypy, basedpyright, ruff, pytest, pre-commit, hatchling tools.

Evidence: All 5 projects have dev dependency groups with these core tools.

12. GitLab Project-Specific Patterns

Pattern: Custom PyPI Index (STANDARD - 4/4 GitLab projects)

[tool.uv]
publish-url = "{{gitlab_instance_url}}/api/v4/projects/{{project_id}}/packages/pypi"

[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple"
default = true

[[tool.uv.index]]
name = "gitlab"
url = "{{gitlab_instance_url}}/api/v4/groups/{{group_id}}/-/packages/pypi/simple"
explicit = true
default = false

13. Project Metadata Standards

Pattern: Author and Maintainer (STANDARD - 5/5 projects)

[project]
authors = [{ name = "{{author_name_from_git_config_user_name}}", email = "{{author_email_from_git_config_user_email}}" }]
maintainers = [{ name = "{{author_name_from_git_config_user_name}}", email = "{{author_email_from_git_config_user_email}}" }]

Observation: Email addresses differ between GitHub projects (personal email) and GitLab projects (corporate email).

Pattern: Classifiers (STANDARD - 5/5 projects)

Common classifiers across all projects:

classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "Operating System :: POSIX :: Linux" or "Operating System :: OS Independent",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]

Pattern: Keywords (STANDARD - 5/5 projects)

All projects include domain-specific keywords related to their purpose.

Pattern: requires-python (STANDARD - 5/5 projects)

Two variants:

  • GitHub: >=3.10
  • GitLab: >=3.11,<3.13

14. CLI Entry Points

Pattern: Typer-based CLI (STANDARD - 5/5 projects)

[project.scripts]
{package_name} = "{package_name}.cli:main" or "{package_name}.cli:app"

[project]
dependencies = [
    "typer>=0.19.2",
]

Evidence: All 5 projects use Typer for CLI implementation.

Summary of Standard Patterns

STANDARD (5/5 projects):

  • Dual-mode version.py with hatch-vcs
  • packages/ directory structure
  • all exports in init.py
  • Ruff formatting with 120 char line length
  • Mypy strict mode
  • Basedpyright type checking
  • Pre-commit hooks (sync-deps, ruff, prettier, shellcheck, shfmt)
  • .markdownlint.json (identical config)
  • Semantic release configuration
  • Typer-based CLI
  • pytest with coverage

COMMON (3-4/5 projects):

  • pep723-loader for type checking in pre-commit
  • Custom hatch_build.py hook
  • .editorconfig
  • GitLab custom PyPI index

The model must follow STANDARD patterns for all new Python projects. COMMON patterns should be used when applicable (e.g., hatch_build.py only if binaries needed).