From 363bb25e086284524cfebd29a399b6fd31b09681 Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:38:39 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 11 + README.md | 3 + plugin.lock.json | 77 +++++++ skills/detect-python-version/SKILL.md | 21 ++ .../scripts/detect_version.py | 67 ++++++ skills/python-project-code-structure/SKILL.md | 22 ++ .../scripts/detect_python_project_metadata.py | 56 +++++ skills/python-test-strategy/SKILL.md | 32 +++ .../reference/coverage.md | 54 +++++ .../python-test-strategy/reference/naming.md | 45 ++++ .../reference/public-api.md | 57 +++++ .../scripts/locate_test_file.py | 196 ++++++++++++++++++ 12 files changed, 641 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 plugin.lock.json create mode 100644 skills/detect-python-version/SKILL.md create mode 100755 skills/detect-python-version/scripts/detect_version.py create mode 100644 skills/python-project-code-structure/SKILL.md create mode 100755 skills/python-project-code-structure/scripts/detect_python_project_metadata.py create mode 100644 skills/python-test-strategy/SKILL.md create mode 100644 skills/python-test-strategy/reference/coverage.md create mode 100644 skills/python-test-strategy/reference/naming.md create mode 100644 skills/python-test-strategy/reference/public-api.md create mode 100755 skills/python-test-strategy/scripts/locate_test_file.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..7edc35d --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "sanhe-py-best-practices", + "description": "Sanhe's Python Best Practices", + "version": "1.0.0", + "author": { + "name": "Sanhe Hu" + }, + "skills": [ + "./skills" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9670e4 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# sanhe-py-best-practices + +Sanhe's Python Best Practices diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..807e601 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,77 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:MacHu-GWU/sanhe-claude-code-plugins:plugins/python/sanhe-py-best-practices", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "c45fc44a6d6645852e85269ce56783ad51d07d10", + "treeHash": "91c0058cc278c8fcb301a94ce9461e9b5f3354dd800c1cdf64f47e7ed23f606e", + "generatedAt": "2025-11-28T10:12:04.021324Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "sanhe-py-best-practices", + "description": "Sanhe's Python Best Practices", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "7d1a1ced39b3dd3198cd0c99342dfbc02b64fa974e02adf4da0f736e895c641b" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "5d81d454e4ec803c26a646b5e9be6951f34a87b0610ab916454aad8746e1b4db" + }, + { + "path": "skills/python-test-strategy/SKILL.md", + "sha256": "4417256dd35abdd146d28da02169f39cb8046284725794870d8f1f4451b30ad7" + }, + { + "path": "skills/python-test-strategy/scripts/locate_test_file.py", + "sha256": "0118e2d17e913818d3801406af8bb7c3e1b1fd955c31fb7a3c36e9f5b764c5e0" + }, + { + "path": "skills/python-test-strategy/reference/public-api.md", + "sha256": "e0d8f6ecf04954d9640e8820e17e96b0dc4158c2b40e6bba0b765f801d9d7dfb" + }, + { + "path": "skills/python-test-strategy/reference/coverage.md", + "sha256": "121ab23f27a32a56f455648b2cb8e69b9b123fa5d2a5e06e34ad4467ced1884d" + }, + { + "path": "skills/python-test-strategy/reference/naming.md", + "sha256": "2d98af51097dd5db8de408de507a10f103f21a22390514314a8fb400d4023950" + }, + { + "path": "skills/detect-python-version/SKILL.md", + "sha256": "6b3b345bc437cb5989d46d589c8db8db6d337486409478d328c61ff3333ec1bf" + }, + { + "path": "skills/detect-python-version/scripts/detect_version.py", + "sha256": "375692c059d9bbe8599136b4f6797165acd514cddff776c01cf2147422495b57" + }, + { + "path": "skills/python-project-code-structure/SKILL.md", + "sha256": "c4eb28bace456e06d8351e8615fe28fec96c50adea30fcfa45b8a08b3634426a" + }, + { + "path": "skills/python-project-code-structure/scripts/detect_python_project_metadata.py", + "sha256": "c3967f1f0dc8756d05bb93fafa2ae1fe232e22f5dc99e332c28e75f934db5cb8" + } + ], + "dirSha256": "91c0058cc278c8fcb301a94ce9461e9b5f3354dd800c1cdf64f47e7ed23f606e" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/detect-python-version/SKILL.md b/skills/detect-python-version/SKILL.md new file mode 100644 index 0000000..4c59a13 --- /dev/null +++ b/skills/detect-python-version/SKILL.md @@ -0,0 +1,21 @@ +--- +name: detect-python-version +description: Detects the Python major and minor version of the current repository +--- + +# detect-python-version + +Runs a script that outputs the Python version in `major.minor` format (e.g., `3.11`, `3.13`). + +## Instructions + +Run `scripts/detect_version.py` to detect the Python version. + +The script outputs just the version string in `major.minor` format, nothing else. + +## Examples + +**Output:** `3.11` + +**Output:** `3.13` + diff --git a/skills/detect-python-version/scripts/detect_version.py b/skills/detect-python-version/scripts/detect_version.py new file mode 100755 index 0000000..080e4e7 --- /dev/null +++ b/skills/detect-python-version/scripts/detect_version.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import subprocess +from pathlib import Path + + +def locate_git_repo(dir_cwd: Path) -> Path | None: + """ + Locate the git repository root directory by searching for the .git folder. + """ + for _ in range(10): + if dir_cwd.joinpath(".git").exists(): + return dir_cwd + dir_cwd = dir_cwd.parent + return None + + +def locate_pyproject_toml(dir_cwd: Path) -> Path | None: + """ + Locate the pyproject.toml file by searching upwards in the directory tree. + """ + for _ in range(10): + if dir_cwd.joinpath("pyproject.toml").exists(): + return dir_cwd.joinpath("pyproject.toml") + dir_cwd = dir_cwd.parent + return None + + +def locate_venv_bin_python(dir_cwd: Path) -> Path | None: + """ + Locate the virtual environment directory by searching for the .venv folder. + """ + for _ in range(10): + path_venv_bin_python = dir_cwd.joinpath(".venv", "bin", "python") + if path_venv_bin_python.exists(): + return path_venv_bin_python + dir_cwd = dir_cwd.parent + return None + + +def get_python_version(path_venv_bin_python: Path) -> tuple[int, int, int]: + """ + Get the Python version of the specified python executable. + """ + args = [ + f"{path_venv_bin_python}", + "--version", + ] + res = subprocess.run(args, capture_output=True) + text = res.stdout.decode("utf-8").strip() + parts = text.split()[1].split(".") + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def main(): + dir_here = Path.cwd() + path_venv_bin_python = locate_venv_bin_python(dir_here) + if path_venv_bin_python is not None: + major, minor, micro = get_python_version(path_venv_bin_python) + py_ver = f"{major}.{minor}" + print(py_ver) + return + + +if __name__ == "__main__": + main() diff --git a/skills/python-project-code-structure/SKILL.md b/skills/python-project-code-structure/SKILL.md new file mode 100644 index 0000000..d95c885 --- /dev/null +++ b/skills/python-project-code-structure/SKILL.md @@ -0,0 +1,22 @@ +--- +name: python-project-code-structure +description: Detects and displays Python project structure and important paths +--- + +# python-project-code-structure + +**Use this skill when you need to:** + +- Understand the directory structure of a Python project +- Find important paths (package location, tests, docs, venv, tools) +- Get package name and version from pyproject.toml +- Locate project root and configuration files + +**What it does:** + +Runs `scripts/detect_python_project_metadata.py` to automatically detect and display all critical paths in your Python project, including: + +- Package name and version +- Virtual environment paths (Python, pip, pytest) +- Tests and documentation directories +- Configuration files (Sphinx, Makefile) diff --git a/skills/python-project-code-structure/scripts/detect_python_project_metadata.py b/skills/python-project-code-structure/scripts/detect_python_project_metadata.py new file mode 100755 index 0000000..e96ae83 --- /dev/null +++ b/skills/python-project-code-structure/scripts/detect_python_project_metadata.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import json +import tomllib +from pathlib import Path + + +def locate_pyproject_toml(dir_cwd: Path) -> Path | None: + """ + Locate the pyproject.toml file by searching upwards in the directory tree. + """ + for _ in range(10): + if dir_cwd.joinpath("pyproject.toml").exists(): + return dir_cwd.joinpath("pyproject.toml") + dir_cwd = dir_cwd.parent + return None + + +def main(): + dir_here = Path.cwd() + path_pyproject_toml = locate_pyproject_toml(dir_here) + data = tomllib.loads(path_pyproject_toml.read_text(encoding="utf-8")) + package_name = data["project"]["name"] + package_version = data["project"]["version"] + dir_project_root = path_pyproject_toml.parent + dir_package = dir_project_root / package_name + dir_venv = dir_project_root / ".venv" + path_venv_bin_python = dir_venv / "bin" / "python" + path_venv_bin_pip = dir_venv / "bin" / "pip" + path_venv_bin_pytest = dir_venv / "bin" / "pytest" + dir_unit_tests = dir_project_root / "tests" + dir_docs_source = dir_project_root / "docs" / "source" + path_sphinx_conf_py = dir_docs_source / "conf.py" + path_makefile = dir_project_root / "Makefile" + python_project_metadata = { + "package_name": (package_name, "Package name from pyproject.toml"), + "package_version": (package_version, "Package version"), + "dir_package": (str(dir_package), "Main package directory"), + "path_venv_bin_python": (str(path_venv_bin_python), "virtualenv Python interpreter"), + "path_venv_bin_pip": (str(path_venv_bin_pip), "virtualenv Pip package manager"), + "path_venv_bin_pytest": (str(path_venv_bin_pytest), "virtualenv Pytest test runner"), + "dir_unit_tests": (str(dir_unit_tests), "Unit tests directory"), + "dir_docs_source": (str(dir_docs_source), "Documentation source"), + "path_sphinx_conf_py": (str(path_sphinx_conf_py), "Sphinx config file"), + "path_makefile": (str(path_makefile), "Commands for environment, testing, docs, and releases"), + } + print("Python Project Structure:") + for key, (path, description) in python_project_metadata.items(): + print(f"{key}") + print(f" Path: {path}") + print(f" Desc: {description}\n") + + +if __name__ == "__main__": + main() diff --git a/skills/python-test-strategy/SKILL.md b/skills/python-test-strategy/SKILL.md new file mode 100644 index 0000000..6358449 --- /dev/null +++ b/skills/python-test-strategy/SKILL.md @@ -0,0 +1,32 @@ +--- +name: python-test-strategy +description: Python testing patterns with organized test file structure, coverage goals, and public API testing +--- + +# python-test-strategy + +Organized testing strategy for Python projects: test file naming, coverage goals (95%+), and public API testing. + +## Quick Start + +**Find test location for any source file:** +```bash +python scripts/locate_test_file.py /path/to/source/file.py +``` + +**Run tests:** +- Individual file: `.venv/bin/python tests/subpackage/test_*.py` +- Package: `.venv/bin/python tests/subpackage/all.py` +- All: `.venv/bin/python tests/all.py` + +## Key Patterns + +- **Test files mirror source**: `source//.py` → `tests//test__.py` +- **Coverage goal**: 95%+ for all implementation files +- **Public API**: Export all public interfaces in `api.py`, test in `tests/test_api.py` + +## References + +- 🎯 [Naming & File Location](./reference/naming.md) +- 📊 [Coverage Setup](./reference/coverage.md) +- 🔌 [Public API Testing](./reference/public-api.md) diff --git a/skills/python-test-strategy/reference/coverage.md b/skills/python-test-strategy/reference/coverage.md new file mode 100644 index 0000000..5dda53a --- /dev/null +++ b/skills/python-test-strategy/reference/coverage.md @@ -0,0 +1,54 @@ +# Code Coverage Configuration + +## Goals + +- **Target**: 95%+ coverage for all implementation files +- Prevents untested code paths +- Detects breaking changes early + +## Configuration File + +`.coveragerc` at project root controls coverage: + +```ini +[report] +exclude_lines = + pragma: no cover + if __name__ == .__main__.: + raise NotImplementedError + +omit = + */tests/* + */venv/* +``` + +## Running Coverage + +```bash +# Individual module with coverage report +.venv/bin/python tests/subpackage/test_module.py + +# Entire package +.venv/bin/python tests/subpackage/all.py + +# All tests +.venv/bin/python tests/all.py +``` + +Output: HTML report in `htmlcov/` showing covered/uncovered lines. + +## Marking Code as Non-Testable + +Use `# pragma: no cover` for untestable code: + +```python +if sys.platform == "win32": # pragma: no cover + return "windows" +``` + +Use for: +- Platform-specific code +- Emergency fallbacks +- Code that shouldn't occur in normal operation + +Don't use for regular logic or error handling. diff --git a/skills/python-test-strategy/reference/naming.md b/skills/python-test-strategy/reference/naming.md new file mode 100644 index 0000000..1bf1f38 --- /dev/null +++ b/skills/python-test-strategy/reference/naming.md @@ -0,0 +1,45 @@ +# Test File Naming & Organization + +## Convention + +Test files mirror source directory structure with consistent naming: + +``` +Source: //.py +Test: tests//test__.py +``` + +## Examples + +| Source | Test | +|--------|------| +| `learn_haystack/math/calculator.py` | `tests/math/test_math_calculator.py` | +| `learn_haystack/math/ops/add.py` | `tests/math/ops/test_math_ops_add.py` | +| `learn_haystack/utils.py` | `tests/test_utils.py` | + +## Finding Test Location + +Use the script to determine correct test path: +```bash +python scripts/locate_test_file.py /absolute/path/to/source.py +``` + +## Directory Structure + +Test directories mirror source packages: +``` +learn_haystack/ tests/ +├── math/ → ├── math/ +│ └── ops/ → │ └── ops/ +└── utils.py → └── test_utils.py + +tests/math/all.py # Run all tests in math package +tests/math/ops/all.py # Run all tests in ops subpackage +tests/all.py # Run all project tests +``` + +## Why This Pattern + +- Unique names prevent collisions across packages +- Directory mirroring makes tests easy to find +- Full path in filename supports tooling (IDE jumps, auto-generation) diff --git a/skills/python-test-strategy/reference/public-api.md b/skills/python-test-strategy/reference/public-api.md new file mode 100644 index 0000000..bae041e --- /dev/null +++ b/skills/python-test-strategy/reference/public-api.md @@ -0,0 +1,57 @@ +# Public API Testing + +## API Module + +Create `api.py` that exports all public interfaces (one import per line): + +```python +# project/api.py +from .math.operations import add_numbers +from .math.operations import subtract_numbers +from .utils.helpers import format_output +from .core.engine import create_engine +``` + +One import per line makes changes clear in diffs and easier to maintain. + +## Test Public API + +Test file at `tests/test_api.py` verifies all exports are accessible: + +```python +from project import api + +def test_api(): + """Verify all public APIs are importable.""" + _ = api.add_numbers + _ = api.subtract_numbers + _ = api.format_output + _ = api.create_engine +``` + +## Purpose + +- Catch accidental removal of public exports +- Prevent breaking changes to API +- Document what users can import +- Fail tests if API changes unexpectedly + +## Best Practices + +1. **Export only stable APIs** - Functions and classes that are unlikely to change +2. **Keep minimal** - Don't export internal utilities +3. **Document** - Add docstring to `api.py` explaining what's exported +4. **Group logically** - Organize imports by functional area + +## Deprecation + +Mark deprecated APIs before removal: + +```python +import warnings + +def old_function(): + """Deprecated: Use new_function() instead.""" + warnings.warn("Use new_function instead", DeprecationWarning, stacklevel=2) + return new_function() +``` diff --git a/skills/python-test-strategy/scripts/locate_test_file.py b/skills/python-test-strategy/scripts/locate_test_file.py new file mode 100755 index 0000000..cee35bc --- /dev/null +++ b/skills/python-test-strategy/scripts/locate_test_file.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Locate the test file path for a given Python source file. + +This utility calculates where a test file should be placed for a given Python +source file, following the Python test strategy naming convention: + + Source: git-repo///.py + Test: tests//test__.py + +Given an absolute path to a source file, this script: + +1. Finds the project root (by locating pyproject.toml) +2. Determines the relative path from project root to source file +3. Calculates the correct test file path using naming convention +4. Prints the absolute test file path + +This is useful for: + +- IDE integrations that need to jump from source to test file +- Build tools that generate test files in the correct location +- Pre-commit hooks that validate tests exist for changed source files +- Development workflows that automate test file creation + +Examples: + + Given source file: + /Users/dev/project/learn_claude_code/math/operations/calculator.py + + The script outputs: + /Users/dev/project/tests/math/operations/test_math_operations_calculator.py + + Another example: + Given source file: + /Users/dev/project/learn_claude_code/utils/helpers.py + + The script outputs: + /Users/dev/project/tests/utils/test_utils_helpers.py +""" + +import argparse +import sys +from pathlib import Path + + +def locate_pyproject_toml(dir_cwd: Path) -> Path | None: + """ + Locate the pyproject.toml file by searching upwards in the directory tree. + + Searches up to 10 levels up from the starting directory to find the + pyproject.toml file, which indicates the project root. + + Args: + dir_cwd: Starting directory for search + + Returns: + Path to pyproject.toml if found, None otherwise + """ + for _ in range(10): + pyproject = dir_cwd / "pyproject.toml" + if pyproject.exists(): + return pyproject + dir_cwd = dir_cwd.parent + return None + + +def calculate_test_file_path( + source_file_path: Path, + project_root: Path, +) -> Path: + """ + Calculate the test file path for a given source file. + + Applies the naming convention: + tests//test__.py + + For example: + Source: learn_claude_code/math/operations/calculator.py + Test: tests/math/operations/test_math_operations_calculator.py + + Args: + source_file_path: Absolute path to the source file + project_root: Absolute path to the project root + + Returns: + Absolute path where the test file should be located + + Raises: + ValueError: If source file is not within the project + """ + # Get relative path from project root + try: + relative_source = source_file_path.relative_to(project_root) + except ValueError: + raise ValueError( + f"Source file {source_file_path} is not within project root {project_root}" + ) + + # Get all path parts: ['package_name', 'subpackage', 'module.py'] + parts = relative_source.parts + skip_first = 1 # Skip the project/package name (first directory) + + # Extract subdirectory and module name + # parts[1:-1] are subdirectories, parts[-1] is the module filename + subdirs = parts[skip_first:-1] + module_filename = parts[-1].replace(".py", "") + + # Build test filename + if subdirs: + # Has subdirectories: test___.py + test_filename = f"test_{'_'.join(subdirs)}_{module_filename}.py" + test_dir = project_root / "tests" / Path(*subdirs) + else: + # Root level: test_.py + test_filename = f"test_{module_filename}.py" + test_dir = project_root / "tests" + + return test_dir / test_filename + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Calculate test file path for a given Python source file", + epilog=""" +Examples: + %(prog)s /path/to/project/learn_claude_code/math/calculator.py + → /path/to/project/tests/math/test_math_calculator.py + + %(prog)s /path/to/project/learn_claude_code/math/operations/calculator.py + → /path/to/project/tests/math/operations/test_math_operations_calculator.py + +This script is useful for: + • IDE integrations (jump from source to test) + • Pre-commit hooks (verify tests exist) + • Build tools (generate test files) + • Development workflows + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument( + "source_file", + type=str, + help="Absolute path to the Python source file", + ) + + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show detailed information about the calculation", + ) + + args = parser.parse_args() + + # Convert to Path + source_path = Path(args.source_file) + + # Don't check existence of source file for flexibility, this function + # is just to calculate the test path. + + # Find project root + pyproject = locate_pyproject_toml(source_path.parent) + if not pyproject: + print( + "Error: Could not locate pyproject.toml (project root not found)", + file=sys.stderr, + ) + sys.exit(1) + + project_root = pyproject.parent + + # Calculate test file path + try: + test_path = calculate_test_file_path(source_path, project_root) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + # Output results + if args.verbose: + print(f"Project root: {project_root}") + print(f"Source file: {source_path}") + rel_source = source_path.relative_to(project_root) + print(f"Relative source: {rel_source}") + print(f"Test file: {test_path}") + else: + # Just print the test path + print(test_path) + + +if __name__ == "__main__": + main()