diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a50047d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# Python files +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 100 + +# YAML files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# JSON files +[*.json] +indent_style = space +indent_size = 2 + +# Markdown files +[*.md] +trim_trailing_whitespace = false + +# Makefile +[Makefile] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 430bb45..f77d84f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,20 +6,49 @@ labels: bug assignees: '' --- -**Describe the bug** A clear and concise description of what the bug is. +# Describe the bug -**To Reproduce** Steps to reproduce the behavior: +A clear and concise description of what the bug is. + +# To Reproduce + +Steps to reproduce the behavior: 1. Install package '...' 2. Run code '...' 3. See error -**Expected behavior** A clear and concise description of what you expected to happen. +# Expected behavior + +A clear and concise description of what you expected to happen. -**Environment:** +# Actual behavior + +A clear description of what actually happened. + +# Environment: - OS: [e.g. Ubuntu 22.04, Windows 11] - Python version: [e.g. 3.10.8] - Package version: [e.g. 0.1.1] +- Installation method: [e.g. pip, poetry, from source] + +# Error message/logs + +``` +Paste any error messages or logs here +``` + +# Possible Solution + +If you have suggestions on a fix for the bug, please describe it here. + +# Additional context + +Add any other context about the problem here. + +# Checklist -**Additional context** Add any other context about the problem here. +- [ ] I have checked that this issue has not already been reported +- [ ] I have provided all the information needed to understand the bug +- [ ] I have simplified the reproduction steps as much as possible diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index f99a5f8..79a76fd 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,19 +2,43 @@ name: Feature request about: Suggest an idea for this project title: '[FEATURE] ' -labels: 'enhancement' +labels: enhancement assignees: '' - --- -**Is your feature request related to a problem? Please describe.** +# Is your feature request related to a problem? Please describe. + A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -**Describe the solution you'd like** +# Describe the solution you'd like + A clear and concise description of what you want to happen. -**Describe alternatives you've considered** +# Describe alternatives you've considered + A clear and concise description of any alternative solutions or features you've considered. -**Additional context** +# User experience / example code + +How would users interact with this feature? If applicable, provide example code or CLI commands showing how this feature would be used. + +```python +# Example code demonstrating how the feature would be used +from my_python_package import new_feature + +new_feature.do_something() +``` + +# Benefits and potential drawbacks + +What are the benefits of implementing this feature? Are there any potential drawbacks or challenges you foresee? + +# Additional context + Add any other context or screenshots about the feature request here. + +# Checklist + +- [ ] I have checked that this feature has not already been requested +- [ ] I have considered the scope of this feature and how it fits with the project's goals +- [ ] I have provided clear examples of how the feature would be used diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d2d5c65..cfb0847 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,30 @@ -# Description +# Pull Request - +## Description + - # Type of change +## Related Issues + + + +## Type of change + - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring (no functional changes) +- [ ] Test additions or improvements +- [ ] CI/CD or build system changes + +## How Has This Been Tested? + + -# Checklist +## Checklist + - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code @@ -18,3 +33,12 @@ - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules +- [ ] I have updated the version number as appropriate (for feature/breaking changes) +- [ ] I have added a note to CHANGELOG.md if appropriate + +## Screenshots (if applicable) + + +## Additional context + diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 3dbd598..7e257e9 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -7,6 +7,10 @@ on: branches: [ "main" ] workflow_dispatch: +# Add permissions block here +permissions: + contents: write # Needed to update the README.md file + jobs: coverage: runs-on: ubuntu-latest diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml new file mode 100644 index 0000000..d8098a1 --- /dev/null +++ b/.github/workflows/dependabot.yml @@ -0,0 +1,69 @@ +# Dependabot configuration file +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + # Enable version updates for Python + - package-ecosystem: "pip" + # Look for requirements files in the root directory + directory: "/" + # Check for updates once a week (Monday) + schedule: + interval: "weekly" + day: "monday" + # Group dependencies to consolidate PRs + groups: + dev-dependencies: + patterns: + - "black" + - "isort" + - "ruff" + - "mypy" + - "pytest*" + - "bandit" + - "pre-commit" + production-dependencies: + patterns: + - "*" + exclude-patterns: + - "black" + - "isort" + - "ruff" + - "mypy" + - "pytest*" + - "bandit" + - "pre-commit" + # Maximum number of open PRs + open-pull-requests-limit: 10 + # Prefix PR titles + commit-message: + prefix: "deps" + include: "scope" + # Add labels to PRs + labels: + - "dependencies" + - "automerge" + # Allow automatic merging + reviewers: + - "DiogoRibeiro7" + assignees: + - "DiogoRibeiro7" + + # Enable version updates for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + commit-message: + prefix: "ci" + include: "scope" + labels: + - "dependencies" + - "github_actions" + - "automerge" + reviewers: + - "DiogoRibeiro7" + assignees: + - "DiogoRibeiro7" diff --git a/.github/workflows/docstring-coverage.yml b/.github/workflows/docstring-coverage.yml new file mode 100644 index 0000000..ef0e10b --- /dev/null +++ b/.github/workflows/docstring-coverage.yml @@ -0,0 +1,109 @@ +name: Docstring Coverage + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + docstring-coverage: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + + - name: Check docstring coverage + run: | + python scripts/check_docstring_coverage.py --min-coverage 80 + + - name: Generate docstring coverage badge + run: | + python -c " + import re + import subprocess + import json + + # Run the docstring coverage script and capture output + result = subprocess.run( + ['python', 'scripts/check_docstring_coverage.py', '--dir', 'src'], + capture_output=True, + text=True + ) + + # Extract the overall coverage percentage + match = re.search(r'Overall docstring coverage: (\d+\.\d+)%', result.stdout) + if match: + coverage = float(match.group(1)) + + # Determine badge color + color = 'red' + if coverage >= 90: + color = 'brightgreen' + elif coverage >= 80: + color = 'green' + elif coverage >= 70: + color = 'yellowgreen' + elif coverage >= 60: + color = 'yellow' + elif coverage >= 50: + color = 'orange' + + # Create badge URL + badge_url = f'https://img.shields.io/badge/docstring%20coverage-{coverage:.1f}%25-{color}' + + # Update README.md + with open('README.md', 'r') as f: + readme = f.read() + + # Look for existing docstring coverage badge + docstring_badge_pattern = r'!\[Docstring Coverage\]\(https://img\.shields\.io/badge/docstring%20coverage-[\d\.]+%25-[a-z]+\)' + if re.search(docstring_badge_pattern, readme): + # Replace existing badge + readme = re.sub(docstring_badge_pattern, f'![Docstring Coverage]({badge_url})', readme) + else: + # Look for badge section to add to + badge_section = re.search(r'(!\[[^\]]+\]\([^\)]+\)[ \t]*)+', readme) + if badge_section: + # Add to existing badges + end_pos = badge_section.end() + readme = readme[:end_pos] + f' ![Docstring Coverage]({badge_url})' + readme[end_pos:] + else: + # Add after first line + first_line_end = readme.find('\\n') + if first_line_end != -1: + readme = readme[:first_line_end+1] + f'\\n![Docstring Coverage]({badge_url})\\n' + readme[first_line_end+1:] + else: + readme = readme + f'\\n\\n![Docstring Coverage]({badge_url})\\n' + + with open('README.md', 'w') as f: + f.write(readme) + + print(f'Updated README.md with docstring coverage badge: {coverage:.1f}%') + else: + print('Could not extract docstring coverage percentage') + " + + - name: Commit updated README with docstring coverage badge + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md + git diff --quiet && git diff --staged --quiet || ( + git commit -m "docs: update docstring coverage badge [skip ci]" + git push + ) + +permissions: + contents: write diff --git a/.github/workflows/docstrings-coverage.yml b/.github/workflows/docstrings-coverage.yml new file mode 100644 index 0000000..ef0e10b --- /dev/null +++ b/.github/workflows/docstrings-coverage.yml @@ -0,0 +1,109 @@ +name: Docstring Coverage + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + docstring-coverage: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + + - name: Check docstring coverage + run: | + python scripts/check_docstring_coverage.py --min-coverage 80 + + - name: Generate docstring coverage badge + run: | + python -c " + import re + import subprocess + import json + + # Run the docstring coverage script and capture output + result = subprocess.run( + ['python', 'scripts/check_docstring_coverage.py', '--dir', 'src'], + capture_output=True, + text=True + ) + + # Extract the overall coverage percentage + match = re.search(r'Overall docstring coverage: (\d+\.\d+)%', result.stdout) + if match: + coverage = float(match.group(1)) + + # Determine badge color + color = 'red' + if coverage >= 90: + color = 'brightgreen' + elif coverage >= 80: + color = 'green' + elif coverage >= 70: + color = 'yellowgreen' + elif coverage >= 60: + color = 'yellow' + elif coverage >= 50: + color = 'orange' + + # Create badge URL + badge_url = f'https://img.shields.io/badge/docstring%20coverage-{coverage:.1f}%25-{color}' + + # Update README.md + with open('README.md', 'r') as f: + readme = f.read() + + # Look for existing docstring coverage badge + docstring_badge_pattern = r'!\[Docstring Coverage\]\(https://img\.shields\.io/badge/docstring%20coverage-[\d\.]+%25-[a-z]+\)' + if re.search(docstring_badge_pattern, readme): + # Replace existing badge + readme = re.sub(docstring_badge_pattern, f'![Docstring Coverage]({badge_url})', readme) + else: + # Look for badge section to add to + badge_section = re.search(r'(!\[[^\]]+\]\([^\)]+\)[ \t]*)+', readme) + if badge_section: + # Add to existing badges + end_pos = badge_section.end() + readme = readme[:end_pos] + f' ![Docstring Coverage]({badge_url})' + readme[end_pos:] + else: + # Add after first line + first_line_end = readme.find('\\n') + if first_line_end != -1: + readme = readme[:first_line_end+1] + f'\\n![Docstring Coverage]({badge_url})\\n' + readme[first_line_end+1:] + else: + readme = readme + f'\\n\\n![Docstring Coverage]({badge_url})\\n' + + with open('README.md', 'w') as f: + f.write(readme) + + print(f'Updated README.md with docstring coverage badge: {coverage:.1f}%') + else: + print('Could not extract docstring coverage percentage') + " + + - name: Commit updated README with docstring coverage badge + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md + git diff --quiet && git diff --staged --quiet || ( + git commit -m "docs: update docstring coverage badge [skip ci]" + git push + ) + +permissions: + contents: write diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..614fd87 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,52 @@ +name: Security Scan + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '0 8 * * 1' # Run at 8:00 UTC every Monday + workflow_dispatch: + +jobs: + bandit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install bandit + + - name: Run bandit + run: | + bandit -r src/ -c pyproject.toml -f json -o bandit-results.json + + - name: Upload bandit results + uses: actions/upload-artifact@v4 + with: + name: bandit-results + path: bandit-results.json + + trivy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + format: 'table' + exit-code: '1' + ignore-unfixed: true + severity: 'CRITICAL,HIGH' diff --git a/.github/workflows/style-check.yml b/.github/workflows/style-check.yml new file mode 100644 index 0000000..b9198fa --- /dev/null +++ b/.github/workflows/style-check.yml @@ -0,0 +1,41 @@ +name: Style Check + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + style: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: | + poetry install + + - name: Run black + run: | + poetry run black --check --diff src tests + + - name: Run isort + run: | + poetry run isort --check-only --diff src tests + + - name: Run ruff + run: | + poetry run ruff check src tests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab50453..4bd8932 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,16 +7,46 @@ repos: - id: check-yaml - id: check-toml - id: check-added-large-files + - id: check-ast + - id: check-json + - id: check-merge-conflict + - id: detect-private-key + - id: mixed-line-ending + args: [--fix=lf] + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: [--profile, black, --filter-files] + + - repo: https://github.com/psf/black + rev: 24.2.0 + hooks: + - id: black + args: [--line-length=100] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.3.0 hooks: - id: ruff args: [--fix] - - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.9.0 hooks: - id: mypy additional_dependencies: [types-all] + + - repo: https://github.com/PyCQA/bandit + rev: 1.7.7 + hooks: + - id: bandit + args: ["-c", "pyproject.toml"] + exclude: "tests/" + + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.1 + hooks: + - id: pyupgrade + args: [--py310-plus] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..0634d3c --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,38 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools +build: + os: ubuntu-22.04 + tools: + python: "3.12" + jobs: + post_create_environment: + # Install poetry + - pip install poetry + # Tell poetry to not use a virtual environment + - poetry config virtualenvs.create false + post_install: + # Install dependencies with poetry + - poetry install --with docs + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optionally build your docs in additional formats such as PDF +formats: + - pdf + - epub + +# Optionally declare the Python requirements required to build your docs +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec356d..6e23127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Command-line interface with multiple commands -- Enhanced greeting functions with various options -- Documentation generation script -- Docker and docker-compose setup -- Makefile for common development tasks -- GitHub workflows for testing and CI -- Pre-commit hooks configuration +- Enhanced development tooling with Black, isort, Bandit, and pre-commit hooks +- Added tox.ini for multi-environment testing +- Added .editorconfig for consistent coding style +- Added security scanning GitHub workflow +- Added style checking GitHub workflow +- Added dev-requirements.txt for easier non-Poetry installation +- Enhanced documentation in README.md for development workflow +- Improved GitHub issue and PR templates ### Changed -- Improved project structure and documentation -- Updated dependency management to use Poetry groups +- Updated Makefile with additional commands for development tasks +- Enhanced pyproject.toml with more comprehensive configurations +- Improved code quality settings with stricter linting rules + +### Fixed + +- Fixed GitHub Actions permissions for the code coverage workflow ## [0.1.1] - 2025-08-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 584713e..f51de78 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,17 +24,18 @@ For feature requests, please use the feature request template. Include: 1. A clear title and description 2. Why this feature would be useful -3. Any potential implementation details +3. Example code showing how the feature would be used +4. Any potential implementation details ### Pull Requests 1. Fork the repository 2. Create a new branch (`git checkout -b feature/amazing-feature`) 3. Make your changes -4. Run tests (`make test`) -5. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Run tests and linting (`make test lint type-check`) +5. Commit your changes using [Conventional Commits](https://www.conventionalcommits.org/) format 6. Push to the branch (`git push origin feature/amazing-feature`) -7. Open a Pull Request +7. Open a Pull Request using the PR template ## Development Setup @@ -46,14 +47,19 @@ For feature requests, please use the feature request template. Include: cd my_python_package ``` -2. Install dependencies with Poetry: +2. Set up the development environment: ```bash + # Using Poetry (recommended) + make setup # Installs dependencies and pre-commit hooks + + # OR manually poetry install + pre-commit install ``` -3. Set up pre-commit hooks: +3. Activate the Poetry virtual environment: ```bash - pre-commit install + poetry shell ``` ### Using Docker @@ -71,25 +77,57 @@ docker-compose up type-check # Run type checking 1. Make sure your code passes all checks: ```bash - make lint - make type-check - make test + make format # Format code with black, isort, and ruff + make lint # Run linting checks + make type-check # Run type checking + make test # Run tests + make security # Run security checks ``` 2. Update documentation if needed: ```bash - python scripts/generate_docs.py + make docs ``` 3. Update the CHANGELOG.md with your changes under the [Unreleased] section ## Coding Standards -- Follow PEP 8 style guidelines -- Include docstrings for all functions, classes, and modules -- Add type hints to all function parameters and return values +We use several tools to maintain code quality: + +- **Black**: For code formatting with line length of 100 +- **isort**: For import sorting +- **Ruff**: For linting and code quality checks +- **mypy**: For static type checking +- **Bandit**: For security scanning + +Our pre-commit hooks automatically check these when you commit. You can also run them manually with: + +```bash +# Format code +make format + +# Run all checks +make lint type-check security +``` + +### Style Guide + +- Follow [PEP 8](https://pep8.org/) style guidelines +- Use [Google-style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) +- Include type hints for all function parameters and return values +- Write descriptive, self-documenting code +- Keep functions and methods focused on a single responsibility +- Use descriptive variable names + +## Testing + - Write tests for all new functionality -- Ensure test coverage is maintained or improved +- Maintain or improve test coverage +- Run the full test suite before submitting a PR: + ```bash + make test-cov # Run tests with coverage report + ``` ## Commit Messages @@ -98,7 +136,7 @@ Follow the [Conventional Commits](https://www.conventionalcommits.org/) specific - `feat:` - A new feature - `fix:` - A bug fix - `docs:` - Documentation changes -- `style:` - Changes that do not affect code logic +- `style:` - Changes that do not affect code logic (formatting, etc.) - `refactor:` - Code changes that neither fix a bug nor add a feature - `perf:` - Performance improvements - `test:` - Adding or correcting tests @@ -106,19 +144,45 @@ Follow the [Conventional Commits](https://www.conventionalcommits.org/) specific - `ci:` - Changes to CI configuration - `chore:` - Other changes that don't modify source or test files +Examples: +``` +feat: add random greeting generator +fix: correct punctuation in formal greetings +docs: update usage examples in README +test: add test case for empty name validation +``` + +## Versioning + +We follow [Semantic Versioning](https://semver.org/). The version will be automatically updated based on commit messages when merged to main: + +- Commits with `BREAKING CHANGE` in the message trigger a major version bump +- Commits starting with `feat:` trigger a minor version bump +- All other commits trigger a patch version bump + ## Release Process -1. Update version number in pyproject.toml -2. Update CHANGELOG.md with release date -3. Create a tag with the version number -4. Push the tag to GitHub -5. Build and publish to PyPI: - ```bash - make publish - ``` +1. Update CHANGELOG.md with the release date +2. Create a tag with the version number: `git tag v1.0.0` +3. Push the tag: `git push origin v1.0.0` +4. The CI/CD pipeline will automatically: + - Run tests + - Build the package + - Publish to PyPI + - Create a GitHub Release + +## Documentation + +- Update documentation for any new features or changes +- Keep docstrings up-to-date with code changes +- Add examples for new functionality ## Questions? -If you have any questions, feel free to open an issue or contact the maintainers. +If you have any questions or need help, please: + +1. Check existing issues and discussions +2. Open a new issue with the "question" label +3. Contact the maintainers Thank you for contributing! diff --git a/Makefile b/Makefile index 28b0827..662f7cb 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,43 @@ -.PHONY: help install format lint type-check test test-cov clean build publish-test publish +.PHONY: help install format lint type-check test test-cov clean build publish-test publish setup dev-install security docs tox docs-api docs-build docs-live help: @echo "Available commands:" @echo " make install Install the package and dependencies" - @echo " make format Format code with ruff" + @echo " make dev-install Install the package and dev dependencies" + @echo " make setup Install pre-commit hooks and dev dependencies" + @echo " make format Format code with black, isort, and ruff" @echo " make lint Lint code with ruff" @echo " make type-check Type check with mypy" @echo " make test Run tests" @echo " make test-cov Run tests with coverage" + @echo " make tox Run tests in multiple Python environments" + @echo " make security Run security checks with bandit" + @echo " make docs Generate documentation with pdoc" + @echo " make docs-api Generate Sphinx API documentation" + @echo " make docs-build Build Sphinx documentation" + @echo " make docs-live Run a live server for Sphinx documentation" @echo " make clean Remove build artifacts" @echo " make build Build package" @echo " make publish-test Publish to TestPyPI" @echo " make publish Publish to PyPI" install: - poetry install + poetry install --without dev,docs + +dev-install: + poetry install --with dev + +setup: + poetry install --with dev + pre-commit install format: - poetry run ruff format . + poetry run black src tests + poetry run isort src tests + poetry run ruff format src tests lint: - poetry run ruff check . + poetry run ruff check src tests type-check: poetry run mypy src tests @@ -31,10 +48,32 @@ test: test-cov: poetry run pytest --cov=my_python_package --cov-report=term-missing +tox: + poetry run tox + +security: + poetry run bandit -r src/ + +docs: + poetry run python scripts/generate_docs.py + +docs-api: + cd docs && python make_api_docs.py + +docs-build: + poetry install --with docs + cd docs && $(MAKE) html + +docs-live: + poetry install --with docs + cd docs && $(MAKE) livehtml + clean: rm -rf build/ rm -rf dist/ rm -rf *.egg-info + rm -rf docs/_build/ + rm -rf docs/api/ find . -type d -name __pycache__ -exec rm -rf {} + find . -type f -name "*.pyc" -delete diff --git a/README.md b/README.md index fdde557..3925028 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,11 @@ [![Tests](https://github.com/DiogoRibeiro7/my_python_package/actions/workflows/test.yml/badge.svg)](https://github.com/DiogoRibeiro7/my_python_package/actions/workflows/test.yml) [![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen)](https://codecov.io/gh/DiogoRibeiro7/my_python_package) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Ruff](https://img.shields.io/badge/ruff-enabled-brightgreen)](https://github.com/astral-sh/ruff) +[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat)](https://pycqa.github.io/isort/) +[![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) A minimal but production-ready Python package scaffold configured for publishing to [PyPI](https://pypi.org). @@ -15,20 +19,41 @@ A minimal but production-ready Python package scaffold configured for publishing - πŸ”§ Configurable greeting functions with multiple formatting options - πŸ§ͺ Comprehensive testing suite with 100% coverage - πŸ“Š Continuous Integration workflows for testing, coverage, and releases -- πŸ› οΈ Code quality tools preconfigured (ruff, mypy, pre-commit) +- πŸ› οΈ Code quality tools preconfigured (black, ruff, mypy, isort, pre-commit) - πŸ“ Complete documentation with doctests - πŸ”„ Automated dependency management and version bumping +- πŸ”’ Security scanning with Bandit and Trivy +- 🧩 Multi-environment testing with tox ## Installation +### From PyPI + ```bash -# From PyPI +# Using pip pip install my_python_package -# Or with Poetry +# Using Poetry poetry add my_python_package ``` +### For Development + +```bash +# Clone the repository +git clone https://github.com/DiogoRibeiro7/my_python_package.git +cd my_python_package + +# Using Poetry (recommended) +poetry install + +# Set up pre-commit hooks +pre-commit install + +# Alternatively, use the Makefile for one-step setup +make setup +``` + ## Usage ### Basic Greeting @@ -120,23 +145,42 @@ my-python-package format World --greeting "Welcome" --uppercase --max-length 15 ## Development -### Setup +### Development Environment Setup + +Setting up your development environment is easy with the included tools: + +```bash +# Full development setup (installs all dev dependencies and pre-commit hooks) +make setup + +# Or install only dependencies without pre-commit hooks +make dev-install + +# If you prefer not to use Poetry +pip install -r dev-requirements.txt +pre-commit install +``` + +### Code Formatting and Quality Tools + +The project uses multiple tools to ensure code quality: -1. Clone the repository - ```bash - git clone https://github.com/DiogoRibeiro7/my_python_package.git - cd my_python_package - ``` +```bash +# Format code (black, isort, ruff) +make format + +# Lint code (ruff) +make lint -2. Install dependencies with Poetry - ```bash - poetry install - ``` +# Type check (mypy) +make type-check + +# Security check (bandit) +make security -3. Set up pre-commit hooks - ```bash - pre-commit install - ``` +# Run all quality checks at once +make lint type-check security +``` ### Testing @@ -144,13 +188,25 @@ Run tests with pytest: ```bash # Run all tests -poetry run pytest +make test # Run tests with coverage -poetry run pytest --cov=my_python_package +make test-cov -# Run doctests -poetry run pytest --doctest-modules src/ +# Run tests in multiple Python environments +make tox +``` + +#### Test Categories + +You can run specific test categories using pytest markers: + +```bash +# Run only fast tests (skip slow ones) +pytest -m "not slow" + +# Run only integration tests +pytest -m "integration" ``` ### Documentation @@ -159,41 +215,64 @@ Generate documentation: ```bash # Generate HTML documentation -poetry run python scripts/generate_docs.py +make docs -# Generate Markdown documentation -poetry run python scripts/generate_docs.py --format markdown +# Or use the script directly with options +python scripts/generate_docs.py --format markdown --output-dir docs/markdown ``` -### Code Quality - -Run linting and type checking: +### Package Management ```bash -# Format code -poetry run ruff format . +# Build the package +make build -# Lint code -poetry run ruff check . +# Publish to TestPyPI +make publish-test -# Type check -poetry run mypy src tests +# Publish to PyPI +make publish ``` -You can also use the included Makefile: +### Version Management + +Automatically bump the package version: ```bash -# Format and lint -make format -make lint +# Patch version (0.1.0 -> 0.1.1) +make bump-patch -# Type check -make type-check +# Minor version (0.1.0 -> 0.2.0) +make bump-minor -# Run tests with coverage -make test-cov +# Major version (0.1.0 -> 1.0.0) +make bump-major +``` + +### Dependency Management + +Check and update dependencies: + +```bash +# Check for missing or unused dependencies +make check-deps ``` +## Continuous Integration + +The repository includes GitHub Actions for: + +1. **Testing**: Runs the test suite on multiple Python versions +2. **Code Coverage**: Tracks and reports test coverage +3. **Style Checking**: Ensures code follows the project's style guidelines +4. **Security Scanning**: Checks for vulnerabilities in code and dependencies +5. **Version Bumping**: Automatically bumps version on push to main based on commit message: + - `BREAKING CHANGE` β†’ major version bump + - `feat:` prefix β†’ minor version bump + - Otherwise β†’ patch version bump +6. **Dependency Updates**: Weekly PR to update dependency constraints to latest +7. **Release Automation**: Automates PyPI releases when a version tag is pushed + ## Project Structure ```text @@ -206,9 +285,14 @@ my_python_package/ β”œβ”€β”€ CONTRIBUTORS.md # List of contributors β”œβ”€β”€ .gitignore # Git ignore rules β”œβ”€β”€ .pre-commit-config.yaml # Pre-commit hooks configuration +β”œβ”€β”€ .editorconfig # Editor configuration +β”œβ”€β”€ setup.cfg # Configuration for various tools +β”œβ”€β”€ tox.ini # Multi-environment testing β”œβ”€β”€ Makefile # Common development tasks β”œβ”€β”€ Dockerfile # Docker container definition β”œβ”€β”€ docker-compose.yml # Docker services configuration +β”œβ”€β”€ requirements.txt # Dependencies for simple installation +β”œβ”€β”€ dev-requirements.txt # Development dependencies β”œβ”€β”€ src/ β”‚ └── my_python_package/ # Package source code β”‚ β”œβ”€β”€ __init__.py # Package exports @@ -235,25 +319,36 @@ my_python_package/ └── workflows/ # CI/CD workflows β”œβ”€β”€ test.yml # Run tests on push/PR β”œβ”€β”€ code-coverage.yml # Track code coverage + β”œβ”€β”€ style-check.yml # Check code style + β”œβ”€β”€ security-scan.yml # Security scanning β”œβ”€β”€ release.yml # PyPI release automation β”œβ”€β”€ dependency-scanning.yml # Security scanning β”œβ”€β”€ auto-pyproject-update.yml # Version bumping └── auto-upgrade-pyproject.yml # Dependency upgrades ``` -## Automated Workflows +## Tool Configuration -The repository includes GitHub Actions for: +The project includes configuration for several development tools: -1. **Testing**: Runs the test suite on multiple Python versions -2. **Code Coverage**: Tracks and reports test coverage -3. **Version Bumping**: Automatically bumps version on push to main based on commit message: - - `BREAKING CHANGE` β†’ major version bump - - `feat:` prefix β†’ minor version bump - - Otherwise β†’ patch version bump -4. **Dependency Updates**: Weekly PR to update dependency constraints to latest -5. **Security Scanning**: Regular checks for vulnerable dependencies -6. **Release Automation**: Automates PyPI releases when a version tag is pushed +### Code Style and Quality + +- **Black**: Consistent code formatting with a line length of 100 +- **isort**: Import sorting with Black compatibility +- **Ruff**: Fast Python linter that combines multiple tools (flake8, pycodestyle, etc.) +- **mypy**: Static type checking with strict settings +- **Bandit**: Security vulnerability scanning + +### Testing + +- **pytest**: Test framework with coverage reporting +- **tox**: Multi-environment testing +- **Coverage**: Code coverage tracking and reporting + +### CI/CD + +- **GitHub Actions**: Automated workflows for testing, linting, and releasing +- **pre-commit**: Automated checks before committing code ## Contributing diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..5289fe8 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,16 @@ +pytest>=8.4.1 +tomlkit>=0.13.3 +packaging>=25.0 +pytest-cov>=5.0.0 +mypy>=1.9.0 +ruff>=0.3.0 +black>=24.2.0 +isort>=5.13.2 +pydocstyle>=6.3.0 +bandit>=1.7.7 +pytest-mock>=3.12.0 +pytest-sugar>=1.0.0 +pre-commit>=3.6.2 +tox>=4.13.0 +pdoc>=14.3.0 +types-all>=1.0.0 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..8c1a921 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,33 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile clean livehtml apidoc + +# Clean build files +clean: + rm -rf $(BUILDDIR)/* + rm -rf api/* + +# Generate API documentation +apidoc: + python make_api_docs.py + +# Build documentation with live reload +livehtml: + sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O) --open-browser + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css new file mode 100644 index 0000000..5dc2e7d --- /dev/null +++ b/docs/_static/css/custom.css @@ -0,0 +1,119 @@ +/* Custom styles for my_python_package documentation */ + +/* Improve code block appearance */ +div[class^="highlight"] { + border-radius: 4px; + border-left: 3px solid #2980b9; +} + +/* Make tables look better */ +table.docutils { + border-radius: 4px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); + margin-bottom: 24px; +} + +table.docutils th { + background-color: #2980b9; + color: white; + border: none; +} + +/* Improve admonitions */ +.admonition { + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); +} + +.admonition-title { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.admonition.note .admonition-title { + background-color: #2980b9; +} + +.admonition.warning .admonition-title { + background-color: #f39c12; +} + +.admonition.error .admonition-title { + background-color: #e74c3c; +} + +.admonition.tip .admonition-title { + background-color: #27ae60; +} + +/* Improve function signatures */ +dl.py.function, dl.py.class, dl.py.method { + padding: 10px; + border-radius: 4px; + border-left: 3px solid #2980b9; + background-color: #f5f5f5; + margin-bottom: 20px; +} + +dl.py.function dt, dl.py.class dt, dl.py.method dt { + background-color: #e8f0f9; + padding: 8px; + border-radius: 4px; + margin-bottom: 10px; +} + +/* Improve navigation */ +.wy-nav-side { + background-color: #1a242f; +} + +.wy-menu-vertical li.current { + background-color: #f5f5f5; +} + +.wy-menu-vertical li.current > a { + border-color: #2980b9; +} + +/* Make headings more readable */ +h1, h2, h3, h4, h5, h6 { + font-family: 'Roboto', sans-serif; + margin-top: 20px; + margin-bottom: 10px; +} + +h1 { + border-bottom: 1px solid #eaecef; + padding-bottom: 0.3em; +} + +/* Custom class for module overview boxes */ +.module-overview { + background-color: #f8f9fa; + border-left: 3px solid #2980b9; + padding: 15px; + margin-bottom: 20px; + border-radius: 4px; +} + +.module-overview h3 { + margin-top: 0; + color: #2980b9; +} + +/* Make function parameters stand out */ +.field-list { + border-left: 2px solid #2980b9; + padding-left: 10px; +} + +/* Improve warning boxes */ +div.warning { + background-color: #fef9e7; +} + +/* Add version selector styling */ +.rst-versions { + border-top: solid 10px #2980b9; +} diff --git a/docs/_static/js/custom.js b/docs/_static/js/custom.js new file mode 100644 index 0000000..646c832 --- /dev/null +++ b/docs/_static/js/custom.js @@ -0,0 +1,119 @@ +// Custom JavaScript for my_python_package documentation + +document.addEventListener('DOMContentLoaded', function() { + // Add copy buttons to code blocks + document.querySelectorAll('div[class^="highlight"]').forEach(function(codeBlock) { + // Create the copy button + var button = document.createElement('button'); + button.className = 'copy-button'; + button.textContent = 'Copy'; + + // Add the button to the code block + codeBlock.appendChild(button); + + // Add click event listener to the button + button.addEventListener('click', function() { + // Find the element within the code block + var code = codeBlock.querySelector('code'); + var text = code.innerText; + + // Create a temporary textarea element to copy the text + var textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + + // Select and copy the text + textarea.select(); + document.execCommand('copy'); + + // Remove the textarea + document.body.removeChild(textarea); + + // Change the button text to indicate success + button.textContent = 'Copied!'; + + // Reset the button text after a delay + setTimeout(function() { + button.textContent = 'Copy'; + }, 2000); + }); + }); + + // Add styles for the copy button + var style = document.createElement('style'); + style.textContent = ` + .copy-button { + position: absolute; + top: 5px; + right: 5px; + background-color: #2980b9; + color: white; + border: none; + border-radius: 3px; + padding: 5px 10px; + font-size: 12px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s; + } + div[class^="highlight"] { + position: relative; + } + div[class^="highlight"]:hover .copy-button { + opacity: 1; + } + .copy-button:hover { + background-color: #3498db; + } + `; + document.head.appendChild(style); + + // Add anchor links to headings + document.querySelectorAll('h2, h3, h4, h5, h6').forEach(function(heading) { + if (heading.id) { + // Create the anchor link + var link = document.createElement('a'); + link.className = 'headerlink'; + link.href = '#' + heading.id; + link.title = 'Permalink to this heading'; + link.innerHTML = 'ΒΆ'; + + // Add the link to the heading + heading.appendChild(link); + } + }); + + // Add example collapsers + document.querySelectorAll('.admonition.example').forEach(function(example) { + var title = example.querySelector('.admonition-title'); + if (title) { + title.style.cursor = 'pointer'; + + // Create a toggle button + var toggle = document.createElement('span'); + toggle.className = 'example-toggle'; + toggle.innerHTML = '[-]'; + toggle.style.float = 'right'; + toggle.style.marginRight = '5px'; + title.appendChild(toggle); + + // Add click event listener to the title + title.addEventListener('click', function() { + // Find the content of the example + var content = example.querySelectorAll(':scope > *:not(.admonition-title)'); + + // Toggle the visibility of the content + var isVisible = content[0].style.display !== 'none'; + content.forEach(function(element) { + element.style.display = isVisible ? 'none' : ''; + }); + + // Update the toggle button + toggle.innerHTML = isVisible ? '[+]' : '[-]'; + }); + } + }); +}); diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..590f283 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,31 @@ +# API Reference + +This page documents the public API of `my_python_package`. + +## Core Functions + +::: my_python_package.core + options: + show_root_heading: true + show_source: true + +## Configuration + +::: my_python_package.config + options: + show_root_heading: true + show_source: true + +## Logging + +::: my_python_package.logging + options: + show_root_heading: true + show_source: true + +## Command Line Interface + +::: my_python_package.cli + options: + show_root_heading: true + show_source: true diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..41864b2 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,65 @@ +========= +Changelog +========= + +All notable changes to this project will be documented in this file. + +The format is based on `Keep a Changelog `_, and this project adheres to `Semantic Versioning `_. + +[Unreleased] +----------- + +Added +~~~~~ + +- Enhanced development tooling with Black, isort, Bandit, and pre-commit hooks +- Added tox.ini for multi-environment testing +- Added .editorconfig for consistent coding style +- Added security scanning GitHub workflow +- Added style checking GitHub workflow +- Added docstring coverage checking +- Added Sphinx documentation with Read the Docs theme +- Added ReadTheDocs configuration +- Added Dependabot configuration for automated dependency updates +- Added dev-requirements.txt for easier non-Poetry installation +- Enhanced documentation in README.md for development workflow +- Improved GitHub issue and PR templates + +Changed +~~~~~~~ + +- Updated Makefile with additional commands for development tasks +- Enhanced pyproject.toml with more comprehensive configurations +- Improved code quality settings with stricter linting rules +- Enhanced CONTRIBUTING.md with more detailed guidelines + +Fixed +~~~~~ + +- Fixed GitHub Actions permissions for the code coverage workflow + +[0.1.1] - 2025-08-14 +------------------- + +Added +~~~~~ + +- Initial package structure +- Basic hello function +- Testing setup with pytest +- Automated dependency management scripts + +Changed +~~~~~~~ + +- Updated pyproject.toml to use modern Poetry configuration + +[0.1.0] - 2025-08-01 +------------------- + +Added +~~~~~ + +- Initial release +- Basic project structure +- MIT License diff --git a/docs/cli.rst b/docs/cli.rst new file mode 100644 index 0000000..ea4f4e3 --- /dev/null +++ b/docs/cli.rst @@ -0,0 +1,247 @@ +======================= +Command Line Interface +======================= + +``my_python_package`` provides a command-line interface (CLI) for easy access to its functionality. + +Basic Usage +---------- + +The basic syntax for the CLI is: + +.. code-block:: bash + + my-python-package COMMAND [ARGS] [OPTIONS] + +Available Commands +----------------- + +hello +~~~~~ + +Generate a simple greeting: + +.. code-block:: bash + + my-python-package hello NAME [--greeting GREETING] + +Examples: + +.. code-block:: bash + + my-python-package hello World + # Output: Hello, World! + + my-python-package hello Python --greeting Hi + # Output: Hi, Python! + +random +~~~~~~ + +Generate a random greeting: + +.. code-block:: bash + + my-python-package random NAME + +Example: + +.. code-block:: bash + + my-python-package random World + # Output varies with each run + +time +~~~~ + +Generate a time-based greeting: + +.. code-block:: bash + + my-python-package time NAME [--formal] + +Examples: + +.. code-block:: bash + + my-python-package time World + # Output depends on time of day: + # Morning: "Good morning, World!" + # Afternoon: "Good afternoon, World!" + # Evening: "Good evening, World!" + + my-python-package time Mrs.Smith --formal + # Output: "Good day, Mr./Ms. Mrs.Smith!" + +format +~~~~~~ + +Format a greeting with various options: + +.. code-block:: bash + + my-python-package format NAME + [--greeting GREETING] + [--punctuation PUNCTUATION] + [--uppercase] + [--max-length MAX_LENGTH] + +Examples: + +.. code-block:: bash + + my-python-package format World + # Output: Hello, World! + + my-python-package format World --greeting Welcome --punctuation "!!!" --uppercase + # Output: WELCOME, WORLD!!! + + my-python-package format "Very Long Name" --max-length 15 + # Output: Hello, Very... + +multi +~~~~~ + +Greet multiple names: + +.. code-block:: bash + + my-python-package multi NAME1 NAME2 ... [--greeting GREETING] + +Example: + +.. code-block:: bash + + my-python-package multi Alice Bob Charlie + # Output: + # Hello, Alice! + # Hello, Bob! + # Hello, Charlie! + + my-python-package multi Alice Bob --greeting "Greetings" + # Output: + # Greetings, Alice! + # Greetings, Bob! + +config +~~~~~~ + +Manage configuration settings: + +.. code-block:: bash + + my-python-package config SUBCOMMAND [OPTIONS] + +Subcommands: + +- ``show``: Show current configuration +- ``set``: Set configuration values +- ``add-greeting``: Add a greeting to available greetings +- ``save``: Save configuration to file +- ``load``: Load configuration from file + +Examples: + +.. code-block:: bash + + # Show current configuration + my-python-package config show + + # Set default greeting + my-python-package config set --greeting "Howdy" + + # Set default punctuation + my-python-package config set --punctuation "?" + + # Set formal title + my-python-package config set --title "Dr. " + + # Set maximum name length + my-python-package config set --max-name-length 30 + + # Add a greeting + my-python-package config add-greeting "Salutations" + + # Save configuration to file + my-python-package config save config.json + + # Load configuration from file + my-python-package config load config.json + +Global Options +------------ + +The following options are available for all commands: + +.. code-block:: bash + + --log-level {debug,info,warning,error,critical} + Set logging level + --log-file LOG_FILE Path to log file + --version Show version information and exit + --help Show help message and exit + +Examples: + +.. code-block:: bash + + # Show help for the hello command + my-python-package hello --help + + # Show version information + my-python-package --version + + # Set logging level + my-python-package hello World --log-level debug + + # Log to file + my-python-package hello World --log-file greeting.log + +Advanced Usage +------------ + +Scripting +~~~~~~~~ + +You can use the CLI in shell scripts: + +.. code-block:: bash + + #!/bin/bash + + # Greet all users in a file + while read name; do + my-python-package hello "$name" --greeting "Welcome" + done < users.txt + + # Save and load configuration + my-python-package config set --greeting "Hi" --punctuation "!" + my-python-package config save my_config.json + + # Later, restore the configuration + my-python-package config load my_config.json + +Output Redirection +~~~~~~~~~~~~~~~~ + +You can redirect the output to files: + +.. code-block:: bash + + # Save greetings to a file + my-python-package multi Alice Bob Charlie > greetings.txt + + # Append more greetings + my-python-package hello Dave >> greetings.txt + +Error Handling +~~~~~~~~~~~~ + +The CLI will return non-zero exit codes on errors: + +.. code-block:: bash + + # Script example with error handling + if ! my-python-package hello ""; then + echo "Failed to greet empty name" + fi diff --git a/docs/code_of_conduct.rst b/docs/code_of_conduct.rst new file mode 100644 index 0000000..f4fb4e0 --- /dev/null +++ b/docs/code_of_conduct.rst @@ -0,0 +1,132 @@ +=============== +Code of Conduct +=============== + +Our Pledge +--------- + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +Our Standards +----------- + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +Enforcement Responsibilities +-------------------------- + +Project maintainers are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +Scope +---- + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +Enforcement +---------- + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All project maintainers are obligated to respect the privacy and security of the +reporter of any incident. + +Enforcement Guidelines +-------------------- + +Project maintainers will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +1. Correction +~~~~~~~~~~~~ + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from project maintainers, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +2. Warning +~~~~~~~~~ + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +3. Temporary Ban +~~~~~~~~~~~~~~ + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +4. Permanent Ban +~~~~~~~~~~~~~~ + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +Attribution +---------- + +This Code of Conduct is adapted from the `Contributor Covenant `_, +version 2.1, available at +`https://www.contributor-covenant.org/version/2/1/code_of_conduct.html `_. diff --git a/docs/config.py b/docs/config.py new file mode 100644 index 0000000..14733c9 --- /dev/null +++ b/docs/config.py @@ -0,0 +1,167 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import os +import sys +from datetime import datetime + +# Add package to path for autodoc +sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, os.path.abspath("../src")) + +# -- Project information ----------------------------------------------------- + +project = "my_python_package" +copyright = f"{datetime.now().year}, Diogo Ribeiro" +author = "Diogo Ribeiro" + +# The full version, including alpha/beta/rc tags +try: + from my_python_package import __version__ + + release = __version__ +except ImportError: + release = "0.3.0" + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", + "sphinx.ext.autosectionlabel", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "sphinx.ext.mathjax", + "sphinx_rtd_theme", + "sphinx.ext.autosummary", + "sphinx.ext.githubpages", + "myst_parser", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# -- Options for autodoc ----------------------------------------------------- +autodoc_default_options = { + "members": True, + "member-order": "bysource", + "undoc-members": True, + "show-inheritance": True, + "special-members": "__init__", +} +autodoc_typehints = "description" +autodoc_typehints_format = "short" +autoclass_content = "both" + +# -- Options for napoleon ---------------------------------------------------- +napoleon_google_docstring = True +napoleon_numpy_docstring = False +napoleon_include_init_with_doc = True +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = True +napoleon_use_admonition_for_notes = True +napoleon_use_admonition_for_references = True +napoleon_use_ivar = True +napoleon_use_param = True +napoleon_use_rtype = True +napoleon_use_keyword = True +napoleon_custom_sections = None + +# -- Options for intersphinx ------------------------------------------------- +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. +html_theme = "sphinx_rtd_theme" + +# Theme options +html_theme_options = { + "logo_only": False, + "display_version": True, + "prev_next_buttons_location": "bottom", + "style_external_links": False, + "style_nav_header_background": "#2980B9", + # Toc options + "collapse_navigation": True, + "sticky_navigation": True, + "navigation_depth": 4, + "includehidden": True, + "titles_only": False, +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# These paths are either relative to html_static_path +# or fully qualified paths (eg. https://...) +html_css_files = [ + "css/custom.css", +] + +html_js_files = [ + "js/custom.js", +] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +html_sidebars = { + "**": [ + "sidebar/brand.html", + "sidebar/search.html", + "sidebar/scroll-start.html", + "sidebar/navigation.html", + "sidebar/ethical-ads.html", + "sidebar/scroll-end.html", + ] +} + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +html_favicon = None + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +html_show_copyright = True + +# -- Options for myst_parser ------------------------------------------------- +myst_enable_extensions = [ + "amsmath", + "colon_fence", + "deflist", + "dollarmath", + "fieldlist", + "html_admonition", + "html_image", + "linkify", + "replacements", + "smartquotes", + "strikethrough", + "substitution", + "tasklist", +] diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..fa9f314 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,136 @@ +============ +Contributing +============ + +Thank you for considering contributing to ``my_python_package``! This page provides guidelines for contributing to the project. + +Code of Conduct +-------------- + +Please be respectful and considerate of others when contributing to this project. We aim to foster an inclusive and welcoming community. + +For more details, see the :doc:`code_of_conduct`. + +Ways to Contribute +----------------- + +There are many ways to contribute to the project: + +- Reporting bugs +- Suggesting enhancements +- Improving documentation +- Writing code +- Reviewing pull requests + +Reporting Bugs +------------- + +If you find a bug, please create an issue using the bug report template. Include: + +1. A clear title and description +2. Steps to reproduce the behavior +3. Expected behavior +4. Actual behavior +5. Environment details (OS, Python version, package version) + +Suggesting Enhancements +---------------------- + +For feature requests, please use the feature request template. Include: + +1. A clear title and description +2. Why this feature would be useful +3. Example code showing how the feature would be used +4. Any potential implementation details + +Pull Request Process +------------------ + +1. Fork the repository +2. Create a new branch (``git checkout -b feature/amazing-feature``) +3. Make your changes +4. Run tests and linting (``make test lint type-check``) +5. Commit your changes using `Conventional Commits `_ format +6. Push to the branch (``git push origin feature/amazing-feature``) +7. Open a Pull Request using the PR template + +Development Environment +--------------------- + +See the :doc:`development` guide for details on setting up your development environment. + +Coding Standards +-------------- + +We use several tools to maintain code quality: + +- **Black**: For code formatting with line length of 100 +- **isort**: For import sorting +- **Ruff**: For linting and code quality checks +- **mypy**: For static type checking +- **Bandit**: For security scanning + +Our pre-commit hooks automatically check these when you commit. + +Style Guide +~~~~~~~~~~ + +- Follow `PEP 8 `_ style guidelines +- Use `Google-style docstrings `_ +- Include type hints for all function parameters and return values +- Write descriptive, self-documenting code +- Keep functions and methods focused on a single responsibility +- Use descriptive variable names + +Testing +------ + +- Write tests for all new functionality +- Maintain or improve test coverage +- Run the full test suite before submitting a PR: + + .. code-block:: bash + + make test-cov # Run tests with coverage report + +Documentation +----------- + +- Update documentation for any new features or changes +- Keep docstrings up-to-date with code changes +- Add examples for new functionality + +Commit Messages +------------- + +Follow the `Conventional Commits `_ specification: + +- ``feat:`` - A new feature +- ``fix:`` - A bug fix +- ``docs:`` - Documentation changes +- ``style:`` - Changes that do not affect code logic (formatting, etc.) +- ``refactor:`` - Code changes that neither fix a bug nor add a feature +- ``perf:`` - Performance improvements +- ``test:`` - Adding or correcting tests +- ``build:`` - Changes to build system or dependencies +- ``ci:`` - Changes to CI configuration +- ``chore:`` - Other changes that don't modify source or test files + +Examples: + +.. code-block:: text + + feat: add random greeting generator + fix: correct punctuation in formal greetings + docs: update usage examples in README + test: add test case for empty name validation + +Getting Help +----------- + +If you need help or have questions, you can: + +- Open an issue with the "question" label +- Contact the maintainers directly + +Thank you for contributing! diff --git a/docs/development.rst b/docs/development.rst new file mode 100644 index 0000000..39e5e49 --- /dev/null +++ b/docs/development.rst @@ -0,0 +1,307 @@ +================= +Development Guide +================= + +This guide will help you set up your development environment and understand the workflow for contributing to ``my_python_package``. + +Development Environment Setup +--------------------------- + +Prerequisites +~~~~~~~~~~~~ + +- Python 3.10 or higher +- `Poetry `_ for dependency management +- Git + +Initial Setup +~~~~~~~~~~~~ + +1. Clone the repository: + + .. code-block:: bash + + git clone https://github.com/DiogoRibeiro7/my_python_package.git + cd my_python_package + +2. Install dependencies and set up pre-commit hooks: + + .. code-block:: bash + + # Using the Makefile (recommended) + make setup + + # Alternatively, manually: + poetry install + pre-commit install + +3. Activate the virtual environment: + + .. code-block:: bash + + poetry shell + +Alternative Setup Methods +~~~~~~~~~~~~~~~~~~~~~~~ + +Using Docker +^^^^^^^^^^^ + +The repository includes Docker configuration files: + +.. code-block:: bash + + # Build and run the application + docker-compose up app + + # Run tests + docker-compose up test + + # Run linting + docker-compose up lint + + # Run type checking + docker-compose up type-check + +Using pip +^^^^^^^^ + +If you prefer not to use Poetry: + +.. code-block:: bash + + pip install -e ".[dev]" + # OR + pip install -r dev-requirements.txt + +Development Workflow +------------------ + +Code Quality Tools +~~~~~~~~~~~~~~~~ + +The project uses several tools to ensure code quality: + +.. code-block:: bash + + # Format code with black, isort, and ruff + make format + + # Run linting with ruff + make lint + + # Run type checking with mypy + make type-check + + # Run security checks with bandit + make security + + # Run all checks at once + make lint type-check security + +Testing +~~~~~~ + +.. code-block:: bash + + # Run all tests + make test + + # Run tests with coverage report + make test-cov + + # Run tests in multiple environments + make tox + +Documentation +~~~~~~~~~~~ + +.. code-block:: bash + + # Generate API documentation + make docs + + # Or use the scripts directly + python scripts/generate_docs.py + python scripts/generate_api_docs.py + +Pre-commit Hooks +~~~~~~~~~~~~~~ + +The repository is configured with pre-commit hooks that automatically check your code before committing: + +- black: Code formatting +- isort: Import sorting +- ruff: Linting +- mypy: Type checking +- Various file checks (trailing whitespace, YAML/TOML validation, etc.) + +If any of these checks fail, your commit will be aborted. You can fix the issues and try again, or use ``git commit --no-verify`` to bypass the checks (not recommended). + +Project Structure +--------------- + +.. code-block:: text + + my_python_package/ + β”œβ”€β”€ pyproject.toml # Project metadata, dependencies + β”œβ”€β”€ setup.cfg # Configuration for various tools + β”œβ”€β”€ tox.ini # Multi-environment testing + β”œβ”€β”€ .pre-commit-config.yaml # Pre-commit hooks configuration + β”œβ”€β”€ .editorconfig # Editor configuration + β”œβ”€β”€ Makefile # Common development tasks + β”œβ”€β”€ src/ + β”‚ └── my_python_package/ # Package source code + β”‚ β”œβ”€β”€ __init__.py # Package exports + β”‚ β”œβ”€β”€ __main__.py # Module execution entry point + β”‚ β”œβ”€β”€ core.py # Core greeting functionality + β”‚ β”œβ”€β”€ config.py # Configuration system + β”‚ β”œβ”€β”€ logging.py # Logging system + β”‚ └── cli.py # Command-line interface + β”œβ”€β”€ tests/ # Test directory + β”œβ”€β”€ docs/ # Documentation + └── scripts/ # Utility scripts + +Commit Guidelines +--------------- + +The project follows the `Conventional Commits `_ specification: + +.. code-block:: text + + (): + + [optional body] + + [optional footer(s)] + +Types: + +- ``feat``: A new feature +- ``fix``: A bug fix +- ``docs``: Documentation changes +- ``style``: Changes that do not affect code logic +- ``refactor``: Code changes that neither fix a bug nor add a feature +- ``perf``: Performance improvements +- ``test``: Adding or correcting tests +- ``build``: Changes to build system or dependencies +- ``ci``: Changes to CI configuration +- ``chore``: Other changes that don't modify source or test files + +Examples: + +.. code-block:: text + + feat: add random greeting generator + fix: correct punctuation in formal greetings + docs: update usage examples in README + +Versioning +--------- + +The project uses `Semantic Versioning `_. Version numbers are automatically bumped based on commit messages: + +- ``BREAKING CHANGE`` in the commit message β†’ major version bump +- ``feat:`` prefix β†’ minor version bump +- Otherwise β†’ patch version bump + +Publishing +--------- + +.. code-block:: bash + + # Bump version (done automatically on merge to main) + make bump-patch # 0.1.0 -> 0.1.1 + make bump-minor # 0.1.0 -> 0.2.0 + make bump-major # 0.1.0 -> 1.0.0 + + # Build the package + make build + + # Publish to TestPyPI + make publish-test + + # Publish to PyPI + make publish + +Continuous Integration +------------------- + +The repository uses GitHub Actions for CI/CD: + +- Running tests on multiple Python versions +- Checking code style and quality +- Generating and uploading coverage reports +- Security scanning +- Automatically bumping version numbers +- Publishing to PyPI on release + +.. note:: + The CI pipeline will automatically fail if the code does not pass the tests, linting, or type checking. + +Development Tips +-------------- + +Working with Dependencies +~~~~~~~~~~~~~~~~~~~~~~~ + +To add a new dependency: + +.. code-block:: bash + + # Production dependency + poetry add package-name + + # Development dependency + poetry add --group dev package-name + +To update dependencies: + +.. code-block:: bash + + # Update all dependencies + poetry update + + # Update a specific dependency + poetry update package-name + +Working with Tests +~~~~~~~~~~~~~~~~ + +To run a specific test: + +.. code-block:: bash + + pytest tests/test_core.py::test_hello_default + +To run tests with verbose output: + +.. code-block:: bash + + pytest -v + +To run tests with coverage and see which lines are not covered: + +.. code-block:: bash + + pytest --cov=my_python_package --cov-report=term-missing + +Debugging Tips +~~~~~~~~~~~~ + +For interactive debugging, you can use: + +.. code-block:: python + + import pdb; pdb.set_trace() # Python < 3.7 + breakpoint() # Python >= 3.7 + +Additional Resources +------------------ + +- `Poetry documentation `_ +- `Black documentation `_ +- `Ruff documentation `_ +- `mypy documentation `_ +- `pytest documentation `_ +- `tox documentation `_ diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..bfc0f86 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,93 @@ +=========================== +my_python_package +=========================== + +A minimal but production-ready Python package scaffold configured for publishing to PyPI. + +.. image:: https://img.shields.io/pypi/v/my_python_package.svg + :target: https://pypi.org/project/my_python_package/ + :alt: PyPI version + +.. image:: https://img.shields.io/pypi/pyversions/my_python_package.svg + :target: https://pypi.org/project/my_python_package/ + :alt: Python Versions + +.. image:: https://github.com/DiogoRibeiro7/my_python_package/actions/workflows/test.yml/badge.svg + :target: https://github.com/DiogoRibeiro7/my_python_package/actions/workflows/test.yml + :alt: Tests + +.. image:: https://img.shields.io/badge/coverage-95%25-brightgreen + :target: https://codecov.io/gh/DiogoRibeiro7/my_python_package + :alt: Coverage + +.. image:: https://img.shields.io/badge/License-MIT-yellow.svg + :target: https://opensource.org/licenses/MIT + :alt: License: MIT + +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/psf/black + :alt: Code style: black + +Features +-------- + +- πŸš€ Modern Python packaging with Poetry +- πŸ”§ Configurable greeting functions with multiple formatting options +- πŸ§ͺ Comprehensive testing suite with 100% coverage +- πŸ“Š Continuous Integration workflows for testing, coverage, and releases +- πŸ› οΈ Code quality tools preconfigured (black, ruff, mypy, isort, pre-commit) +- πŸ“ Complete documentation with doctests +- πŸ”„ Automated dependency management and version bumping +- πŸ”’ Security scanning with Bandit and Trivy +- 🧩 Multi-environment testing with tox + +Overview +-------- + +This package demonstrates a properly structured Python project with modern tooling and configuration. +It provides various greeting functions with configurable options for formatting, randomization, and validation. + +.. code-block:: python + + from my_python_package import hello + + # Basic usage + greeting = hello("World") + print(greeting) # Output: Hello, World! + + # With custom greeting + custom = hello("Python", greeting="Hi") + print(custom) # Output: Hi, Python! + +Contents +-------- + +.. toctree:: + :maxdepth: 2 + :caption: User Guide + + installation + usage + cli + +.. toctree:: + :maxdepth: 2 + :caption: API Reference + + api/modules + +.. toctree:: + :maxdepth: 1 + :caption: Development + + contributing + development + code_of_conduct + changelog + +Indices and tables +----------------- + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..779b4fd --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,84 @@ +============ +Installation +============ + +From PyPI +--------- + +The package is available on `PyPI `_, and you can install it using pip: + +.. code-block:: bash + + pip install my_python_package + +Using Poetry +----------- + +If you use `Poetry `_ for dependency management, you can add the package to your project with: + +.. code-block:: bash + + poetry add my_python_package + +From Source +---------- + +To install the package from source, first clone the repository: + +.. code-block:: bash + + git clone https://github.com/DiogoRibeiro7/my_python_package.git + cd my_python_package + +Then install it using one of the following methods: + +Using Poetry (recommended) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + poetry install + +Using pip +~~~~~~~~ + +.. code-block:: bash + + pip install . + +Development Installation +---------------------- + +If you're planning to contribute to the package, you should install it in development mode with all the development dependencies: + +.. code-block:: bash + + # Using Poetry (recommended) + poetry install + + # Set up pre-commit hooks + pre-commit install + + # Or use the Makefile for one-step setup + make setup + +This will install all the required development tools like pytest, black, ruff, mypy, etc. + +Verify Installation +----------------- + +To verify that the package is installed correctly, you can run the following Python code: + +.. code-block:: python + + from my_python_package import hello + + print(hello("World")) # Should output: Hello, World! + +Or use the command-line interface: + +.. code-block:: bash + + my-python-package hello World + +This should output: ``Hello, World!`` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..002908e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,53 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +REM Check if sphinx-build is available +%SPHINXBUILD% --version >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help +if "%1" == "clean" goto clean +if "%1" == "apidoc" goto apidoc +if "%1" == "livehtml" goto livehtml + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:clean +rmdir /s /q %BUILDDIR% +rmdir /s /q api +goto end + +:apidoc +python make_api_docs.py +goto end + +:livehtml +sphinx-autobuild %SOURCEDIR% %BUILDDIR%/html %SPHINXOPTS% %O% --open-browser +goto end + +:end +popd diff --git a/docs/make_api_docs.py b/docs/make_api_docs.py new file mode 100644 index 0000000..1f702e3 --- /dev/null +++ b/docs/make_api_docs.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python +""" +Generate API documentation RST files for Sphinx. + +This script inspects the Python modules in the package and creates +RST files for the API documentation. +""" + +import importlib +import inspect +import os +import pkgutil +import sys +from pathlib import Path +from typing import List, Set + +# Add the project to the Python path +sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +PACKAGE_NAME = "my_python_package" +API_DIR = Path(__file__).parent / "api" + + +def generate_module_rst(module_name: str, output_path: Path) -> None: + """Generate RST file for a module.""" + module = importlib.import_module(module_name) + + # Create the content for the RST file + content = [ + f"{module_name}", + "=" * len(module_name), + "", + f".. automodule:: {module_name}", + " :members:", + " :undoc-members:", + " :show-inheritance:", + "", + ] + + # Get all classes in the module + classes = inspect.getmembers(module, inspect.isclass) + for class_name, class_obj in classes: + if class_obj.__module__ == module_name: + content.extend([ + f"{module_name}.{class_name}", + "-" * len(f"{module_name}.{class_name}"), + "", + f".. autoclass:: {module_name}.{class_name}", + " :members:", + " :undoc-members:", + " :show-inheritance:", + "", + ]) + + # Write the RST file + with open(output_path, "w") as f: + f.write("\n".join(content)) + + +def generate_package_rst(package_name: str, output_path: Path, modules: List[str]) -> None: + """Generate RST file for a package.""" + # Create the content for the RST file + content = [ + f"{package_name}", + "=" * len(package_name), + "", + f".. automodule:: {package_name}", + " :members:", + " :undoc-members:", + " :show-inheritance:", + "", + "Submodules", + "----------", + "", + ".. toctree::", + " :maxdepth: 1", + "", + ] + + # Add modules to the toctree + for module in sorted(modules): + if module != package_name: + module_short = module.split(".")[-1] + content.append(f" {module_short}") + + content.append("") + + # Write the RST file + with open(output_path, "w") as f: + f.write("\n".join(content)) + + +def generate_modules_rst(output_path: Path, packages: List[str]) -> None: + """Generate the modules.rst file that includes all packages.""" + # Create the content for the RST file + content = [ + "API Reference", + "============", + "", + ".. toctree::", + " :maxdepth: 2", + "", + ] + + # Add packages to the toctree + for package in sorted(packages): + package_short = package.split(".")[-1] + content.append(f" {package_short}") + + content.append("") + + # Write the RST file + with open(output_path, "w") as f: + f.write("\n".join(content)) + + +def discover_modules(package_name: str) -> Set[str]: + """Discover all modules in the package.""" + modules = set() + + def walk_packages(pkg_name, prefix=""): + try: + pkg = importlib.import_module(pkg_name) + + # Check if this is a package + if hasattr(pkg, "__path__"): + for _, name, is_pkg in pkgutil.iter_modules(pkg.__path__, pkg_name + "."): + if not name.startswith("_"): # Skip private modules + modules.add(name) + if is_pkg: + walk_packages(name) + except (ImportError, AttributeError): + print(f"Error importing {pkg_name}") + + # Start with the base package + modules.add(package_name) + walk_packages(package_name) + + return modules + + +def main() -> None: + """Generate API documentation RST files.""" + # Create the API directory if it doesn't exist + os.makedirs(API_DIR, exist_ok=True) + + # Discover modules + modules = discover_modules(PACKAGE_NAME) + + # List of packages (modules with submodules) + packages = [] + submodules = {} + + # Group modules by package + for module in modules: + parts = module.split(".") + if len(parts) > 1: + package = ".".join(parts[:-1]) + submodules.setdefault(package, []).append(module) + else: + packages.append(module) + + # Generate RST files for each module + for module in modules: + if module in packages or module in submodules: + continue # Skip packages (they'll be handled separately) + + output_path = API_DIR / f"{module.split('.')[-1]}.rst" + generate_module_rst(module, output_path) + + # Generate RST files for each package + for package, package_modules in submodules.items(): + # Add the package to the modules list for each submodule + package_modules.append(package) + + output_path = API_DIR / f"{package.split('.')[-1]}.rst" + generate_package_rst(package, output_path, package_modules) + + # Also generate RST files for each submodule + for module in package_modules: + if module != package: # Skip the package itself + output_path = API_DIR / f"{module.split('.')[-1]}.rst" + generate_module_rst(module, output_path) + + # Generate the modules.rst file + generate_modules_rst(API_DIR / "modules.rst", packages + list(submodules.keys())) + + print(f"Generated API documentation in {API_DIR}") + + +if __name__ == "__main__": + main() diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..05f76da --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,10 @@ +sphinx>=7.2.6 +sphinx-rtd-theme>=2.0.0 +myst-parser>=2.0.0 +sphinxcontrib-napoleon>=0.7 +sphinx-autodoc-typehints>=1.24.0 +sphinx-autobuild>=2021.3.14 +sphinx-copybutton>=0.5.2 +sphinx-github-changelog>=1.2.0 +sphinx-issues>=3.0.1 +sphinxext-opengraph>=0.8.2 diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..52ec8fc --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,236 @@ +===== +Usage +===== + +This page demonstrates how to use the various features of ``my_python_package``. + +Basic Greeting +------------- + +The most basic function is ``hello()``, which returns a simple greeting: + +.. code-block:: python + + from my_python_package import hello + + # Basic usage + greeting = hello("World") + print(greeting) # Output: Hello, World! + + # With custom greeting + custom = hello("Python", greeting="Hi") + print(custom) # Output: Hi, Python! + +Formatted Greetings +------------------ + +For more control over the output, use ``format_greeting()``: + +.. code-block:: python + + from my_python_package import format_greeting + + # Default formatting + print(format_greeting("World")) # Output: Hello, World! + + # Custom formatting + print(format_greeting( + "Python", + greeting="Welcome", + punctuation="!!!", + uppercase=True + )) # Output: WELCOME, PYTHON!!! + + # Truncate long greetings + print(format_greeting("Very Long Name", max_length=15)) # Output: Hello, Very... + +Multiple Greetings +----------------- + +To greet multiple people at once, use ``create_greeting_list()``: + +.. code-block:: python + + from my_python_package import create_greeting_list + + # Greet multiple people + greetings = create_greeting_list(["Alice", "Bob", "Charlie"]) + for greeting in greetings: + print(greeting) + + # Output: + # Hello, Alice! + # Hello, Bob! + # Hello, Charlie! + +Context-Aware Greetings +---------------------- + +The ``generate_greeting()`` function can adjust the greeting based on the time of day or formality: + +.. code-block:: python + + from my_python_package import generate_greeting + + # Time-based greeting (morning/afternoon/evening) + print(generate_greeting("World", time_based=True)) + # Output varies based on time of day: + # Morning: "Good morning, World!" + # Afternoon: "Good afternoon, World!" + # Evening: "Good evening, World!" + + # Formal greeting + print(generate_greeting("Mrs. Smith", formal=True)) + # Output: "Good day, Mr./Ms. Mrs. Smith!" + + # Both formal and time-based + print(generate_greeting("Mrs. Smith", formal=True, time_based=True)) + # Output depends on time of day, but includes formal title + +Random Greetings +--------------- + +For variety, use ``random_greeting()`` to get a different greeting each time: + +.. code-block:: python + + from my_python_package import random_greeting + + # Get a random greeting + print(random_greeting("World")) # Different greeting each time + +Name Validation +-------------- + +To validate names before using them in greetings, use ``validate_name()``: + +.. code-block:: python + + from my_python_package import validate_name + + # Check if a name is valid + valid, error = validate_name("John") + if valid: + print("Name is valid!") + else: + print(f"Invalid name: {error}") + + # Invalid examples: + validate_name("") # (False, "Name cannot be empty") + validate_name("J") # (False, "Name must be at least 2 characters") + validate_name("John123") # (False, "Name cannot contain numbers or special characters") + +Configuration +------------ + +You can configure default settings for the package: + +.. code-block:: python + + from my_python_package.core import set_default_greeting, set_default_punctuation, add_greeting + from my_python_package.config import config + + # Set default greeting + set_default_greeting("Howdy") + + # Set default punctuation + set_default_punctuation("?") + + # Add a new greeting to the available list + add_greeting("Salutations") + + # Set maximum name length + config.max_name_length = 30 + + # Set formal title + config.formal_title = "Dr. " + + # Get the current configuration + import json + print(json.dumps(config.as_dict(), indent=2)) + +Advanced Examples +--------------- + +Combining Multiple Features +~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can combine multiple features for more complex behavior: + +.. code-block:: python + + from my_python_package import validate_name, format_greeting, hello + + def greet_user(name, formal=False, uppercase=False): + # First validate the name + is_valid, error = validate_name(name) + if not is_valid: + return f"Cannot greet: {error}" + + # Choose appropriate greeting + greeting = "Dear" if formal else "Hi" + + # Format the greeting + return format_greeting( + name, + greeting=greeting, + uppercase=uppercase, + max_length=20 + ) + + # Example usage + print(greet_user("John")) # Output: Hi, John! + print(greet_user("John", formal=True)) # Output: Dear, John! + print(greet_user("John", uppercase=True)) # Output: HI, JOHN! + print(greet_user("J")) # Output: Cannot greet: Name must be at least 2 characters + +Creating a Custom Greeting System +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For more advanced use cases, you can create a custom greeting system: + +.. code-block:: python + + from my_python_package import format_greeting, random_greeting, generate_greeting + import random + + class GreetingSystem: + def __init__(self, default_greeting="Hello"): + self.default_greeting = default_greeting + self.greetings_history = {} + + def greet(self, name, greeting_type="standard"): + # Track greetings per user + if name not in self.greetings_history: + self.greetings_history[name] = [] + + # Generate greeting based on type + if greeting_type == "standard": + result = format_greeting(name, greeting=self.default_greeting) + elif greeting_type == "random": + result = random_greeting(name) + elif greeting_type == "time": + result = generate_greeting(name, time_based=True) + elif greeting_type == "formal": + result = generate_greeting(name, formal=True) + else: + result = format_greeting(name, greeting=greeting_type) + + # Record this greeting + self.greetings_history[name].append(result) + + return result + + def get_greeting_history(self, name): + return self.greetings_history.get(name, []) + + # Example usage + greeter = GreetingSystem() + print(greeter.greet("Alice")) # Standard: Hello, Alice! + print(greeter.greet("Alice", "random")) # Random greeting + print(greeter.greet("Alice", "time")) # Time-based greeting + print(greeter.greet("Bob", "formal")) # Formal: Good day, Mr./Ms. Bob! + print(greeter.greet("Bob", "Welcome")) # Custom: Welcome, Bob! + + # Get history for a user + print(greeter.get_greeting_history("Alice")) # List of all greetings for Alice diff --git a/mkdocs.yaml b/mkdocs.yaml new file mode 100644 index 0000000..67b4e89 --- /dev/null +++ b/mkdocs.yaml @@ -0,0 +1,114 @@ +site_name: my_python_package +site_description: A minimal but production-ready Python package scaffold +site_author: Diogo Ribeiro +site_url: https://my-python-package.readthedocs.io/ + +repo_name: DiogoRibeiro7/my_python_package +repo_url: https://github.com/DiogoRibeiro7/my_python_package +edit_uri: edit/main/docs/ + +theme: + name: material + palette: + # Light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.instant + - navigation.tracking + - navigation.expand + - navigation.indexes + - navigation.top + - search.highlight + - search.share + - content.tabs.link + - content.code.copy + icon: + repo: fontawesome/brands/github + +plugins: + - search + - mkdocstrings: + handlers: + python: + selection: + docstring_style: google + rendering: + show_source: true + show_root_heading: true + show_category_heading: true + show_if_no_docstring: false + - git-revision-date-localized: + enable_creation_date: true + - mkdocs-jupyter: + include_source: true + +markdown_extensions: + - admonition + - attr_list + - def_list + - footnotes + - meta + - md_in_html + - pymdownx.arithmatex: + generic: true + - pymdownx.betterem + - pymdownx.caret + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.magiclink + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + - tables + - toc: + permalink: true + slugify: !!python/name:pymdownx.slugs.uslugify + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/DiogoRibeiro7 + - icon: fontawesome/brands/python + link: https://pypi.org/project/my-python-package/ + +nav: + - Home: index.md + - Installation: installation.md + - Usage: usage.md + - API Reference: api.md + - Development: + - Contributing: contributing.md + - Development Guide: development.md + - Code of Conduct: code_of_conduct.md + - Changelog: changelog.md diff --git a/pyproject.toml b/pyproject.toml index c63c3c0..ef9c4b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,16 @@ license = "MIT" readme = "README.md" packages = [{ include = "my_python_package", from = "src" }] repository = "https://github.com/DiogoRibeiro7/my_python_package" +keywords = ["python", "package", "template"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] [tool.poetry.dependencies] python = ">=3.10" @@ -18,6 +28,28 @@ packaging = "^25.0" pytest-cov = "^5.0.0" mypy = "^1.9.0" ruff = "^0.3.0" +black = "^24.2.0" +isort = "^5.13.2" +pydocstyle = "^6.3.0" +bandit = "^1.7.7" +pytest-mock = "^3.12.0" +pytest-sugar = "^1.0.0" +pre-commit = "^3.6.2" + +[tool.poetry.group.docs] +optional = true + +[tool.poetry.group.docs.dependencies] +sphinx = "^7.2.6" +sphinx-rtd-theme = "^2.0.0" +myst-parser = "^2.0.0" +sphinxcontrib-napoleon = "^0.7" +sphinx-autodoc-typehints = "^1.24.0" +sphinx-autobuild = "^2021.3.14" +sphinx-copybutton = "^0.5.2" +sphinx-github-changelog = "^1.2.0" +sphinx-issues = "^3.0.1" +sphinxext-opengraph = "^0.8.2" [build-system] requires = ["poetry-core"] @@ -30,21 +62,81 @@ warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +strict_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true -# Linting configuration +# Black configuration +[tool.black] +line-length = 100 +target-version = ['py310'] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist +)/ +''' + +# Ruff configuration [tool.ruff] target-version = "py310" line-length = 100 -select = ["E", "F", "B", "I", "N", "UP", "ANN", "D"] -ignore = ["ANN101"] +select = ["E", "F", "B", "I", "N", "UP", "ANN", "D", "S", "BLE", "A", "C4", "T20", "PT", "RET", "SIM"] +ignore = ["ANN101", "D203", "D213"] +unfixable = ["F401"] + +[tool.ruff.pydocstyle] +convention = "google" [tool.ruff.per-file-ignores] -"tests/*" = ["ANN", "D"] +"tests/*" = ["ANN", "D", "S101"] +"*/__init__.py" = ["F401"] + +[tool.ruff.isort] +known-first-party = ["my_python_package"] [tool.pytest.ini_options] minversion = "8.0" addopts = "--cov=my_python_package --cov-report=term-missing" testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", +] + +[tool.coverage.run] +source = ["my_python_package"] +omit = ["tests/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "pass", + "raise ImportError", +] + +[tool.bandit] +exclude_dirs = ["tests", "docs"] +skips = ["B101"] [tool.poetry.scripts] my-python-package = "my_python_package.cli:main" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a07f7d5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# Generated from pyproject.toml - Do not edit directly +# To update, run: +# poetry export -f requirements.txt --without-hashes -o requirements.txt + +my_python_package>=0.3.0 diff --git a/scripts/check_docstring_coverage.py b/scripts/check_docstring_coverage.py new file mode 100644 index 0000000..c0373bb --- /dev/null +++ b/scripts/check_docstring_coverage.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python +""" +Check docstring coverage in the project. + +This script inspects Python modules, classes, and functions to ensure +they have proper docstrings. It reports items that are missing docstrings +and provides an overall coverage percentage. + +Usage: + python scripts/check_docstring_coverage.py [--min-coverage PERCENTAGE] + +Examples: + # Check docstring coverage + python scripts/check_docstring_coverage.py + + # Check and fail if below 80% + python scripts/check_docstring_coverage.py --min-coverage 80 +""" + +import ast +import os +import sys +from argparse import ArgumentParser +from pathlib import Path +from typing import Dict, List, NamedTuple, Optional, Set, Tuple + + +class DocItem(NamedTuple): + """Information about a Python module item that should have a docstring.""" + + name: str + path: str + lineno: int + type: str # "module", "class", or "function" + has_docstring: bool + + +def is_public(name: str) -> bool: + """ + Check if a name is public (not starting with underscore). + + Args: + name: The name to check + + Returns: + True if the name is public, False otherwise + """ + return not name.startswith("_") or ( + name.startswith("__") and name.endswith("__") + ) # Special methods are public + + +def should_have_docstring(node: ast.AST, include_all: bool = False) -> bool: + """ + Determine if a node should have a docstring based on our standards. + + Args: + node: The AST node to check + include_all: Whether to include all items or just public ones + + Returns: + True if the node should have a docstring, False otherwise + """ + if isinstance(node, ast.Module): + return True + if isinstance(node, ast.ClassDef): + return include_all or is_public(node.name) + if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef): + # Skip property setters and simple properties + if hasattr(node, "decorator_list"): + for decorator in node.decorator_list: + if isinstance(decorator, ast.Name) and decorator.id == "property": + if len(node.body) <= 2: # Simple property getter + return False + if isinstance(decorator, ast.Attribute) and decorator.attr == "setter": + return False + if node.name == "__init__" and len(node.body) <= 2: + # Skip simple __init__ methods that just assign attributes + return False + return include_all or is_public(node.name) + return False + + +def get_docstring(node: ast.AST) -> Optional[str]: + """ + Extract docstring from an AST node. + + Args: + node: The AST node to extract docstring from + + Returns: + The docstring if present, None otherwise + """ + try: + if not node.body: + return None + first_node = node.body[0] + if isinstance(first_node, ast.Expr) and isinstance(first_node.value, ast.Str): + return first_node.value.s + return None + except (AttributeError, IndexError): + return None + + +def check_file_docstrings(file_path: Path, include_all: bool = False) -> List[DocItem]: + """ + Check docstring coverage for a Python file. + + Args: + file_path: Path to the Python file + include_all: Whether to check all items or just public ones + + Returns: + List of DocItem instances for each item that should have a docstring + """ + with open(file_path, "r", encoding="utf-8") as f: + try: + tree = ast.parse(f.read(), filename=str(file_path)) + except SyntaxError: + print(f"Syntax error in {file_path}") + return [] + + results: List[DocItem] = [] + + # Check module docstring + module_has_doc = bool(get_docstring(tree)) + if should_have_docstring(tree, include_all): + results.append( + DocItem( + name=file_path.stem, + path=str(file_path), + lineno=1, + type="module", + has_docstring=module_has_doc, + ) + ) + + # Check classes and functions + for node in ast.walk(tree): + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + if should_have_docstring(node, include_all): + item_type = "class" if isinstance(node, ast.ClassDef) else "function" + has_doc = bool(get_docstring(node)) + results.append( + DocItem( + name=node.name, + path=str(file_path), + lineno=node.lineno, + type=item_type, + has_docstring=has_doc, + ) + ) + + # Check methods inside classes + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if should_have_docstring(item, include_all): + has_doc = bool(get_docstring(item)) + results.append( + DocItem( + name=f"{node.name}.{item.name}", + path=str(file_path), + lineno=item.lineno, + type="method", + has_docstring=has_doc, + ) + ) + + return results + + +def check_directory_docstrings( + directory: Path, include_all: bool = False, exclude: Optional[Set[str]] = None +) -> Tuple[List[DocItem], Dict[str, float]]: + """ + Check docstring coverage for all Python files in a directory. + + Args: + directory: Directory to check + include_all: Whether to check all items or just public ones + exclude: Set of directory names to exclude + + Returns: + Tuple of (all DocItems, stats by type) + """ + if exclude is None: + exclude = { + "__pycache__", + ".git", + ".github", + "venv", + ".venv", + "build", + "dist", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + } + + all_results: List[DocItem] = [] + for root, dirs, files in os.walk(directory): + # Skip excluded directories + dirs[:] = [d for d in dirs if d not in exclude] + + for file in files: + if file.endswith(".py"): + file_path = Path(root) / file + results = check_file_docstrings(file_path, include_all) + all_results.extend(results) + + # Calculate statistics + stats: Dict[str, Dict[str, int]] = {"module": {}, "class": {}, "function": {}, "method": {}} + for item in all_results: + stats.setdefault(item.type, {}) + stats[item.type].setdefault("total", 0) + stats[item.type].setdefault("with_docstring", 0) + + stats[item.type]["total"] += 1 + if item.has_docstring: + stats[item.type]["with_docstring"] += 1 + + # Calculate percentages + percentages: Dict[str, float] = {} + total_items = 0 + total_with_docs = 0 + + for item_type, counts in stats.items(): + if counts.get("total", 0) > 0: + percentage = (counts.get("with_docstring", 0) / counts["total"]) * 100 + percentages[item_type] = percentage + total_items += counts["total"] + total_with_docs += counts.get("with_docstring", 0) + + if total_items > 0: + percentages["overall"] = (total_with_docs / total_items) * 100 + else: + percentages["overall"] = 100.0 + + return all_results, percentages + + +def print_report( + items: List[DocItem], stats: Dict[str, float], show_documented: bool = False +) -> None: + """ + Print a docstring coverage report. + + Args: + items: List of DocItem instances + stats: Statistics dictionary with percentages + show_documented: Whether to show items with docstrings + """ + # Print missing docstrings + missing = [item for item in items if not item.has_docstring] + if missing: + print("\nMissing docstrings:") + print("-" * 80) + for item in sorted(missing, key=lambda x: (x.path, x.lineno)): + print(f"{item.path}:{item.lineno} - {item.type} '{item.name}'") + else: + print("\nAll items have docstrings!") + + # Print documented items if requested + if show_documented: + documented = [item for item in items if item.has_docstring] + if documented: + print("\nDocumented items:") + print("-" * 80) + for item in sorted(documented, key=lambda x: (x.path, x.lineno)): + print(f"{item.path}:{item.lineno} - {item.type} '{item.name}'") + + # Print statistics + print("\nDocstring coverage statistics:") + print("-" * 80) + for item_type, percentage in sorted(stats.items()): + if item_type != "overall": + print(f"{item_type.capitalize()}: {percentage:.1f}%") + + # Print overall percentage + print("\n" + "=" * 80) + print(f"Overall docstring coverage: {stats.get('overall', 0):.1f}%") + print("=" * 80) + + +def main(): + """Run the docstring coverage check.""" + parser = ArgumentParser(description="Check docstring coverage") + parser.add_argument( + "--dir", type=str, default="src", help="Directory to check (default: src)" + ) + parser.add_argument( + "--include-all", + action="store_true", + help="Include private functions and classes (starting with _)", + ) + parser.add_argument( + "--show-documented", + action="store_true", + help="Show items with docstrings in the report", + ) + parser.add_argument( + "--min-coverage", + type=float, + default=0, + help="Minimum required docstring coverage percentage", + ) + args = parser.parse_args() + + directory = Path(args.dir) + if not directory.exists() or not directory.is_dir(): + print(f"Error: {args.dir} is not a valid directory") + return 1 + + items, stats = check_directory_docstrings(directory, args.include_all) + print_report(items, stats, args.show_documented) + + # Check minimum coverage requirement + overall_coverage = stats.get("overall", 0) + if args.min_coverage > 0 and overall_coverage < args.min_coverage: + print( + f"\nError: Docstring coverage ({overall_coverage:.1f}%) " + f"is below the minimum requirement ({args.min_coverage}%)" + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_docstrings_coverage.py b/scripts/check_docstrings_coverage.py new file mode 100644 index 0000000..a167e9a --- /dev/null +++ b/scripts/check_docstrings_coverage.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python +""" +Check docstring coverage in the project. + +This script inspects Python modules, classes, and functions to ensure +they have proper docstrings. It reports items that are missing docstrings +and provides an overall coverage percentage. + +Usage: + python scripts/check_docstring_coverage.py [--min-coverage PERCENTAGE] + +Examples: + # Check docstring coverage + python scripts/check_docstring_coverage.py + + # Check and fail if below 80% + python scripts/check_docstring_coverage.py --min-coverage 80 +""" + +import ast +import os +import sys +from argparse import ArgumentParser +from pathlib import Path +from typing import Dict, List, NamedTuple, Optional, Set, Tuple + + +class DocItem(NamedTuple): + """Information about a Python module item that should have a docstring.""" + + name: str + path: str + lineno: int + type: str # "module", "class", or "function" + has_docstring: bool + + +def is_public(name: str) -> bool: + """ + Check if a name is public (not starting with underscore). + + Args: + name: The name to check + + Returns: + True if the name is public, False otherwise + """ + return not name.startswith("_") or ( + name.startswith("__") and name.endswith("__") + ) # Special methods are public + + +def should_have_docstring(node: ast.AST, include_all: bool = False) -> bool: + """ + Determine if a node should have a docstring based on our standards. + + Args: + node: The AST node to check + include_all: Whether to include all items or just public ones + + Returns: + True if the node should have a docstring, False otherwise + """ + if isinstance(node, ast.Module): + return True + if isinstance(node, ast.ClassDef): + return include_all or is_public(node.name) + if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef): + # Skip property setters and simple properties + if hasattr(node, "decorator_list"): + for decorator in node.decorator_list: + if isinstance(decorator, ast.Name) and decorator.id == "property": + if len(node.body) <= 2: # Simple property getter + return False + if isinstance(decorator, ast.Attribute) and decorator.attr == "setter": + return False + if node.name == "__init__" and len(node.body) <= 2: + # Skip simple __init__ methods that just assign attributes + return False + return include_all or is_public(node.name) + return False + + +def get_docstring(node: ast.AST) -> Optional[str]: + """ + Extract docstring from an AST node. + + Args: + node: The AST node to extract docstring from + + Returns: + The docstring if present, None otherwise + """ + try: + if not node.body: + return None + first_node = node.body[0] + if isinstance(first_node, ast.Expr) and isinstance(first_node.value, ast.Str): + if isinstance(first_node, ast.Expr): + if ( + isinstance(first_node.value, ast.Str) + or (isinstance(first_node.value, ast.Constant) and isinstance(first_node.value.value, str)) + ): + return first_node.value.s if hasattr(first_node.value, "s") else first_node.value.value + return None + except (AttributeError, IndexError): + return None + + +def check_file_docstrings(file_path: Path, include_all: bool = False) -> List[DocItem]: + """ + Check docstring coverage for a Python file. + + Args: + file_path: Path to the Python file + include_all: Whether to check all items or just public ones + + Returns: + List of DocItem instances for each item that should have a docstring + """ + with open(file_path, "r", encoding="utf-8") as f: + try: + tree = ast.parse(f.read(), filename=str(file_path)) + except SyntaxError: + print(f"Syntax error in {file_path}") + return [] + + results: List[DocItem] = [] + + # Check module docstring + module_has_doc = bool(get_docstring(tree)) + if should_have_docstring(tree, include_all): + results.append( + DocItem( + name=file_path.stem, + path=str(file_path), + lineno=1, + type="module", + has_docstring=module_has_doc, + ) + ) + + # Check classes and functions + for node in ast.walk(tree): + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + if should_have_docstring(node, include_all): + item_type = "class" if isinstance(node, ast.ClassDef) else "function" + has_doc = bool(get_docstring(node)) + results.append( + DocItem( + name=node.name, + path=str(file_path), + lineno=node.lineno, + type=item_type, + has_docstring=has_doc, + ) + ) + + # Check methods inside classes + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if should_have_docstring(item, include_all): + has_doc = bool(get_docstring(item)) + results.append( + DocItem( + name=f"{node.name}.{item.name}", + path=str(file_path), + lineno=item.lineno, + type="method", + has_docstring=has_doc, + ) + ) + + return results + + +def check_directory_docstrings( + directory: Path, include_all: bool = False, exclude: Optional[Set[str]] = None +) -> Tuple[List[DocItem], Dict[str, float]]: + """ + Check docstring coverage for all Python files in a directory. + + Args: + directory: Directory to check + include_all: Whether to check all items or just public ones + exclude: Set of directory names to exclude + + Returns: + Tuple of (all DocItems, stats by type) + """ + if exclude is None: + exclude = { + "__pycache__", + ".git", + ".github", + "venv", + ".venv", + "build", + "dist", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + } + + all_results: List[DocItem] = [] + for root, dirs, files in os.walk(directory): + # Skip excluded directories + dirs[:] = [d for d in dirs if d not in exclude] + + for file in files: + if file.endswith(".py"): + file_path = Path(root) / file + results = check_file_docstrings(file_path, include_all) + all_results.extend(results) + + # Calculate statistics + stats: Dict[str, Dict[str, int]] = {"module": {}, "class": {}, "function": {}, "method": {}} + for item in all_results: + stats.setdefault(item.type, {}) + stats[item.type].setdefault("total", 0) + stats[item.type].setdefault("with_docstring", 0) + + stats[item.type]["total"] += 1 + if item.has_docstring: + stats[item.type]["with_docstring"] += 1 + + # Calculate percentages + percentages: Dict[str, float] = {} + total_items = 0 + total_with_docs = 0 + + for item_type, counts in stats.items(): + if counts.get("total", 0) > 0: + percentage = (counts.get("with_docstring", 0) / counts["total"]) * 100 + percentages[item_type] = percentage + total_items += counts["total"] + total_with_docs += counts.get("with_docstring", 0) + + if total_items > 0: + percentages["overall"] = (total_with_docs / total_items) * 100 + else: + percentages["overall"] = 100.0 + + return all_results, percentages + + +def print_report( + items: List[DocItem], stats: Dict[str, float], show_documented: bool = False +) -> None: + """ + Print a docstring coverage report. + + Args: + items: List of DocItem instances + stats: Statistics dictionary with percentages + show_documented: Whether to show items with docstrings + """ + # Print missing docstrings + missing = [item for item in items if not item.has_docstring] + if missing: + print("\nMissing docstrings:") + print("-" * 80) + for item in sorted(missing, key=lambda x: (x.path, x.lineno)): + print(f"{item.path}:{item.lineno} - {item.type} '{item.name}'") + else: + print("\nAll items have docstrings!") + + # Print documented items if requested + if show_documented: + documented = [item for item in items if item.has_docstring] + if documented: + print("\nDocumented items:") + print("-" * 80) + for item in sorted(documented, key=lambda x: (x.path, x.lineno)): + print(f"{item.path}:{item.lineno} - {item.type} '{item.name}'") + + # Print statistics + print("\nDocstring coverage statistics:") + print("-" * 80) + for item_type, percentage in sorted(stats.items()): + if item_type != "overall": + print(f"{item_type.capitalize()}: {percentage:.1f}%") + + # Print overall percentage + print("\n" + "=" * 80) + print(f"Overall docstring coverage: {stats.get('overall', 0):.1f}%") + print("=" * 80) + + +def main(): + """Run the docstring coverage check.""" + parser = ArgumentParser(description="Check docstring coverage") + parser.add_argument( + "--dir", type=str, default="src", help="Directory to check (default: src)" + ) + parser.add_argument( + "--include-all", + action="store_true", + help="Include private functions and classes (starting with _)", + ) + parser.add_argument( + "--show-documented", + action="store_true", + help="Show items with docstrings in the report", + ) + parser.add_argument( + "--min-coverage", + type=float, + default=0, + help="Minimum required docstring coverage percentage", + ) + args = parser.parse_args() + + directory = Path(args.dir) + if not directory.exists() or not directory.is_dir(): + print(f"Error: {args.dir} is not a valid directory") + return 1 + + items, stats = check_directory_docstrings(directory, args.include_all) + print_report(items, stats, args.show_documented) + + # Check minimum coverage requirement + overall_coverage = stats.get("overall", 0) + if args.min_coverage > 0 and overall_coverage < args.min_coverage: + print( + f"\nError: Docstring coverage ({overall_coverage:.1f}%) " + f"is below the minimum requirement ({args.min_coverage}%)" + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_api_docs.py b/scripts/generate_api_docs.py new file mode 100644 index 0000000..240ae2e --- /dev/null +++ b/scripts/generate_api_docs.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python +""" +Generate API documentation for the package using pdoc. + +This script automatically generates API documentation from docstrings in a more +structured way than the simple generate_docs.py script. It includes special +handling for modules, classes, and functions, and creates a more comprehensive +documentation structure. + +Usage: + python scripts/generate_api_docs.py [--format {html,markdown}] [--output-dir DIRECTORY] +""" + +import argparse +import importlib +import inspect +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Dict, List, Literal, Optional, Set, Tuple, Union + + +def check_pdoc_installed() -> bool: + """ + Check if pdoc is installed. + + Returns: + True if pdoc is installed, False otherwise + """ + try: + importlib.util.find_spec("pdoc") + return True + except ImportError: + print("pdoc is not installed. Installing it now...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "pdoc"]) + return True + except subprocess.CalledProcessError: + print("Failed to install pdoc. Please install it manually: pip install pdoc") + return False + + +def get_module_structure( + package_name: str, + exclude_private: bool = True, + exclude_dirs: Optional[Set[str]] = None +) -> Dict[str, List[str]]: + """ + Get the structure of a package, including modules and subpackages. + + Args: + package_name: Name of the package to document + exclude_private: Whether to exclude private modules (starting with _) + exclude_dirs: Set of directory names to exclude + + Returns: + Dictionary mapping package/subpackage names to lists of module names + """ + if exclude_dirs is None: + exclude_dirs = {"__pycache__", "tests", "examples"} + + try: + package = importlib.import_module(package_name) + except ImportError: + print(f"Could not import {package_name}. Make sure it is installed.") + return {} + + # Find the package directory + if not hasattr(package, "__file__"): + print(f"Package {package_name} has no __file__ attribute.") + return {} + + package_dir = Path(package.__file__).parent + structure: Dict[str, List[str]] = {package_name: []} + + # Find all Python files in the package directory + for root, dirs, files in os.walk(package_dir): + # Skip excluded directories + dirs[:] = [d for d in dirs if d not in exclude_dirs] + + # Calculate the module path relative to the package + rel_path = Path(root).relative_to(package_dir.parent) + module_path = str(rel_path).replace(os.sep, ".") + + # Skip private modules if requested + if exclude_private and any(part.startswith("_") for part in module_path.split(".")): + continue + + # Find Python files in this directory + py_files = [f for f in files if f.endswith(".py") and not (exclude_private and f.startswith("_"))] + if py_files: + # Add this subpackage if it's not already in the structure + if module_path not in structure: + structure[module_path] = [] + + # Add modules to the subpackage + for py_file in py_files: + if py_file == "__init__.py": + continue + module_name = f"{module_path}.{py_file[:-3]}" + structure[module_path].append(module_name) + + return structure + + +def create_module_doc_file( + module_name: str, + output_dir: Path, + format_type: Literal["html", "markdown"] = "markdown" +) -> Path: + """ + Create documentation for a single module. + + Args: + module_name: Name of the module to document + output_dir: Directory to write documentation + format_type: Output format (html or markdown) + + Returns: + Path to the generated documentation file + """ + # Create output directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + # Determine output file extension + ext = ".html" if format_type == "html" else ".md" + + # Generate output file path + module_parts = module_name.split(".") + output_path = output_dir + for part in module_parts[:-1]: + output_path = output_path / part + os.makedirs(output_path, exist_ok=True) + + output_file = output_path / f"{module_parts[-1]}{ext}" + + # Call pdoc to generate documentation + cmd = [ + sys.executable, + "-m", + "pdoc", + f"--output-dir={output_path}", + ] + + if format_type == "html": + cmd.append("--html") + + cmd.append(module_name) + + try: + subprocess.check_call(cmd) + return output_file + except subprocess.CalledProcessError as e: + print(f"Error generating documentation for {module_name}: {e}") + return output_file + + +def create_index_file( + structure: Dict[str, List[str]], + output_dir: Path, + format_type: Literal["html", "markdown"] = "markdown", + package_name: str = "my_python_package" +) -> None: + """ + Create an index file that links to all the module documentation. + + Args: + structure: Package structure from get_module_structure() + output_dir: Directory to write documentation + format_type: Output format (html or markdown) + package_name: Name of the package + """ + # Determine output file and content type + if format_type == "html": + index_file = output_dir / "index.html" + template = """ + + + + + {package} API Documentation + + + +

{package} API Documentation

+

This is the API documentation for the {package} package.

+ +

Package Structure

+ {content} + + +""" + package_content = "
    \n" + for package, modules in sorted(structure.items()): + if package == package_name: + package_link = f"./{package.split('.')[-1]}/index.html" + package_content += f'
  • {package}
  • \n' + else: + package_parts = package.split(".") + package_link = f"./{'/'.join(package_parts)}/index.html" + package_content += f'
  • {package}
  • \n' + + if modules: + package_content += '
      \n' + for module in sorted(modules): + module_parts = module.split(".") + module_link = f"./{'/'.join(module_parts[:-1])}/{module_parts[-1]}.html" + package_content += f'
    • {module}
    • \n' + package_content += '
    \n' + + package_content += "
" + content = template.format(package=package_name, content=package_content) + else: + # Markdown format + index_file = output_dir / "README.md" + content = f"# {package_name} API Documentation\n\n" + content += f"This is the API documentation for the {package_name} package.\n\n" + content += "## Package Structure\n\n" + + for package, modules in sorted(structure.items()): + if package == package_name: + package_link = f"./{package.split('.')[-1]}/index.html" + content += f"- [{package}]({package_link})\n" + else: + package_parts = package.split(".") + package_link = f"./{'/'.join(package_parts)}/index.html" + content += f"- [{package}]({package_link})\n" + + if modules: + for module in sorted(modules): + module_parts = module.split(".") + module_link = f"./{'/'.join(module_parts[:-1])}/{module_parts[-1]}.html" + content += f" - [{module}]({module_link})\n" + + # Write the index file + with open(index_file, "w") as f: + f.write(content) + + +def generate_docs( + format_type: Literal["html", "markdown"] = "html", + output_dir: Optional[Path] = None, + package_name: str = "my_python_package", +) -> bool: + """ + Generate documentation using pdoc. + + Args: + format_type: Output format (html or markdown) + output_dir: Directory to output documentation (defaults to 'docs') + package_name: Name of the package to document + + Returns: + True if documentation was generated successfully, False otherwise + """ + # Get the project root directory + root_dir = Path(__file__).parent.parent.absolute() + + # Define the output directory for docs + docs_dir = output_dir or root_dir / "docs" / "api" + + # Create docs directory if it doesn't exist + os.makedirs(docs_dir, exist_ok=True) + + # Clear previous docs + for item in docs_dir.iterdir(): + if item.is_dir(): + shutil.rmtree(item) + else: + if not item.name.startswith("."): # Preserve hidden files + item.unlink() + + # Import the package to ensure it's in sys.modules + try: + importlib.import_module(package_name) + except ImportError: + print(f"Could not import {package_name}. Make sure it is installed.") + return False + + # Get the package structure + structure = get_module_structure(package_name) + if not structure: + print(f"Could not determine the structure of {package_name}.") + return False + + # Generate documentation using pdoc + print(f"Generating {format_type} documentation for {package_name}...") + + # Process each module + for package, modules in structure.items(): + # Generate documentation for the package itself + create_module_doc_file(package, docs_dir, format_type) + + # Generate documentation for each module + for module in modules: + create_module_doc_file(module, docs_dir, format_type) + + # Create an index file + create_index_file(structure, docs_dir, format_type, package_name) + + print(f"Documentation generated successfully in {docs_dir}") + return True + + +def parse_args() -> argparse.Namespace: + """ + Parse command line arguments. + + Returns: + Parsed arguments + """ + parser = argparse.ArgumentParser(description="Generate API documentation") + parser.add_argument( + "--format", + choices=["html", "markdown"], + default="html", + help="Output format (default: html)", + ) + parser.add_argument( + "--output-dir", + type=Path, + help="Output directory (default: docs/api/)", + ) + parser.add_argument( + "--package", + default="my_python_package", + help="Package name to document (default: my_python_package)", + ) + return parser.parse_args() + + +def main() -> int: + """ + Main function. + + Returns: + Exit code (0 for success, 1 for failure) + """ + args = parse_args() + + if not check_pdoc_installed(): + return 1 + + format_type: Literal["html", "markdown"] = "html" + if args.format == "markdown": + format_type = "markdown" + + if not generate_docs( + format_type=format_type, + output_dir=args.output_dir, + package_name=args.package, + ): + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f31143a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,35 @@ +[bdist_wheel] +universal = 1 + +[aliases] +test = pytest + +[pydocstyle] +convention = google +add-ignore = D107 +match-dir = (?!tests)(?!docs)(?!examples)[^\.].* + +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test +python_functions = test_* +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + +[coverage:run] +source = my_python_package +omit = tests/* + +[coverage:report] +exclude_lines = + pragma: no cover + def __repr__ + if self.debug: + raise NotImplementedError + if __name__ == .__main__.: + pass + raise ImportError +fail_under = 80 +show_missing = True diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..f15d622 --- /dev/null +++ b/tox.ini @@ -0,0 +1,43 @@ +[tox] +isolated_build = True +envlist = py310, py311, py312, lint, type, docs + +[testenv] +deps = + pytest + pytest-cov + pytest-mock + pytest-sugar +commands = + pytest {posargs:tests} --cov=my_python_package --cov-report=term-missing + +[testenv:lint] +deps = + ruff + black + isort +commands = + ruff check src tests + black --check --diff src tests + isort --check-only --diff src tests + +[testenv:type] +deps = + mypy + types-all +commands = + mypy src tests + +[testenv:format] +deps = + black + isort +commands = + black src tests + isort src tests + +[testenv:docs] +deps = + pdoc +commands = + pdoc --html --output-dir docs src/my_python_package