diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 79a76fd..8a5afb3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -24,7 +24,7 @@ How would users interact with this feature? If applicable, provide example code ```python # Example code demonstrating how the feature would be used -from my_python_package import new_feature +from greeting_toolkit import new_feature new_feature.do_something() ``` diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 3bc36ab..91a0ba5 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -34,7 +34,7 @@ jobs: - name: Run tests with coverage run: | - poetry run pytest --cov=my_python_package --cov-report=xml --cov-report=term + poetry run pytest --cov=greeting_toolkit --cov-report=xml --cov-report=term - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f1ad68e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,33 @@ +name: Deploy Documentation + +on: + push: + branches: ["main"] + workflow_dispatch: + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - 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 --with docs + - name: Build docs + run: | + poetry run make -C docs html + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs/_build/html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c29d56..fcb3929 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,8 +205,8 @@ jobs: # Update the links at the bottom if grep -q "\[unreleased\]:" CHANGELOG.md; then - sed -i "s|\[unreleased\]:.*|[unreleased]: https://github.com/DiogoRibeiro7/my_python_package/compare/v${{ steps.extract_version.outputs.version }}...HEAD|" CHANGELOG.md - sed -i "/\[unreleased\]:/a [{{ steps.extract_version.outputs.version }}]: https://github.com/DiogoRibeiro7/my_python_package/compare/v$(git describe --tags --abbrev=0 ${{ github.ref_name }}^)...v${{ steps.extract_version.outputs.version }}" CHANGELOG.md + sed -i "s|\[unreleased\]:.*|[unreleased]: https://github.com/DiogoRibeiro7/greeting-toolkit/compare/v${{ steps.extract_version.outputs.version }}...HEAD|" CHANGELOG.md + sed -i "/\[unreleased\]:/a [{{ steps.extract_version.outputs.version }}]: https://github.com/DiogoRibeiro7/greeting-toolkit/compare/v$(git describe --tags --abbrev=0 ${{ github.ref_name }}^)...v${{ steps.extract_version.outputs.version }}" CHANGELOG.md fi fi fi @@ -215,4 +215,4 @@ jobs: if: success() run: | echo "โœ… Package v${{ steps.extract_version.outputs.version }} has been released to PyPI!" - echo "๐Ÿ“ฆ https://pypi.org/project/my-python-package/${{ steps.extract_version.outputs.version }}/" + echo "๐Ÿ“ฆ https://pypi.org/project/greeting-toolkit/${{ steps.extract_version.outputs.version }}/" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e2fca88..159b5b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: - name: Run tests with coverage run: | - poetry run pytest --cov=my_python_package --cov-report=xml + poetry run pytest --cov=greeting_toolkit --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bd8932..dedf755 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,11 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + exclude: ^mkdocs\.yaml$ - id: check-toml - id: check-added-large-files - id: check-ast @@ -15,38 +16,42 @@ repos: args: [--fix=lf] - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 6.0.1 hooks: - id: isort args: [--profile, black, --filter-files] - repo: https://github.com/psf/black - rev: 24.2.0 + rev: 25.1.0 hooks: - id: black args: [--line-length=100] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.0 + rev: v0.12.9 hooks: - id: ruff args: [--fix] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 + rev: v1.17.1 hooks: - id: mypy - additional_dependencies: [types-all] + files: ^src/ + # Use only the required typing stubs instead of the heavy types-all package. + additional_dependencies: + # Stub package for setuptools (includes pkg_resources types) + - types-setuptools - repo: https://github.com/PyCQA/bandit - rev: 1.7.7 + rev: 1.8.6 hooks: - id: bandit args: ["-c", "pyproject.toml"] exclude: "tests/" - repo: https://github.com/asottile/pyupgrade - rev: v3.15.1 + rev: v3.20.0 hooks: - id: pyupgrade args: [--py310-plus] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e23127..23f043e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Fixed GitHub Actions permissions for the code coverage workflow +### Migration + +- Renamed package from `my_python_package` to `greeting-toolkit`. Update imports to `greeting_toolkit` and CLI usage to `greeting-toolkit`. + ## [0.1.1] - 2025-08-14 ### Added @@ -48,6 +52,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Basic project structure - MIT License -[0.1.0]: https://github.com/DiogoRibeiro7/my_python_package/releases/tag/v0.1.0 -[0.1.1]: https://github.com/DiogoRibeiro7/my_python_package/compare/v0.1.0...v0.1.1 -[unreleased]: https://github.com/DiogoRibeiro7/my_python_package/compare/v0.1.1...HEAD +[0.1.0]: https://github.com/DiogoRibeiro7/greeting-toolkit/releases/tag/v0.1.0 +[0.1.1]: https://github.com/DiogoRibeiro7/greeting-toolkit/compare/v0.1.0...v0.1.1 +[unreleased]: https://github.com/DiogoRibeiro7/greeting-toolkit/compare/v0.1.1...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f51de78..7bc10e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,8 @@ -# Contributing to my_python_package +# Contributing to greeting-toolkit -Thank you for considering contributing to my_python_package! This document provides guidelines and instructions for contributing. +Thank you for considering contributing to greeting-toolkit! This document provides guidelines and instructions for contributing. + +> **Migration note:** The project was renamed from `my_python_package` to `greeting-toolkit`. Update imports to `greeting_toolkit` and CLI usage to `greeting-toolkit`. ## Code of Conduct @@ -43,8 +45,8 @@ For feature requests, please use the feature request template. Include: 1. Clone the repository: ```bash - git clone https://github.com/DiogoRibeiro7/my_python_package.git - cd my_python_package + git clone https://github.com/DiogoRibeiro7/greeting-toolkit.git + cd greeting-toolkit ``` 2. Set up the development environment: diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index f388a5b..8208265 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,6 +1,6 @@ # Contributors -This file lists the contributors to the `my_python_package` project. +This file lists the contributors to the `greeting-toolkit` project. ## Core Contributors @@ -17,7 +17,7 @@ Template for new contributors: ## How to Contribute -Thank you for considering contributing to `my_python_package`! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for details on how to contribute to this project. +Thank you for considering contributing to `greeting-toolkit`! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for details on how to contribute to this project. ## Contributor License Agreement diff --git a/Dockerfile b/Dockerfile index a18fa7b..252ce3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,4 +40,4 @@ RUN poetry install ENTRYPOINT ["poetry", "run"] # Default command -CMD ["python", "-m", "my_python_package"] +CMD ["python", "-m", "greeting_toolkit"] diff --git a/Makefile b/Makefile index 662f7cb..69a3f4c 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ test: poetry run pytest test-cov: - poetry run pytest --cov=my_python_package --cov-report=term-missing + poetry run pytest --cov=greeting_toolkit --cov-report=term-missing tox: poetry run tox diff --git a/README.md b/README.md index b491091..1331c60 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ -# my_python_package +# greeting-toolkit -[![PyPI version](https://img.shields.io/pypi/v/my_python_package.svg) ![Docstring Coverage](https://img.shields.io/badge/docstring%20coverage-93.1%25-brightgreen)](https://pypi.org/project/my_python_package/) -[![Python Versions](https://img.shields.io/pypi/pyversions/my_python_package.svg)](https://pypi.org/project/my_python_package/) -[![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) +[![PyPI version](https://img.shields.io/pypi/v/greeting-toolkit.svg)](https://pypi.org/project/greeting-toolkit/) +![Docstring Coverage](https://img.shields.io/badge/docstring%20coverage-93.1%25-brightgreen) +[![Python Versions](https://img.shields.io/pypi/pyversions/greeting-toolkit.svg)](https://pypi.org/project/greeting-toolkit/) +[![Tests](https://github.com/DiogoRibeiro7/greeting-toolkit/actions/workflows/test.yml/badge.svg)](https://github.com/DiogoRibeiro7/greeting-toolkit/actions/workflows/test.yml) +[![Coverage](https://codecov.io/gh/DiogoRibeiro7/greeting-toolkit/branch/main/graph/badge.svg)](https://codecov.io/gh/DiogoRibeiro7/greeting-toolkit) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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) @@ -11,13 +12,13 @@ [![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). +**greeting-toolkit** is a minimal but production-ready Python package scaffold configured for publishing to [PyPI](https://pypi.org). ## Features - ๐Ÿš€ Modern Python packaging with Poetry - ๐Ÿ”ง Configurable greeting functions with multiple formatting options -- ๐Ÿงช Comprehensive testing suite with 100% coverage +- ๐Ÿงช Comprehensive testing suite with over 95% coverage - ๐Ÿ“Š Continuous Integration workflows for testing, coverage, and releases - ๐Ÿ› ๏ธ Code quality tools preconfigured (black, ruff, mypy, isort, pre-commit) - ๐Ÿ“ Complete documentation with doctests @@ -31,18 +32,18 @@ A minimal but production-ready Python package scaffold configured for publishing ```bash # Using pip -pip install my_python_package +pip install greeting-toolkit # Using Poetry -poetry add my_python_package +poetry add greeting-toolkit ``` ### For Development ```bash # Clone the repository -git clone https://github.com/DiogoRibeiro7/my_python_package.git -cd my_python_package +git clone https://github.com/DiogoRibeiro7/greeting-toolkit.git +cd greeting-toolkit # Using Poetry (recommended) poetry install @@ -59,7 +60,7 @@ make setup ### Basic Greeting ```python -from my_python_package import hello +from greeting_toolkit import hello # Basic usage greeting = hello("World") @@ -73,7 +74,7 @@ print(custom) # Output: Hi, Python! ### Formatted Greetings ```python -from my_python_package import format_greeting +from greeting_toolkit import format_greeting # Default formatting print(format_greeting("World")) # Output: Hello, World! @@ -93,7 +94,7 @@ print(format_greeting("Very Long Name", max_length=15)) # Output: Hello, Very.. ### Multiple Greetings ```python -from my_python_package import create_greeting_list +from greeting_toolkit import create_greeting_list # Greet multiple people greetings = create_greeting_list(["Alice", "Bob", "Charlie"]) @@ -104,7 +105,7 @@ for greeting in greetings: ### Context-Aware Greetings ```python -from my_python_package import generate_greeting +from greeting_toolkit import generate_greeting # Time-based greeting (morning/afternoon/evening) print(generate_greeting("World", time_based=True)) @@ -116,7 +117,7 @@ print(generate_greeting("Mrs. Smith", formal=True)) ### Random Greetings ```python -from my_python_package import random_greeting +from greeting_toolkit import random_greeting # Get a random greeting print(random_greeting("World")) # Different greeting each time @@ -128,19 +129,19 @@ The package also provides a command-line interface: ```bash # Basic greeting -my-python-package hello World +greeting-toolkit hello World # Random greeting -my-python-package random World +greeting-toolkit random World # Time-based greeting -my-python-package time World --formal +greeting-toolkit time World --formal # Multiple names -my-python-package multi Alice Bob Charlie --greeting "Greetings" +greeting-toolkit multi Alice Bob Charlie --greeting "Greetings" # Formatted greeting -my-python-package format World --greeting "Welcome" --uppercase --max-length 15 +greeting-toolkit format World --greeting "Welcome" --uppercase --max-length 15 ``` ## Development @@ -276,7 +277,7 @@ The repository includes GitHub Actions for: ## Project Structure ```text -my_python_package/ +greeting_toolkit/ โ”œโ”€โ”€ pyproject.toml # Project metadata, dependencies โ”œโ”€โ”€ README.md # Project overview โ”œโ”€โ”€ LICENSE # MIT license @@ -294,7 +295,7 @@ my_python_package/ โ”œโ”€โ”€ requirements.txt # Dependencies for simple installation โ”œโ”€โ”€ dev-requirements.txt # Development dependencies โ”œโ”€โ”€ src/ -โ”‚ โ””โ”€โ”€ my_python_package/ # Package source code +โ”‚ โ””โ”€โ”€ greeting_toolkit/ # Package source code โ”‚ โ”œโ”€โ”€ __init__.py # Package exports โ”‚ โ”œโ”€โ”€ __main__.py # Module execution entry point โ”‚ โ”œโ”€โ”€ core.py # Core greeting functionality diff --git a/bandit-results.json b/bandit-results.json new file mode 100644 index 0000000..7a67223 --- /dev/null +++ b/bandit-results.json @@ -0,0 +1,98 @@ +{ + "errors": [], + "generated_at": "2025-08-16T07:37:31Z", + "metrics": { + "_totals": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1016, + "nosec": 0, + "skipped_tests": 0 + }, + "src/greeting_toolkit\\__init__.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 48, + "nosec": 0, + "skipped_tests": 0 + }, + "src/greeting_toolkit\\__main__.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 5, + "nosec": 0, + "skipped_tests": 0 + }, + "src/greeting_toolkit\\cli.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 233, + "nosec": 0, + "skipped_tests": 0 + }, + "src/greeting_toolkit\\config.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 296, + "nosec": 0, + "skipped_tests": 0 + }, + "src/greeting_toolkit\\core.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 322, + "nosec": 0, + "skipped_tests": 0 + }, + "src/greeting_toolkit\\logging.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 112, + "nosec": 0, + "skipped_tests": 0 + } + }, + "results": [] +} \ No newline at end of file diff --git a/dev-requirements.txt b/dev-requirements.txt index 5289fe8..94ec533 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -13,4 +13,4 @@ pytest-sugar>=1.0.0 pre-commit>=3.6.2 tox>=4.13.0 pdoc>=14.3.0 -types-all>=1.0.0 +types-setuptools>=0.1 diff --git a/docker-compose.yml b/docker-compose.yml index 2d30ddd..a1787f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: dockerfile: Dockerfile volumes: - .:/opt/pysetup - command: python -m my_python_package + command: python -m greeting_toolkit test: build: diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 5dc2e7d..a97c897 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -1,4 +1,4 @@ -/* Custom styles for my_python_package documentation */ +/* Custom styles for greeting_toolkit documentation */ /* Improve code block appearance */ div[class^="highlight"] { diff --git a/docs/_static/js/custom.js b/docs/_static/js/custom.js index 9381881..b4fd83f 100644 --- a/docs/_static/js/custom.js +++ b/docs/_static/js/custom.js @@ -1,4 +1,4 @@ -// Custom JavaScript for my_python_package documentation +// Custom JavaScript for greeting_toolkit documentation document.addEventListener('DOMContentLoaded', function() { // Add copy buttons to code blocks diff --git a/docs/api.md b/docs/api.md index 590f283..280d98a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,31 +1,31 @@ # API Reference -This page documents the public API of `my_python_package`. +This page documents the public API of `greeting_toolkit`. ## Core Functions -::: my_python_package.core +::: greeting_toolkit.core options: show_root_heading: true show_source: true ## Configuration -::: my_python_package.config +::: greeting_toolkit.config options: show_root_heading: true show_source: true ## Logging -::: my_python_package.logging +::: greeting_toolkit.logging options: show_root_heading: true show_source: true ## Command Line Interface -::: my_python_package.cli +::: greeting_toolkit.cli options: show_root_heading: true show_source: true diff --git a/docs/cli.rst b/docs/cli.rst index 2163305..ec5271b 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -2,7 +2,7 @@ Command Line Interface ======================= -``my_python_package`` provides a command-line interface (CLI) for easy access to its functionality. +``greeting_toolkit`` provides a command-line interface (CLI) for easy access to its functionality. Basic Usage ---------- @@ -11,7 +11,7 @@ The basic syntax for the CLI is: .. code-block:: bash - my-python-package COMMAND [ARGS] [OPTIONS] + greeting-toolkit COMMAND [ARGS] [OPTIONS] Available Commands ----------------- @@ -23,16 +23,16 @@ Generate a simple greeting: .. code-block:: bash - my-python-package hello NAME [--greeting GREETING] + greeting-toolkit hello NAME [--greeting GREETING] Examples: .. code-block:: bash - my-python-package hello World + greeting-toolkit hello World # Output: Hello, World! - my-python-package hello Python --greeting Hi + greeting-toolkit hello Python --greeting Hi # Output: Hi, Python! random @@ -42,13 +42,13 @@ Generate a random greeting: .. code-block:: bash - my-python-package random NAME + greeting-toolkit random NAME Example: .. code-block:: bash - my-python-package random World + greeting-toolkit random World # Output varies with each run time @@ -58,19 +58,19 @@ Generate a time-based greeting: .. code-block:: bash - my-python-package time NAME [--formal] + greeting-toolkit time NAME [--formal] Examples: .. code-block:: bash - my-python-package time World + greeting-toolkit 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 + greeting-toolkit time Mrs.Smith --formal # Output: "Good day, Mr./Ms. Mrs.Smith!" format @@ -80,7 +80,7 @@ Format a greeting with various options: .. code-block:: bash - my-python-package format NAME + greeting-toolkit format NAME [--greeting GREETING] [--punctuation PUNCTUATION] [--uppercase] @@ -90,13 +90,13 @@ Examples: .. code-block:: bash - my-python-package format World + greeting-toolkit format World # Output: Hello, World! - my-python-package format World --greeting Welcome --punctuation "!!!" --uppercase + greeting-toolkit format World --greeting Welcome --punctuation "!!!" --uppercase # Output: WELCOME, WORLD!!! - my-python-package format "Very Long Name" --max-length 15 + greeting-toolkit format "Very Long Name" --max-length 15 # Output: Hello, Very... multi @@ -106,19 +106,19 @@ Greet multiple names: .. code-block:: bash - my-python-package multi NAME1 NAME2 ... [--greeting GREETING] + greeting-toolkit multi NAME1 NAME2 ... [--greeting GREETING] Example: .. code-block:: bash - my-python-package multi Alice Bob Charlie + greeting-toolkit multi Alice Bob Charlie # Output: # Hello, Alice! # Hello, Bob! # Hello, Charlie! - my-python-package multi Alice Bob --greeting "Greetings" + greeting-toolkit multi Alice Bob --greeting "Greetings" # Output: # Greetings, Alice! # Greetings, Bob! @@ -130,7 +130,7 @@ Manage configuration settings: .. code-block:: bash - my-python-package config SUBCOMMAND [OPTIONS] + greeting-toolkit config SUBCOMMAND [OPTIONS] Subcommands: @@ -145,28 +145,28 @@ Examples: .. code-block:: bash # Show current configuration - my-python-package config show + greeting-toolkit config show # Set default greeting - my-python-package config set --greeting "Howdy" + greeting-toolkit config set --greeting "Howdy" # Set default punctuation - my-python-package config set --punctuation "?" + greeting-toolkit config set --punctuation "?" # Set formal title - my-python-package config set --title "Dr. " + greeting-toolkit config set --title "Dr. " # Set maximum name length - my-python-package config set --max-name-length 30 + greeting-toolkit config set --max-name-length 30 # Add a greeting - my-python-package config add-greeting "Salutations" + greeting-toolkit config add-greeting "Salutations" # Save configuration to file - my-python-package config save config.json + greeting-toolkit config save config.json # Load configuration from file - my-python-package config load config.json + greeting-toolkit config load config.json Global Options ------------ @@ -186,16 +186,16 @@ Examples: .. code-block:: bash # Show help for the hello command - my-python-package hello --help + greeting-toolkit hello --help # Show version information - my-python-package --version + greeting-toolkit --version # Set logging level - my-python-package hello World --log-level debug + greeting-toolkit hello World --log-level debug # Log to file - my-python-package hello World --log-file greeting.log + greeting-toolkit hello World --log-file greeting.log Advanced Usage ------------ @@ -211,15 +211,15 @@ You can use the CLI in shell scripts: # Greet all users in a file while read name; do - my-python-package hello "$name" --greeting "Welcome" + greeting-toolkit 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 + greeting-toolkit config set --greeting "Hi" --punctuation "!" + greeting-toolkit config save my_config.json # Later, restore the configuration - my-python-package config load my_config.json + greeting-toolkit config load my_config.json Output Redirection ~~~~~~~~~~~~~~~~ @@ -229,10 +229,10 @@ You can redirect the output to files: .. code-block:: bash # Save greetings to a file - my-python-package multi Alice Bob Charlie > greetings.txt + greeting-toolkit multi Alice Bob Charlie > greetings.txt # Append more greetings - my-python-package hello Dave >> greetings.txt + greeting-toolkit hello Dave >> greetings.txt Error Handling ~~~~~~~~~~~~ @@ -242,6 +242,6 @@ The CLI will return non-zero exit codes on errors: .. code-block:: bash # Script example with error handling - if ! my-python-package hello ""; then + if ! greeting-toolkit hello ""; then echo "Failed to greet empty name" fi diff --git a/docs/config.py b/docs/config.py index 14733c9..ec2f7bf 100644 --- a/docs/config.py +++ b/docs/config.py @@ -14,13 +14,13 @@ # -- Project information ----------------------------------------------------- -project = "my_python_package" +project = "greeting-toolkit" 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__ + from greeting_toolkit import __version__ release = __version__ except ImportError: diff --git a/docs/contributing.rst b/docs/contributing.rst index fa9f314..3caa761 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -2,7 +2,7 @@ Contributing ============ -Thank you for considering contributing to ``my_python_package``! This page provides guidelines for contributing to the project. +Thank you for considering contributing to ``greeting_toolkit``! This page provides guidelines for contributing to the project. Code of Conduct -------------- diff --git a/docs/development.rst b/docs/development.rst index 5b56640..38b745b 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -2,7 +2,7 @@ Development Guide ================= -This guide will help you set up your development environment and understand the workflow for contributing to ``my_python_package``. +This guide will help you set up your development environment and understand the workflow for contributing to ``greeting_toolkit``. Development Environment Setup --------------------------- @@ -21,8 +21,8 @@ Initial Setup .. code-block:: bash - git clone https://github.com/DiogoRibeiro7/my_python_package.git - cd my_python_package + git clone https://github.com/DiogoRibeiro7/greeting-toolkit.git + cd greeting-toolkit 2. Install dependencies and set up pre-commit hooks: @@ -143,7 +143,7 @@ Project Structure .. code-block:: text - my_python_package/ + greeting_toolkit/ โ”œโ”€โ”€ pyproject.toml # Project metadata, dependencies โ”œโ”€โ”€ setup.cfg # Configuration for various tools โ”œโ”€โ”€ tox.ini # Multi-environment testing @@ -151,7 +151,7 @@ Project Structure โ”œโ”€โ”€ .editorconfig # Editor configuration โ”œโ”€โ”€ Makefile # Common development tasks โ”œโ”€โ”€ src/ - โ”‚ โ””โ”€โ”€ my_python_package/ # Package source code + โ”‚ โ””โ”€โ”€ greeting_toolkit/ # Package source code โ”‚ โ”œโ”€โ”€ __init__.py # Package exports โ”‚ โ”œโ”€โ”€ __main__.py # Module execution entry point โ”‚ โ”œโ”€โ”€ core.py # Core greeting functionality @@ -284,7 +284,7 @@ To run tests with coverage and see which lines are not covered: .. code-block:: bash - pytest --cov=my_python_package --cov-report=term-missing + pytest --cov=greeting_toolkit --cov-report=term-missing Debugging Tips ~~~~~~~~~~~~ diff --git a/docs/index.rst b/docs/index.rst index bfc0f86..24b6eb6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,23 +1,23 @@ =========================== -my_python_package +greeting-toolkit =========================== 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/ +.. image:: https://img.shields.io/pypi/v/greeting-toolkit.svg + :target: https://pypi.org/project/greeting-toolkit/ :alt: PyPI version -.. image:: https://img.shields.io/pypi/pyversions/my_python_package.svg - :target: https://pypi.org/project/my_python_package/ +.. image:: https://img.shields.io/pypi/pyversions/greeting-toolkit.svg + :target: https://pypi.org/project/greeting-toolkit/ :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 +.. image:: https://github.com/DiogoRibeiro7/greeting-toolkit/actions/workflows/test.yml/badge.svg + :target: https://github.com/DiogoRibeiro7/greeting-toolkit/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 + :target: https://codecov.io/gh/DiogoRibeiro7/greeting-toolkit :alt: Coverage .. image:: https://img.shields.io/badge/License-MIT-yellow.svg @@ -49,7 +49,7 @@ It provides various greeting functions with configurable options for formatting, .. code-block:: python - from my_python_package import hello + from greeting_toolkit import hello # Basic usage greeting = hello("World") diff --git a/docs/installation.rst b/docs/installation.rst index 779b4fd..07fae31 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -5,11 +5,11 @@ Installation From PyPI --------- -The package is available on `PyPI `_, and you can install it using pip: +The package is available on `PyPI `_, and you can install it using pip: .. code-block:: bash - pip install my_python_package + pip install greeting-toolkit Using Poetry ----------- @@ -18,7 +18,7 @@ If you use `Poetry `_ for dependency management, you .. code-block:: bash - poetry add my_python_package + poetry add greeting-toolkit From Source ---------- @@ -27,8 +27,8 @@ 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 + git clone https://github.com/DiogoRibeiro7/greeting-toolkit.git + cd greeting-toolkit Then install it using one of the following methods: @@ -71,7 +71,7 @@ To verify that the package is installed correctly, you can run the following Pyt .. code-block:: python - from my_python_package import hello + from greeting_toolkit import hello print(hello("World")) # Should output: Hello, World! @@ -79,6 +79,6 @@ Or use the command-line interface: .. code-block:: bash - my-python-package hello World + greeting-toolkit hello World This should output: ``Hello, World!`` diff --git a/docs/make_api_docs.py b/docs/make_api_docs.py index be409b8..517c4db 100644 --- a/docs/make_api_docs.py +++ b/docs/make_api_docs.py @@ -18,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -PACKAGE_NAME = "my_python_package" +PACKAGE_NAME = "greeting_toolkit" API_DIR = Path(__file__).parent / "api" diff --git a/docs/usage.rst b/docs/usage.rst index 5ea03f4..5827146 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -2,7 +2,7 @@ Usage ===== -This page demonstrates how to use the various features of ``my_python_package``. +This page demonstrates how to use the various features of ``greeting_toolkit``. Basic Greeting ------------- @@ -11,7 +11,7 @@ The most basic function is ``hello()``, which returns a simple greeting: .. code-block:: python - from my_python_package import hello + from greeting_toolkit import hello # Basic usage greeting = hello("World") @@ -28,7 +28,7 @@ For more control over the output, use ``format_greeting()``: .. code-block:: python - from my_python_package import format_greeting + from greeting_toolkit import format_greeting # Default formatting print(format_greeting("World")) # Output: Hello, World! @@ -51,7 +51,7 @@ To greet multiple people at once, use ``create_greeting_list()``: .. code-block:: python - from my_python_package import create_greeting_list + from greeting_toolkit import create_greeting_list # Greet multiple people greetings = create_greeting_list(["Alice", "Bob", "Charlie"]) @@ -70,7 +70,7 @@ The ``generate_greeting()`` function can adjust the greeting based on the time o .. code-block:: python - from my_python_package import generate_greeting + from greeting_toolkit import generate_greeting # Time-based greeting (morning/afternoon/evening) print(generate_greeting("World", time_based=True)) @@ -94,7 +94,7 @@ For variety, use ``random_greeting()`` to get a different greeting each time: .. code-block:: python - from my_python_package import random_greeting + from greeting_toolkit import random_greeting # Get a random greeting print(random_greeting("World")) # Different greeting each time @@ -106,7 +106,7 @@ To validate names before using them in greetings, use ``validate_name()``: .. code-block:: python - from my_python_package import validate_name + from greeting_toolkit import validate_name # Check if a name is valid valid, error = validate_name("John") @@ -127,8 +127,8 @@ 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 + from greeting_toolkit.core import set_default_greeting, set_default_punctuation, add_greeting + from greeting_toolkit.config import config # Set default greeting set_default_greeting("Howdy") @@ -159,7 +159,7 @@ You can combine multiple features for more complex behavior: .. code-block:: python - from my_python_package import validate_name, format_greeting, hello + from greeting_toolkit import validate_name, format_greeting, hello def greet_user(name, formal=False, uppercase=False): # First validate the name @@ -191,7 +191,7 @@ 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 + from greeting_toolkit import format_greeting, random_greeting, generate_greeting import random class GreetingSystem: diff --git a/examples/usage.py b/examples/usage.py index c508113..c8ad8ad 100644 --- a/examples/usage.py +++ b/examples/usage.py @@ -1,4 +1,4 @@ -from my_python_package import hello +from greeting_toolkit import hello def main(): diff --git a/mkdocs.yaml b/mkdocs.yaml index 67b4e89..23e9e3c 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -1,10 +1,10 @@ -site_name: my_python_package +site_name: greeting-toolkit site_description: A minimal but production-ready Python package scaffold site_author: Diogo Ribeiro -site_url: https://my-python-package.readthedocs.io/ +site_url: https://greeting-toolkit.readthedocs.io/ -repo_name: DiogoRibeiro7/my_python_package -repo_url: https://github.com/DiogoRibeiro7/my_python_package +repo_name: DiogoRibeiro7/greeting-toolkit +repo_url: https://github.com/DiogoRibeiro7/greeting-toolkit edit_uri: edit/main/docs/ theme: @@ -100,7 +100,7 @@ extra: - icon: fontawesome/brands/github link: https://github.com/DiogoRibeiro7 - icon: fontawesome/brands/python - link: https://pypi.org/project/my-python-package/ + link: https://pypi.org/project/greeting-toolkit/ nav: - Home: index.md diff --git a/pyproject.toml b/pyproject.toml index 52a1e46..d9d7d5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,14 @@ [tool.poetry] -name = "my_python_package" -version = "0.3.1" +name = "greeting-toolkit" +version = "0.3.0" description = "A short description of the package." authors = ["Diogo Ribeiro "] license = "MIT" readme = "README.md" -packages = [{ include = "my_python_package", from = "src" }] -repository = "https://github.com/DiogoRibeiro7/my_python_package" +packages = [{ include = "greeting_toolkit", from = "src" }] +repository = "https://github.com/DiogoRibeiro7/greeting-toolkit" +homepage = "https://github.com/DiogoRibeiro7/greeting-toolkit" +documentation = "https://github.com/DiogoRibeiro7/greeting-toolkit" keywords = ["python", "package", "template"] classifiers = [ "Development Status :: 4 - Beta", @@ -35,6 +37,7 @@ bandit = "^1.7.7" pytest-mock = "^3.12.0" pytest-sugar = "^1.0.0" pre-commit = "^3.6.2" +types-setuptools = "*" [tool.poetry.group.docs] optional = true @@ -102,15 +105,15 @@ unfixable = ["F401"] convention = "google" [tool.ruff.per-file-ignores] -"tests/*" = ["ANN", "D", "S101"] +"tests/*" = ["ANN", "D", "S101", "PT011", "F401", "SIM117", "S102", "SIM115"] "*/__init__.py" = ["F401"] [tool.ruff.isort] -known-first-party = ["my_python_package"] +known-first-party = ["greeting_toolkit"] [tool.pytest.ini_options] minversion = "8.0" -addopts = "--cov=my_python_package --cov-report=term-missing" +addopts = "--cov=greeting_toolkit --cov-report=term-missing" testpaths = ["tests"] python_files = "test_*.py" python_classes = "Test*" @@ -121,7 +124,7 @@ markers = [ ] [tool.coverage.run] -source = ["my_python_package"] +source = ["greeting_toolkit"] omit = ["tests/*"] [tool.coverage.report] @@ -139,4 +142,4 @@ exclude_dirs = ["tests", "docs"] skips = ["B101"] [tool.poetry.scripts] -my-python-package = "my_python_package.cli:main" +greeting-toolkit = "greeting_toolkit.cli:main" diff --git a/requirements.txt b/requirements.txt index a07f7d5..a55fd40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ # To update, run: # poetry export -f requirements.txt --without-hashes -o requirements.txt -my_python_package>=0.3.0 +greeting-toolkit>=0.3.0 diff --git a/scripts/check_docstrings_coverage.py b/scripts/check_docstrings_coverage.py index 3fa1448..b566243 100644 --- a/scripts/check_docstrings_coverage.py +++ b/scripts/check_docstrings_coverage.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -""" -Check docstring coverage in the project. +"""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 @@ -36,8 +35,7 @@ class DocItem(NamedTuple): def is_public(name: str) -> bool: - """ - Check if a name is public (not starting with underscore). + """Check if a name is public (not starting with underscore). Args: name: The name to check @@ -51,8 +49,7 @@ def is_public(name: str) -> bool: def should_have_docstring(node: ast.AST, include_all: bool = False) -> bool: - """ - Determine if a node should have a docstring based on our standards. + """Determine if a node should have a docstring based on our standards. Args: node: The AST node to check @@ -81,35 +78,33 @@ def should_have_docstring(node: ast.AST, include_all: bool = False) -> bool: return False -def get_docstring(node: ast.AST) -> Optional[str]: - """ - Extract docstring from an AST node. +def get_docstring(node: ast.AST) -> str | None: + """Extract docstring from an AST node. Args: node: The AST node to extract docstring from Returns: - The docstring if present, None otherwise + 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)) + if isinstance(first_node.value, ast.Str): + return first_node.value.s + if 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 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. +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 @@ -118,14 +113,14 @@ def check_file_docstrings(file_path: Path, include_all: bool = False) -> List[Do Returns: List of DocItem instances for each item that should have a docstring """ - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, 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] = [] + results: list[DocItem] = [] # Check module docstring module_has_doc = bool(get_docstring(tree)) @@ -176,10 +171,9 @@ def check_file_docstrings(file_path: Path, include_all: bool = False) -> List[Do 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. + directory: Path, include_all: bool = False, exclude: set[str] | None = None +) -> tuple[list[DocItem], dict[str, float]]: + """Check docstring coverage for all Python files in a directory. Args: directory: Directory to check @@ -203,7 +197,7 @@ def check_directory_docstrings( ".ruff_cache", } - all_results: List[DocItem] = [] + 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] @@ -215,7 +209,7 @@ def check_directory_docstrings( all_results.extend(results) # Calculate statistics - stats: Dict[str, Dict[str, int]] = {"module": {}, "class": {}, "function": {}, "method": {}} + 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) @@ -226,7 +220,7 @@ def check_directory_docstrings( stats[item.type]["with_docstring"] += 1 # Calculate percentages - percentages: Dict[str, float] = {} + percentages: dict[str, float] = {} total_items = 0 total_with_docs = 0 @@ -246,10 +240,9 @@ def check_directory_docstrings( def print_report( - items: List[DocItem], stats: Dict[str, float], show_documented: bool = False + items: list[DocItem], stats: dict[str, float], show_documented: bool = False ) -> None: - """ - Print a docstring coverage report. + """Print a docstring coverage report. Args: items: List of DocItem instances @@ -291,9 +284,7 @@ def print_report( 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("--dir", type=str, default="src", help="Directory to check (default: src)") parser.add_argument( "--include-all", action="store_true", diff --git a/scripts/check_imports_vs_pyproject.py b/scripts/check_imports_vs_pyproject.py index 79af679..a28d50d 100644 --- a/scripts/check_imports_vs_pyproject.py +++ b/scripts/check_imports_vs_pyproject.py @@ -1,13 +1,12 @@ #!/usr/bin/env python -""" -check_imports_vs_pyproject.py +"""check_imports_vs_pyproject.py Scan Python sources for imports, compare to pyproject.toml, and optionally FIX: - Adds missing dependencies to the chosen group with a selectable spec strategy. - Preserves formatting/comments using tomlkit. - Supports Poetry ([tool.poetry]) and PEP 621 ([project]). -Examples +Examples: -------- # Detect only (text) python scripts/check_imports_vs_pyproject.py @@ -37,29 +36,31 @@ import sys import urllib.error import urllib.request +from collections.abc import Iterable from dataclasses import dataclass from difflib import unified_diff from pathlib import Path -from typing import Dict, Iterable, List, Optional, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple +from urllib.parse import urlparse import tomlkit from packaging.requirements import Requirement -from packaging.version import Version, InvalidVersion +from packaging.version import InvalidVersion, Version # --- Optional helpers for stdlib / env mapping --- try: - _STDLIB: Set[str] = set(sys.stdlib_module_names) # py>=3.10 + _STDLIB: set[str] = set(sys.stdlib_module_names) # py>=3.10 except Exception: _STDLIB = set() try: from importlib.metadata import packages_distributions - _MODULE_TO_DISTS: Dict[str, List[str]] = packages_distributions() + _MODULE_TO_DISTS: dict[str, list[str]] = packages_distributions() except Exception: _MODULE_TO_DISTS = {} -_COMMON_MODULE_TO_DIST: Dict[str, str] = { +_COMMON_MODULE_TO_DIST: dict[str, str] = { "bs4": "beautifulsoup4", "cv2": "opencv-python", "PIL": "Pillow", @@ -81,13 +82,13 @@ class Config: root: Path pyproject: Path - groups: List[str] # Which groups count as "declared" when checking + groups: list[str] # Which groups count as "declared" when checking include_optional: bool fail_on: str # missing|unused|both|none fmt: str # text|json - exclude_dirs: List[str] + exclude_dirs: list[str] use_env_map: bool - src_hints: List[str] + src_hints: list[str] # Fix-related: fix: bool fix_to: str # main|dev| @@ -100,11 +101,11 @@ class Config: @dataclass class Report: - missing: Dict[str, Set[str]] # dist -> {modules} - unused: Set[str] - ambiguous: Dict[str, Set[str]] - used_dists: Set[str] - declared_dists: Set[str] + missing: dict[str, set[str]] # dist -> {modules} + unused: set[str] + ambiguous: dict[str, set[str]] + used_dists: set[str] + declared_dists: set[str] # ---------------- Utilities ---------------- @@ -118,7 +119,7 @@ def is_stdlib(top: str) -> bool: return top in _STDLIB -def iter_py_files(root: Path, exclude_dirs: List[str]) -> Iterable[Path]: +def iter_py_files(root: Path, exclude_dirs: list[str]) -> Iterable[Path]: default = { ".git", ".hg", @@ -141,13 +142,13 @@ def iter_py_files(root: Path, exclude_dirs: List[str]) -> Iterable[Path]: yield p -def parse_imports(pyfile: Path) -> Set[str]: +def parse_imports(pyfile: Path) -> set[str]: text = pyfile.read_text(encoding="utf-8", errors="ignore") try: tree = ast.parse(text, filename=str(pyfile)) except SyntaxError: return set() - tops: Set[str] = set() + tops: set[str] = set() for n in ast.walk(tree): if isinstance(n, ast.Import): for a in n.names: @@ -161,8 +162,8 @@ def parse_imports(pyfile: Path) -> Set[str]: return tops -def discover_local_tops(root: Path, hints: List[str]) -> Set[str]: - locals_: Set[str] = set() +def discover_local_tops(root: Path, hints: list[str]) -> set[str]: + locals_: set[str] = set() def scan(base: Path) -> None: if not base.exists() or not base.is_dir(): @@ -181,7 +182,7 @@ def scan(base: Path) -> None: return locals_ -def map_module_to_dists(mod: str, use_env: bool = True) -> List[str]: +def map_module_to_dists(mod: str, use_env: bool = True) -> list[str]: if use_env and _MODULE_TO_DISTS: d = _MODULE_TO_DISTS.get(mod, []) if d: @@ -194,11 +195,12 @@ def map_module_to_dists(mod: str, use_env: bool = True) -> List[str]: # ------------- PyPI lookup + strategy ------------- -def fetch_latest( - name: str, timeout: float, include_prerelease: bool -) -> Optional[Version]: +def fetch_latest(name: str, timeout: float, include_prerelease: bool) -> Version | None: url = f"https://pypi.org/pypi/{pep503(name)}/json" try: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"}: + raise ValueError(f"Unsupported URL scheme: {parsed.scheme}") with urllib.request.urlopen(url, timeout=timeout) as r: data = json.loads(r.read().decode("utf-8")) except ( @@ -209,7 +211,7 @@ def fetch_latest( ): return None releases = data.get("releases", {}) or {} - best: Optional[Version] = None + best: Version | None = None for vstr, files in releases.items(): if not files: continue @@ -255,7 +257,7 @@ def poetry_spec_for(v: Version, strategy: str) -> str: # ------------- Read/Write pyproject (tomlkit) ------------- -def load_doc(path: Path) -> Tuple[str, tomlkit.TOMLDocument]: +def load_doc(path: Path) -> tuple[str, tomlkit.TOMLDocument]: text = path.read_text(encoding="utf-8") return text, tomlkit.parse(text) @@ -269,16 +271,14 @@ def layout(doc: tomlkit.TOMLDocument) -> str: return "poetry" if "project" in doc: return "pep621" - raise ValueError( - "Unsupported pyproject: neither [tool.poetry] nor [project] found." - ) + raise ValueError("Unsupported pyproject: neither [tool.poetry] nor [project] found.") def collect_declared( - doc: tomlkit.TOMLDocument, groups: List[str], include_optional: bool -) -> Set[str]: + doc: tomlkit.TOMLDocument, groups: list[str], include_optional: bool +) -> set[str]: wanted = set(groups) - dec: Set[str] = set() + dec: set[str] = set() # Poetry tool = doc.get("tool", {}) @@ -325,11 +325,8 @@ def collect_declared( return dec -def add_dep_to_doc( - doc: tomlkit.TOMLDocument, dist: str, spec: str, fix_to: str -) -> None: - """ - Add dependency to doc in the selected group. +def add_dep_to_doc(doc: tomlkit.TOMLDocument, dist: str, spec: str, fix_to: str) -> None: + """Add dependency to doc in the selected group. - Poetry: under [tool.poetry.dependencies] or group..dependencies - PEP 621: in project.dependencies (main) or optional-dependencies. """ @@ -387,14 +384,14 @@ def analyze(cfg: Config) -> Report: declared = collect_declared(doc, cfg.groups, cfg.include_optional) local = discover_local_tops(cfg.root, cfg.src_hints) - imported: Set[str] = set() + imported: set[str] = set() for f in iter_py_files(cfg.root, cfg.exclude_dirs): imported |= parse_imports(f) third_party = {m for m in imported if not is_stdlib(m) and m not in local} - used_dists: Set[str] = set() - ambiguous: Dict[str, Set[str]] = {} + used_dists: set[str] = set() + ambiguous: dict[str, set[str]] = {} for mod in sorted(third_party): dists = map_module_to_dists(mod, cfg.use_env_map) if len(dists) == 1: @@ -406,7 +403,7 @@ def analyze(cfg: Config) -> Report: if d in declared: used_dists.add(d) - missing: Dict[str, Set[str]] = {} + missing: dict[str, set[str]] = {} for mod in sorted(third_party): dists = map_module_to_dists(mod, cfg.use_env_map) if any(d in declared for d in dists): @@ -418,8 +415,7 @@ def analyze(cfg: Config) -> Report: def apply_fix(cfg: Config, rep: Report) -> int: - """ - Add missing distributions to pyproject.toml using selected strategy. + """Add missing distributions to pyproject.toml using selected strategy. Returns 0 (write/diff success) or raises on errors. """ if not rep.missing: @@ -454,7 +450,7 @@ def apply_fix(cfg: Config, rep: Report) -> int: # ------------- CLI ------------- -def parse_args(argv: Optional[List[str]] = None) -> Config: +def parse_args(argv: list[str] | None = None) -> Config: p = argparse.ArgumentParser( description="Check (and optionally fix) imports vs pyproject dependencies." ) @@ -500,9 +496,7 @@ def parse_args(argv: Optional[List[str]] = None) -> Config: ) # Fix options - p.add_argument( - "--fix", action="store_true", help="Write missing deps into pyproject.toml." - ) + p.add_argument("--fix", action="store_true", help="Write missing deps into pyproject.toml.") p.add_argument( "--fix-to", default="main", @@ -537,9 +531,7 @@ def parse_args(argv: Optional[List[str]] = None) -> Config: action="store_true", help="Dry-run: print unified diff; do not write.", ) - p.add_argument( - "--timeout", type=float, default=8.0, help="HTTP timeout for PyPI lookups." - ) + p.add_argument("--timeout", type=float, default=8.0, help="HTTP timeout for PyPI lookups.") a = p.parse_args(argv) groups = [g.strip() for g in a.groups.split(",") if g.strip()] @@ -566,7 +558,7 @@ def parse_args(argv: Optional[List[str]] = None) -> Config: def format_report_text(r: Report) -> str: - lines: List[str] = [] + lines: list[str] = [] if r.missing: lines.append("MISSING (imported but not declared):") for dist, modules in sorted(r.missing.items()): @@ -600,7 +592,7 @@ def format_report_json(r: Report) -> str: return json.dumps(obj, indent=2, sort_keys=True) -def main(argv: Optional[List[str]] = None) -> int: +def main(argv: list[str] | None = None) -> int: cfg = parse_args(argv) rep = analyze(cfg) diff --git a/scripts/generate_api_docs.py b/scripts/generate_api_docs.py index 3c3b747..9825733 100644 --- a/scripts/generate_api_docs.py +++ b/scripts/generate_api_docs.py @@ -161,7 +161,7 @@ def create_index_file( structure: Dict[str, List[str]], output_dir: Path, format_type: Literal["html", "markdown"] = "markdown", - package_name: str = "my_python_package" + package_name: str = "greeting_toolkit" ) -> None: """ Create an index file that links to all the module documentation. @@ -268,7 +268,7 @@ def create_index_file( def generate_docs( format_type: Literal["html", "markdown"] = "html", output_dir: Optional[Path] = None, - package_name: str = "my_python_package", + package_name: str = "greeting_toolkit", ) -> bool: """ Generate documentation using pdoc. @@ -351,8 +351,8 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--package", - default="my_python_package", - help="Package name to document (default: my_python_package)", + default="greeting_toolkit", + help="Package name to document (default: greeting_toolkit)", ) return parser.parse_args() diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py index a77b9d8..ae329c2 100644 --- a/scripts/generate_docs.py +++ b/scripts/generate_docs.py @@ -43,7 +43,7 @@ def check_pdoc_installed() -> bool: def generate_docs( format_type: Literal["html", "markdown"] = "html", output_dir: Optional[Path] = None, - package_name: str = "my_python_package", + package_name: str = "greeting_toolkit", ) -> bool: """ Generate documentation using pdoc. diff --git a/scripts/pyproject_updater.py b/scripts/pyproject_updater.py index 54a033d..53cfdac 100644 --- a/scripts/pyproject_updater.py +++ b/scripts/pyproject_updater.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -""" -pyproject_updater.py +"""pyproject_updater.py Update dependency constraints in `pyproject.toml` to the **latest** versions from PyPI. @@ -37,30 +36,33 @@ import sys import urllib.error import urllib.request +from collections.abc import Iterable from dataclasses import dataclass from difflib import unified_diff from pathlib import Path -from typing import Dict, Iterable, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlparse import tomlkit from packaging.requirements import Requirement -from packaging.version import Version, InvalidVersion +from packaging.version import InvalidVersion, Version @dataclass(frozen=True) class Options: - strategy: str # one of: exact, caret, tilde, floor - allow_major: bool # if False, keep within current major - include_prerelease: bool # if True, accept pre-releases - groups: List[str] # e.g., ["main", "dev"] - only: Optional[List[str]] # package name filters (normalized) - check: bool # dry-run - file: Path # pyproject.toml path - timeout: float # HTTP timeout + strategy: str # one of: exact, caret, tilde, floor + allow_major: bool # if False, keep within current major + include_prerelease: bool # if True, accept pre-releases + groups: list[str] # e.g., ["main", "dev"] + only: list[str] | None # package name filters (normalized) + check: bool # dry-run + file: Path # pyproject.toml path + timeout: float # HTTP timeout # ---------- TOML helpers ---------- + def _read_doc(path: Path): text = path.read_text(encoding="utf-8") return text, tomlkit.parse(text) @@ -68,8 +70,14 @@ def _read_doc(path: Path): def _write_or_diff(path: Path, before: str, after: str, check: bool) -> int: if check: - diff = "".join(unified_diff(before.splitlines(True), after.splitlines(True), - fromfile=str(path), tofile=str(path))) + diff = "".join( + unified_diff( + before.splitlines(True), + after.splitlines(True), + fromfile=str(path), + tofile=str(path), + ) + ) sys.stdout.write(diff) return 0 path.write_text(after, encoding="utf-8") @@ -87,28 +95,28 @@ def _layout(doc) -> str: # ---------- PyPI version lookup ---------- + def _normalize_pkg_name(name: str) -> str: - """ - Normalize per PEP 503 for PyPI URLs: lowercase and replace `_`/`.` with `-`. + """Normalize per PEP 503 for PyPI URLs: lowercase and replace `_`/`.` with `-`. Keep extras separate (e.g., 'foo[bar]') โ€” we strip extras for lookup. """ base = name.split("[", 1)[0] return base.lower().replace("_", "-").replace(".", "-") -def _fetch_pypi_versions(name: str, timeout: float) -> Dict[str, bool]: - """ - Return {version_str: is_yanked=False/True} for package 'name' from PyPI. - On failure, return empty dict. - """ +def _fetch_pypi_versions(name: str, timeout: float) -> dict[str, bool]: + """Return ``{version_str: is_yanked}`` for package *name* from PyPI.""" url = f"https://pypi.org/pypi/{_normalize_pkg_name(name)}/json" try: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"}: + raise ValueError(f"Unsupported URL scheme: {parsed.scheme}") with urllib.request.urlopen(url, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8")) except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError): return {} - versions: Dict[str, bool] = {} + versions: dict[str, bool] = {} releases = data.get("releases", {}) or {} for ver_str, files in releases.items(): # files is a list of distributions; consider version yanked if all files are yanked @@ -119,11 +127,9 @@ def _fetch_pypi_versions(name: str, timeout: float) -> Dict[str, bool]: return versions -def _select_latest_version(versions: Dict[str, bool], include_prerelease: bool) -> Optional[Version]: - """ - Pick the highest non-yanked Version. If include_prerelease=False, prefer finals. - """ - valid: List[Version] = [] +def _select_latest_version(versions: dict[str, bool], include_prerelease: bool) -> Version | None: + """Pick the highest non-yanked Version. If include_prerelease=False, prefer finals.""" + valid: list[Version] = [] for ver_str, not_yanked in versions.items(): if not not_yanked: continue @@ -141,6 +147,7 @@ def _select_latest_version(versions: Dict[str, bool], include_prerelease: bool) # ---------- Constraint mapping ---------- + def _poetry_string_for_strategy(v: Version, strategy: str) -> str: if strategy == "exact": return f"=={v}" @@ -169,9 +176,8 @@ def _pep440_string_for_strategy(v: Version, strategy: str) -> str: raise ValueError(f"Unknown strategy: {strategy}") -def _respect_major_allowed(current_spec: Optional[str], latest: Version, allow_major: bool) -> bool: - """ - If allow_major is False and current_spec indicates a major cap, avoid bumping across majors. +def _respect_major_allowed(current_spec: str | None, latest: Version, allow_major: bool) -> bool: + """If allow_major is False and current_spec indicates a major cap, avoid bumping across majors. Heuristic: extract existing max major from spec if present; otherwise compare against any pinned/ranged major. """ if allow_major: @@ -180,7 +186,11 @@ def _respect_major_allowed(current_spec: Optional[str], latest: Version, allow_m return latest.major == latest.major # trivial True # Try to parse the requirement to see any existing max try: - req = Requirement(f"pkg {current_spec}") if " " in current_spec else Requirement(f"pkg {current_spec}") + req = ( + Requirement(f"pkg {current_spec}") + if " " in current_spec + else Requirement(f"pkg {current_spec}") + ) except Exception: # Fallback: if spec starts with ^ or ~ (Poetry), infer major from latest string of spec if present if current_spec.startswith("^") or current_spec.startswith("~"): @@ -206,13 +216,14 @@ def _respect_major_allowed(current_spec: Optional[str], latest: Version, allow_m # ---------- Dependency iteration & rewriting ---------- + @dataclass class DepRef: - layout: str # "poetry" or "pep621" - group: str # "main" or group name - name: str # raw name as in file (may include extras) - current_spec: Optional[str] # string spec; None if path/git or table - location: Tuple # references for writing (table/array and key/index) + layout: str # "poetry" or "pep621" + group: str # "main" or group name + name: str # raw name as in file (may include extras) + current_spec: str | None # string spec; None if path/git or table + location: tuple # references for writing (table/array and key/index) def _iter_poetry_deps(doc, groups: Iterable[str]) -> Iterable[DepRef]: @@ -281,8 +292,7 @@ def emit_from_array(arr, group: str): if not groups_set or "main" in groups_set: arr = project.setdefault("dependencies", tomlkit.array()) emit = list(emit_from_array(arr, "main")) - for d in emit: - yield d + yield from emit # optional groups opt = project.setdefault("optional-dependencies", tomlkit.table()) @@ -291,14 +301,11 @@ def emit_from_array(arr, group: str): if groups_set and gname not in groups_set: continue emit = list(emit_from_array(arr, gname)) - for d in emit: - yield d + yield from emit def _set_dep_spec(dep: DepRef, new_spec: str): - """ - Write back new spec to the TOML document at the stored location. - """ + """Write back new spec to the TOML document at the stored location.""" if dep.layout == "poetry": tbl, key = dep.location # type: ignore[assignment] if isinstance(tbl, dict): @@ -321,13 +328,14 @@ def _set_dep_spec(dep: DepRef, new_spec: str): # ---------- Main upgrade routine ---------- + def upgrade(pyproject: Path, opts: Options) -> int: before_text, doc = _read_doc(pyproject) layout = _layout(doc) # Which groups to consider by default groups = opts.groups or ["main", "dev"] # include Poetry dev by default - only_norm = set(_normalize_pkg_name(n) for n in (opts.only or [])) + only_norm = {_normalize_pkg_name(n) for n in (opts.only or [])} # Iterate deps iterator = _iter_poetry_deps if layout == "poetry" else _iter_pep621_deps @@ -360,7 +368,13 @@ def upgrade(pyproject: Path, opts: Options) -> int: if target_major is not None: # pick highest < target_major+1.0.0 candidates = [Version(v) for v, ok in versions.items() if ok] - within = [v for v in candidates if (v.major == target_major and (opts.include_prerelease or not v.is_prerelease))] + within = [ + v + for v in candidates + if ( + v.major == target_major and (opts.include_prerelease or not v.is_prerelease) + ) + ] if within: latest = max(within) @@ -384,22 +398,51 @@ def upgrade(pyproject: Path, opts: Options) -> int: return _write_or_diff(pyproject, before_text, after_text, opts.check) -def parse_args(argv: Optional[List[str]] = None) -> Options: - p = argparse.ArgumentParser(description="Upgrade pyproject dependency constraints to latest from PyPI.") +def parse_args(argv: list[str] | None = None) -> Options: + p = argparse.ArgumentParser( + description="Upgrade pyproject dependency constraints to latest from PyPI." + ) p.add_argument("--file", default="pyproject.toml", help="Path to pyproject.toml") - p.add_argument("--strategy", choices=["exact", "caret", "tilde", "floor"], default="caret", - help="How to express the updated constraint.") - p.add_argument("--allow-major", action="store_true", help="Allow bumping to a new MAJOR version.") - p.add_argument("--respect-major", dest="allow_major", action="store_false", - help="(default) Keep within the current major if possible.") + p.add_argument( + "--strategy", + choices=["exact", "caret", "tilde", "floor"], + default="caret", + help="How to express the updated constraint.", + ) + p.add_argument( + "--allow-major", action="store_true", help="Allow bumping to a new MAJOR version." + ) + p.add_argument( + "--respect-major", + dest="allow_major", + action="store_false", + help="(default) Keep within the current major if possible.", + ) p.set_defaults(allow_major=False) - p.add_argument("--pre", "--include-prerelease", dest="include_prerelease", action="store_true", - help="Allow pre-releases when picking the latest.") - p.add_argument("--no-prerelease", dest="include_prerelease", action="store_false", - help="(default) Exclude pre-releases.") + p.add_argument( + "--pre", + "--include-prerelease", + dest="include_prerelease", + action="store_true", + help="Allow pre-releases when picking the latest.", + ) + p.add_argument( + "--no-prerelease", + dest="include_prerelease", + action="store_false", + help="(default) Exclude pre-releases.", + ) p.set_defaults(include_prerelease=False) - p.add_argument("--groups", default="main,dev", help="Comma-separated groups: e.g., main,dev or analytics,docs") - p.add_argument("--only", default="", help="Comma-separated package names to update (normalized). Empty=all.") + p.add_argument( + "--groups", + default="main,dev", + help="Comma-separated groups: e.g., main,dev or analytics,docs", + ) + p.add_argument( + "--only", + default="", + help="Comma-separated package names to update (normalized). Empty=all.", + ) p.add_argument("--check", action="store_true", help="Dry-run: show unified diff, do not write.") p.add_argument("--timeout", type=float, default=8.0, help="HTTP timeout (seconds).") @@ -419,7 +462,7 @@ def parse_args(argv: Optional[List[str]] = None) -> Options: ) -def main(argv: Optional[List[str]] = None) -> int: +def main(argv: list[str] | None = None) -> int: opts = parse_args(argv) return upgrade(opts.file, opts) diff --git a/scripts/rename_package.py b/scripts/rename_package.py new file mode 100644 index 0000000..63e0420 --- /dev/null +++ b/scripts/rename_package.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Rename the project package. + +This helper script updates common project files when renaming a Python package. +It performs a simple search and replace on text files and renames the source +package directory. + +Usage: + python scripts/rename_package.py old_name new_name + +Both ``old_name`` and ``new_name`` should be given using the distribution name +form (hyphens allowed). The script will automatically convert them to module +names by replacing hyphens with underscores when updating imports and source +folders. +""" +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Iterable + +TEXT_EXTENSIONS = { + ".py", + ".toml", + ".md", + ".rst", + ".txt", + ".yaml", + ".yml", + ".ini", + ".cfg", + ".json", + "Makefile", + "Dockerfile", + "", +} + + +def iter_text_files(root: Path) -> Iterable[Path]: + """Yield text files under ``root`` excluding the ``.git`` directory.""" + for path in root.rglob("*"): + if path.is_dir() or ".git" in path.parts: + continue + ext = path.suffix + if path.name in TEXT_EXTENSIONS or ext in TEXT_EXTENSIONS: + try: + path.read_text() + except UnicodeDecodeError: + continue + yield path + + +def replace_in_file(path: Path, replacements: dict[str, str]) -> None: + content = path.read_text() + new_content = content + for old, new in replacements.items(): + new_content = new_content.replace(old, new) + if new_content != content: + path.write_text(new_content) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Rename Python package in project") + parser.add_argument("old_name", help="Current package name (distribution form)") + parser.add_argument("new_name", help="New package name (distribution form)") + args = parser.parse_args() + + old_dist = args.old_name + new_dist = args.new_name + old_mod = old_dist.replace("-", "_") + new_mod = new_dist.replace("-", "_") + old_cli = old_mod.replace("_", "-") + new_cli = new_mod.replace("_", "-") + + project_root = Path(__file__).resolve().parent.parent + + # Rename source directory + src = project_root / "src" + old_pkg_dir = src / old_mod + new_pkg_dir = src / new_mod + if old_pkg_dir.exists() and not new_pkg_dir.exists(): + old_pkg_dir.rename(new_pkg_dir) + + replacements = { + old_dist: new_dist, + old_mod: new_mod, + old_cli: new_cli, + } + + for file in iter_text_files(project_root): + replace_in_file(file, replacements) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_package.py b/scripts/test_package.py new file mode 100644 index 0000000..c21f1c2 --- /dev/null +++ b/scripts/test_package.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Run a basic verification of the greeting-toolkit package. + +This script performs a series of smoke tests to ensure the package works +correctly after renaming: + +1. Install the project in editable mode. +2. Import the ``greeting_toolkit`` module. +3. Execute a simple CLI command. +4. Run the test suite. +5. Build the documentation to ensure docs generation succeeds. + +The documentation is generated into a temporary directory so no repository +files are modified. +""" +from __future__ import annotations + +import os +import subprocess # noqa: S404 # nosec B404 +import sys +import tempfile +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def run(cmd: list[str], **kwargs: object) -> None: + """Execute ``cmd`` safely and stream its output.""" + if not all(isinstance(part, str) for part in cmd): + raise TypeError("Command parts must be strings") + sys.stdout.write(f"$ {' '.join(cmd)}\n") + subprocess.run(cmd, check=True, **kwargs) # noqa: S603 # nosec B603 + + +def main() -> int: + """Run verification steps and return exit code.""" + run([sys.executable, "-m", "pip", "install", "-e", str(PROJECT_ROOT)]) + run([sys.executable, "-c", "import greeting_toolkit"]) + run(["greeting-toolkit", "hello", "World"]) + + env = os.environ.copy() + env["PYTHONPATH"] = str(PROJECT_ROOT / "src") + run([sys.executable, "-m", "pytest", "--no-cov"], env=env) + + # Ensure documentation can be generated with a known version + run([sys.executable, "-m", "pip", "install", "pdoc==14.3.0"]) + with tempfile.TemporaryDirectory() as tmpdir: + run([sys.executable, "-m", "pdoc", "greeting_toolkit", "-o", tmpdir]) + + sys.stdout.write("All checks completed successfully.\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.cfg b/setup.cfg index f31143a..171d6a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,7 +19,7 @@ filterwarnings = ignore::PendingDeprecationWarning [coverage:run] -source = my_python_package +source = greeting_toolkit omit = tests/* [coverage:report] diff --git a/src/my_python_package/__init__.py b/src/greeting_toolkit/__init__.py similarity index 75% rename from src/my_python_package/__init__.py rename to src/greeting_toolkit/__init__.py index 136d421..66fceb2 100644 --- a/src/my_python_package/__init__.py +++ b/src/greeting_toolkit/__init__.py @@ -1,5 +1,4 @@ -""" -my_python_package - A minimal but production-ready Python package. +"""greeting_toolkit - A minimal but production-ready Python package. This package demonstrates a properly structured Python project with modern tooling and configuration. @@ -8,18 +7,18 @@ options for formatting, randomization, and validation. Examples: - >>> from my_python_package import hello + >>> from greeting_toolkit import hello >>> hello("World") 'Hello, World!' - >>> from my_python_package import generate_greeting + >>> from greeting_toolkit import generate_greeting >>> import re >>> # Time-based greeting will vary by time of day >>> bool(re.match(r'(Good (morning|afternoon|evening)|Hello), World!', ... generate_greeting("World", time_based=True))) True - >>> from my_python_package import random_greeting + >>> from greeting_toolkit import random_greeting >>> # Random greeting will contain the name >>> "World" in random_greeting("World") True @@ -36,7 +35,7 @@ ) # Version and author information -__version__: str = "0.2.0" +__version__: str = "0.3.0" __author__: str = "Diogo Ribeiro" # Public API @@ -49,16 +48,18 @@ "format_greeting", ] -# Enable CLI usage with python -m my_python_package + +# Enable CLI usage with python -m greeting_toolkit def _main() -> None: - """ - Entry point for module execution. + """Entry point for module execution. This function is called when the module is run directly - with `python -m my_python_package`. + with `python -m greeting_toolkit`. """ - from .cli import main import sys + + from .cli import main + sys.exit(main()) diff --git a/src/my_python_package/__main__.py b/src/greeting_toolkit/__main__.py similarity index 100% rename from src/my_python_package/__main__.py rename to src/greeting_toolkit/__main__.py diff --git a/src/my_python_package/cli.py b/src/greeting_toolkit/cli.py similarity index 99% rename from src/my_python_package/cli.py rename to src/greeting_toolkit/cli.py index 6c24df3..60fb8e2 100644 --- a/src/my_python_package/cli.py +++ b/src/greeting_toolkit/cli.py @@ -1,4 +1,4 @@ -"""Command-line interface for my_python_package.""" +"""Command-line interface for greeting_toolkit.""" import argparse import json @@ -25,7 +25,7 @@ def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser( - prog="my-python-package", + prog="greeting-toolkit", description="A simple greeting package", ) diff --git a/src/my_python_package/config.py b/src/greeting_toolkit/config.py similarity index 83% rename from src/my_python_package/config.py rename to src/greeting_toolkit/config.py index 5f964c1..315ff30 100644 --- a/src/my_python_package/config.py +++ b/src/greeting_toolkit/config.py @@ -1,17 +1,24 @@ -"""Configuration module for my_python_package.""" +"""Configuration module for greeting_toolkit.""" import json import os +from contextlib import suppress from pathlib import Path -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, cast # Default configuration -DEFAULT_CONFIG: Dict[str, Any] = { +DEFAULT_CONFIG: dict[str, Any] = { "default_greeting": "Hello", "default_punctuation": "!", "available_greetings": [ - "Hello", "Hi", "Hey", "Greetings", "Hey there", - "Howdy", "Welcome", "Good to see you" + "Hello", + "Hi", + "Hey", + "Greetings", + "Hey there", + "Howdy", + "Welcome", + "Good to see you", ], "max_name_length": 50, "formal_title": "Mr./Ms. ", @@ -19,8 +26,7 @@ class Config: - """ - Configuration handler for my_python_package. + """Configuration handler for greeting_toolkit. Manages package configuration with defaults and optional loading from file. Configuration can be saved to and loaded from JSON files. @@ -44,9 +50,8 @@ class Config: True """ - def __init__(self, config_path: Optional[Path] = None) -> None: - """ - Initialize configuration. + def __init__(self, config_path: Path | None = None) -> None: + """Initialize configuration. Args: config_path: Path to custom config file @@ -70,39 +75,35 @@ def __init__(self, config_path: Optional[Path] = None) -> None: >>> import os >>> os.unlink(tmp_path) """ - self._config: Dict[str, Any] = DEFAULT_CONFIG.copy() - self._config_path: Optional[Path] = config_path + self._config: dict[str, Any] = DEFAULT_CONFIG.copy() + self._config_path: Path | None = config_path self._load_config() def _load_config(self) -> None: - """ - Load configuration from file if exists. + """Load configuration from file if exists. Checks for a configuration file path either from initialization or - from the MY_PYTHON_PACKAGE_CONFIG environment variable. If a valid + from the GREETING_TOOLKIT_CONFIG environment variable. If a valid JSON file is found, its values are merged with the defaults. """ # Check environment variable first - env_config = os.environ.get("MY_PYTHON_PACKAGE_CONFIG") + env_config = os.environ.get("GREETING_TOOLKIT_CONFIG") if env_config: - try: + with suppress(Exception): self._config_path = Path(env_config) - except Exception: - pass # Try loading from file if self._config_path and self._config_path.exists(): try: - with open(self._config_path, "r") as f: + with open(self._config_path) as f: user_config = json.load(f) self._config.update(user_config) - except (json.JSONDecodeError, IOError, OSError): + except (json.JSONDecodeError, OSError): # Fall back to defaults on error pass - def save_config(self, path: Optional[Path] = None) -> None: - """ - Save current configuration to file. + def save_config(self, path: Path | None = None) -> None: + """Save current configuration to file. Args: path: Path to save config (defaults to current config path) @@ -132,15 +133,14 @@ def save_config(self, path: Optional[Path] = None) -> None: ... saved_data["default_greeting"] == "Bonjour" True """ - save_path: Optional[Path] = path or self._config_path + save_path: Path | None = path or self._config_path if save_path: with open(save_path, "w") as f: json.dump(self._config, f, indent=2) @property def default_greeting(self) -> str: - """ - Get default greeting. + """Get default greeting. Returns: The configured default greeting @@ -154,8 +154,7 @@ def default_greeting(self) -> str: @default_greeting.setter def default_greeting(self, value: str) -> None: - """ - Set default greeting. + """Set default greeting. Args: value: New default greeting @@ -173,8 +172,7 @@ def default_greeting(self, value: str) -> None: @property def default_punctuation(self) -> str: - """ - Get default punctuation. + """Get default punctuation. Returns: The configured default punctuation @@ -188,8 +186,7 @@ def default_punctuation(self) -> str: @default_punctuation.setter def default_punctuation(self, value: str) -> None: - """ - Set default punctuation. + """Set default punctuation. Args: value: New default punctuation @@ -206,9 +203,8 @@ def default_punctuation(self, value: str) -> None: self._config["default_punctuation"] = value @property - def available_greetings(self) -> List[str]: - """ - Get available greetings for random selection. + def available_greetings(self) -> list[str]: + """Get available greetings for random selection. Returns: List of greeting strings @@ -223,12 +219,11 @@ def available_greetings(self) -> List[str]: >>> all(isinstance(g, str) for g in greetings) True """ - return cast(List[str], self._config["available_greetings"]) + return cast(list[str], self._config["available_greetings"]) @available_greetings.setter - def available_greetings(self, value: List[str]) -> None: - """ - Set available greetings. + def available_greetings(self, value: list[str]) -> None: + """Set available greetings. Args: value: List of greeting strings @@ -258,8 +253,7 @@ def available_greetings(self, value: List[str]) -> None: @property def max_name_length(self) -> int: - """ - Get maximum name length. + """Get maximum name length. Returns: Maximum allowed length for names @@ -275,8 +269,7 @@ def max_name_length(self) -> int: @max_name_length.setter def max_name_length(self, value: int) -> None: - """ - Set maximum name length. + """Set maximum name length. Args: value: New maximum length @@ -309,8 +302,7 @@ def max_name_length(self, value: int) -> None: @property def formal_title(self) -> str: - """ - Get formal title prefix. + """Get formal title prefix. Returns: The configured formal title prefix @@ -324,8 +316,7 @@ def formal_title(self) -> str: @formal_title.setter def formal_title(self, value: str) -> None: - """ - Set formal title prefix. + """Set formal title prefix. Args: value: New formal title prefix @@ -341,9 +332,8 @@ def formal_title(self, value: str) -> None: """ self._config["formal_title"] = value - def as_dict(self) -> Dict[str, Any]: - """ - Get configuration as dictionary. + def as_dict(self) -> dict[str, Any]: + """Get configuration as dictionary. Returns: A copy of the current configuration dictionary diff --git a/src/my_python_package/core.py b/src/greeting_toolkit/core.py similarity index 88% rename from src/my_python_package/core.py rename to src/greeting_toolkit/core.py index db2fbd4..00f0de0 100644 --- a/src/my_python_package/core.py +++ b/src/greeting_toolkit/core.py @@ -1,18 +1,17 @@ -"""Core functionality of my_python_package.""" +"""Core functionality of greeting_toolkit.""" from __future__ import annotations -import random import re +import secrets from datetime import datetime from typing import Any, Dict, List, Optional, Tuple, Union, cast from .config import config -def hello(name: str, greeting: Optional[str] = None) -> str: - """ - Return a personalized greeting message. +def hello(name: str, greeting: str | None = None) -> str: + """Return a personalized greeting message. Args: name: The name to greet @@ -59,8 +58,7 @@ def hello(name: str, greeting: Optional[str] = None) -> str: def generate_greeting(name: str, formal: bool = False, time_based: bool = False) -> str: - """ - Generate a context-aware greeting. + """Generate a context-aware greeting. Args: name: The name to greet @@ -106,9 +104,8 @@ def generate_greeting(name: str, formal: bool = False, time_based: bool = False) return f"{greeting}, {title}{name}!" -def validate_name(name: str) -> Tuple[bool, Optional[str]]: - """ - Validate a name according to basic rules. +def validate_name(name: str) -> tuple[bool, str | None]: + """Validate a name according to basic rules. Rules: - Must not be empty @@ -174,9 +171,8 @@ def validate_name(name: str) -> Tuple[bool, Optional[str]]: return True, None -def create_greeting_list(names: List[str], greeting: Optional[str] = None) -> List[str]: - """ - Create a list of greetings for multiple names. +def create_greeting_list(names: list[str], greeting: str | None = None) -> list[str]: + """Create a list of greetings for multiple names. Args: names: List of names to greet @@ -211,8 +207,10 @@ def create_greeting_list(names: List[str], greeting: Optional[str] = None) -> Li def random_greeting(name: str) -> str: - """ - Generate a random greeting from a predefined list. + """Generate a random greeting from a predefined list. + + Uses :mod:`secrets` for unpredictability; while this is unnecessary for + most greetings, it avoids the weaker :mod:`random` module. Args: name: The name to greet @@ -221,39 +219,29 @@ def random_greeting(name: str) -> str: A random greeting message Examples: - >>> # Control randomness with seed - >>> import random - >>> random.seed(42) # Set seed for reproducible example - >>> # The exact greeting depends on config.available_greetings >>> greeting = random_greeting("Python") - >>> "Python" in greeting # Name should be in the greeting + >>> "Python" in greeting True >>> any(g in greeting for g in config.available_greetings) True - >>> # Verify different calls give different results - >>> # Reset the seed - >>> random.seed(None) - >>> # Get multiple greetings >>> greetings = [random_greeting("Test") for _ in range(10)] - >>> # Count unique greetings - should be more than 1 if truly random >>> len(set(greetings)) > 1 True """ greetings = config.available_greetings - return f"{random.choice(greetings)}, {name}!" + return f"{secrets.choice(greetings)}, {name}!" def format_greeting( name: str, *, - greeting: Optional[str] = None, - punctuation: Optional[str] = None, + greeting: str | None = None, + punctuation: str | None = None, uppercase: bool = False, - max_length: Optional[int] = None, + max_length: int | None = None, ) -> str: - """ - Format a greeting with various options. + """Format a greeting with various options. Args: name: The name to greet @@ -311,7 +299,7 @@ def format_greeting( # Truncate to max_length-3, then add "..." # For "Hello, John!" with max_length=10: # Take "Hello, J" (8 chars) + "..." = "Hello, J..." - truncated_base = result[:max_length - 3] + truncated_base = result[: max_length - 3] result = truncated_base + "..." # Apply uppercase after truncation @@ -322,8 +310,7 @@ def format_greeting( def set_default_greeting(greeting: str) -> None: - """ - Set the default greeting in the configuration. + """Set the default greeting in the configuration. Args: greeting: New default greeting @@ -402,7 +389,7 @@ def add_greeting(greeting: str) -> None: config.available_greetings = greetings -def get_config() -> Dict[str, Any]: +def get_config() -> dict[str, Any]: """Get the current configuration. Returns: @@ -427,4 +414,3 @@ def get_config() -> Dict[str, Any]: True """ return config.as_dict() - diff --git a/src/my_python_package/logging.py b/src/greeting_toolkit/logging.py similarity index 66% rename from src/my_python_package/logging.py rename to src/greeting_toolkit/logging.py index 7c6b7b5..0325c10 100644 --- a/src/my_python_package/logging.py +++ b/src/greeting_toolkit/logging.py @@ -1,64 +1,63 @@ -"""Logging configuration for my_python_package.""" +"""Logging configuration for greeting_toolkit.""" import logging import os import sys from pathlib import Path -from typing import Literal, Optional, Union, overload +from typing import Literal, overload # Default log format DEFAULT_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" # Package logger -logger: logging.Logger = logging.getLogger("my_python_package") +logger: logging.Logger = logging.getLogger("greeting_toolkit") # Type alias for log levels -LogLevel = Union[int, str, Literal["debug", "info", "warning", "error", "critical"]] +LogLevel = int | str | Literal["debug", "info", "warning", "error", "critical"] @overload def configure_logging( level: LogLevel = logging.INFO, - format_str: Optional[str] = None, + format_str: str | None = None, log_file: None = None, propagate: bool = False, -) -> None: - ... +) -> None: ... @overload def configure_logging( level: LogLevel = logging.INFO, - format_str: Optional[str] = None, - log_file: Union[str, Path] = ..., + format_str: str | None = None, + log_file: str | Path = ..., propagate: bool = False, -) -> None: - ... +) -> None: ... def configure_logging( level: LogLevel = logging.INFO, - format_str: Optional[str] = None, - log_file: Optional[Union[str, Path]] = None, + format_str: str | None = None, + log_file: str | Path | None = None, propagate: bool = False, ) -> None: - """ - Configure logging for the package. + """Configure logging for the package. Args: level: Logging level (default: INFO) Can be an integer level or string name like "debug", "info", etc. format_str: Log format string (default: DEFAULT_FORMAT) Uses standard Python logging format strings - log_file: Optional path to log file + log_file: Optional path to log file within current working directory If provided, logs will be written to this file in addition to console + The path is resolved and must be located inside the current working + directory to prevent writing to unexpected locations propagate: Whether to propagate to parent loggers When True, logs will also be sent to parent loggers Examples: >>> import tempfile - >>> with tempfile.NamedTemporaryFile() as temp: - ... # Configure with INFO level and a log file + >>> with tempfile.NamedTemporaryFile(dir=".") as temp: + ... # Configure with INFO level and a log file within CWD ... configure_logging(level="info", log_file=temp.name) ... # Get the configured level (20 = INFO) ... logger.level == logging.INFO @@ -96,21 +95,23 @@ def configure_logging( # Add file handler if specified if log_file: try: - file_path = Path(log_file) - # Create directory if it doesn't exist + file_path = Path(log_file).expanduser().resolve() + cwd = Path.cwd().resolve() + if not file_path.is_relative_to(cwd): + raise ValueError("log_file must be within the current working directory") + if not file_path.parent.exists(): file_path.parent.mkdir(parents=True, exist_ok=True) file_handler = logging.FileHandler(file_path) file_handler.setFormatter(formatter) logger.addHandler(file_handler) - except (IOError, OSError) as e: + except (OSError, ValueError) as e: logger.warning(f"Failed to configure log file: {e}") def get_logger(name: str) -> logging.Logger: - """ - Get a logger for a specific module. + """Get a logger for a specific module. Args: name: Module name (relative to package) @@ -122,27 +123,26 @@ def get_logger(name: str) -> logging.Logger: >>> # Get a logger for a module >>> module_logger = get_logger("core") >>> module_logger.name - 'my_python_package.core' + 'greeting_toolkit.core' >>> # Get a logger for a nested module >>> nested_logger = get_logger("utils.helpers") >>> nested_logger.name - 'my_python_package.utils.helpers' + 'greeting_toolkit.utils.helpers' """ - return logging.getLogger(f"my_python_package.{name}") + return logging.getLogger(f"greeting_toolkit.{name}") # Configure from environment variables if present def _configure_from_env() -> None: - """ - Configure logging from environment variables. + """Configure logging from environment variables. Reads: - MY_PYTHON_PACKAGE_LOG_LEVEL: Logging level (debug, info, etc.) - MY_PYTHON_PACKAGE_LOG_FILE: Path to log file + GREETING_TOOLKIT_LOG_LEVEL: Logging level (debug, info, etc.) + GREETING_TOOLKIT_LOG_FILE: Path to log file """ - env_level: Optional[str] = os.environ.get("MY_PYTHON_PACKAGE_LOG_LEVEL") - env_file: Optional[str] = os.environ.get("MY_PYTHON_PACKAGE_LOG_FILE") + env_level: str | None = os.environ.get("GREETING_TOOLKIT_LOG_LEVEL") + env_file: str | None = os.environ.get("GREETING_TOOLKIT_LOG_FILE") if env_level or env_file: configure_logging( @@ -154,4 +154,3 @@ def _configure_from_env() -> None: # Default configuration configure_logging() _configure_from_env() - diff --git a/tests/__init__.py b/tests/__init__.py index 10047b4..129a98f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -7,29 +7,29 @@ import pytest -import my_python_package +import greeting_toolkit def test_package_version(): """Test that the package has a valid version string.""" - assert hasattr(my_python_package, "__version__") - assert isinstance(my_python_package, str) + assert hasattr(greeting_toolkit, "__version__") + assert isinstance(greeting_toolkit, str) # Check that it follows semantic versioning (major.minor.patch) - assert re.match(r"^\d+\.\d+\.\d+", my_python_package.__version__) + assert re.match(r"^\d+\.\d+\.\d+", greeting_toolkit.__version__) def test_package_author(): """Test that the package has an author string.""" - assert hasattr(my_python_package, "__author__") - assert isinstance(my_python_package.__author__, str) - assert my_python_package.__author__ == "Diogo Ribeiro" + assert hasattr(greeting_toolkit, "__author__") + assert isinstance(greeting_toolkit.__author__, str) + assert greeting_toolkit.__author__ == "Diogo Ribeiro" def test_package_exports(): """Test that the package exports the expected functions.""" # Check __all__ contents - assert hasattr(my_python_package, "__all__") - assert isinstance(my_python_package.__all__, list) + assert hasattr(greeting_toolkit, "__all__") + assert isinstance(greeting_toolkit.__all__, list) # Check expected functions are in __all__ expected_functions = [ @@ -41,24 +41,24 @@ def test_package_exports(): "format_greeting", ] for func in expected_functions: - assert func in my_python_package.__all__ + assert func in greeting_toolkit.__all__ # Check functions are actually exported - for func in my_python_package.__all__: - assert hasattr(my_python_package, func) - assert callable(getattr(my_python_package, func)) + for func in greeting_toolkit.__all__: + assert hasattr(greeting_toolkit, func) + assert callable(getattr(greeting_toolkit, func)) def test_module_imports(): """Test that all package imports work properly.""" # Test importing the main module - importlib.reload(my_python_package) + importlib.reload(greeting_toolkit) # Test importing submodules - from my_python_package import config - from my_python_package import core - from my_python_package import cli - from my_python_package import logging + from greeting_toolkit import config + from greeting_toolkit import core + from greeting_toolkit import cli + from greeting_toolkit import logging # Verify the modules loaded correctly assert hasattr(config, "Config") @@ -70,10 +70,10 @@ def test_module_imports(): def test_main_function(): """Test the _main function that handles module execution.""" # Create a mock for sys.exit and cli.main - with patch("my_python_package.cli.main", return_value=42) as mock_main: + with patch("greeting_toolkit.cli.main", return_value=42) as mock_main: with patch("sys.exit") as mock_exit: # Call _main function - my_python_package._main() + greeting_toolkit._main() # Verify cli.main was called mock_main.assert_called_once() @@ -88,20 +88,20 @@ def test_direct_execution(): # but we can test the behavior indirectly by simulating it # Save original __name__ - original_name = my_python_package.__name__ + original_name = greeting_toolkit.__name__ try: # Set up the module as if it's being run directly - with patch.object(my_python_package, "__name__", "__main__"): - with patch("my_python_package._main") as mock_main: + with patch.object(greeting_toolkit, "__name__", "__main__"): + with patch("greeting_toolkit._main") as mock_main: # Re-execute the module code - exec(open(my_python_package.__file__).read(), vars(my_python_package)) + exec(open(greeting_toolkit.__file__).read(), vars(greeting_toolkit)) # Verify _main was called mock_main.assert_called_once() finally: # Restore original __name__ - my_python_package.__name__ = original_name + greeting_toolkit.__name__ = original_name def test_doctest_examples(): @@ -109,7 +109,7 @@ def test_doctest_examples(): import doctest # Run doctests on the module - result = doctest.testmod(my_python_package) + result = doctest.testmod(greeting_toolkit) # Verify all tests passed (failures == 0) assert result.failed == 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 27f9307..8eda790 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,12 +1,24 @@ """Tests for the CLI module.""" -import sys +from copy import deepcopy from io import StringIO from unittest.mock import patch import pytest -from my_python_package.cli import main, parse_args +from greeting_toolkit.cli import main, parse_args +from greeting_toolkit.config import DEFAULT_CONFIG +from greeting_toolkit.config import config as global_config + +ORIGINAL_DEFAULTS = deepcopy(DEFAULT_CONFIG) + + +@pytest.fixture(autouse=True) +def reset_config() -> None: + yield + DEFAULT_CONFIG.clear() + DEFAULT_CONFIG.update(deepcopy(ORIGINAL_DEFAULTS)) + global_config._config = deepcopy(ORIGINAL_DEFAULTS) def test_parse_args_hello(): @@ -52,13 +64,19 @@ def test_parse_args_format(): assert not args.uppercase assert args.max_length is None - args = parse_args([ - "format", "World", - "--greeting", "Hi", - "--punctuation", ".", - "--uppercase", - "--max-length", "10", - ]) + args = parse_args( + [ + "format", + "World", + "--greeting", + "Hi", + "--punctuation", + ".", + "--uppercase", + "--max-length", + "10", + ] + ) assert args.command == "format" assert args.name == "World" assert args.greeting == "Hi" @@ -89,7 +107,7 @@ def test_main_no_command(): @pytest.mark.parametrize( - "command,args,expected_output", + ("command", "args", "expected_output"), [ ( "hello", @@ -106,11 +124,11 @@ def test_main_no_command(): ["World", "--uppercase"], "HELLO, WORLD!", ), - ( - "format", - ["World", "--max-length", "10"], - "Hello, ...", - ), + ( + "format", + ["World", "--max-length", "10"], + "Hello, ...", + ), ( "multi", ["Alice", "Bob"], @@ -127,7 +145,7 @@ def test_main_commands(command, args, expected_output): @pytest.mark.parametrize( - "command,args,expected_in_output", + ("command", "args", "expected_in_output"), [ ( "random", @@ -147,3 +165,58 @@ def test_main_variable_output_commands(command, args, expected_in_output): result = main([command] + args) assert result == 0 assert expected_in_output in fake_out.getvalue() + + +def test_main_config_show(): + """Test showing configuration via CLI.""" + with patch("sys.stdout", new=StringIO()) as fake_out: + result = main(["config", "show"]) + assert result == 0 + assert "default_greeting" in fake_out.getvalue() + + +def test_main_config_set(): + """Test setting configuration values via CLI.""" + with patch("sys.stdout", new=StringIO()) as fake_out: + result = main( + [ + "config", + "set", + "--greeting", + "Hi", + "--punctuation", + ".", + "--title", + "Dr. ", + "--max-name-length", + "10", + ] + ) + output = fake_out.getvalue() + assert result == 0 + assert "Default greeting set to: Hi" in output + assert "Default punctuation set to: ." in output + assert "Formal title set to: Dr. " in output + assert "Max name length set to: 10" in output + + +def test_main_config_add_greeting(): + """Test adding a greeting via CLI.""" + with patch("sys.stdout", new=StringIO()) as fake_out: + result = main(["config", "add-greeting", "Ahoy"]) + output = fake_out.getvalue() + assert result == 0 + assert "Added greeting: Ahoy" in output + + +def test_main_config_save_and_load(tmp_path): + """Test saving and loading configuration via CLI.""" + config_file = tmp_path / "config.json" + with patch("sys.stdout", new=StringIO()) as fake_out: + result = main(["config", "save", str(config_file)]) + assert result == 0 + assert config_file.exists() + with patch("sys.stdout", new=StringIO()) as fake_out: + result = main(["config", "load", str(config_file)]) + assert result == 0 + assert "Configuration loaded from" in fake_out.getvalue() diff --git a/tests/test_config.py b/tests/test_config.py index 4959f2e..f5548ae 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,7 +8,7 @@ import pytest -from my_python_package.config import Config, DEFAULT_CONFIG +from greeting_toolkit.config import DEFAULT_CONFIG, Config def test_config_defaults(): @@ -123,7 +123,7 @@ def test_config_load_from_env(): try: # Set environment variable - with patch.dict(os.environ, {"MY_PYTHON_PACKAGE_CONFIG": tmp.name}): + with patch.dict(os.environ, {"GREETING_TOOLKIT_CONFIG": tmp.name}): config = Config() # No path provided, should use env var # Check custom values were loaded @@ -177,7 +177,7 @@ def test_config_save(): assert config_path.exists() # Load and verify - with open(config_path, "r") as f: + with open(config_path) as f: saved_config = json.load(f) assert saved_config["default_greeting"] == "Hola" @@ -201,7 +201,7 @@ def test_config_save_to_current_path(): assert config_path.exists() # Verify content - with open(config_path, "r") as f: + with open(config_path) as f: saved_config = json.load(f) assert saved_config["default_greeting"] == "Bonjour" diff --git a/tests/test_core.py b/tests/test_core.py index 7094ab7..5149335 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,7 +6,7 @@ import pytest -from my_python_package.core import ( +from greeting_toolkit.core import ( create_greeting_list, format_greeting, generate_greeting, @@ -56,7 +56,7 @@ def test_hello_invalid_type(): ) def test_generate_greeting_time_based(hour, expected_prefix): """Test time-based greetings at different hours.""" - with patch("my_python_package.core.datetime") as mock_datetime: + with patch("greeting_toolkit.core.datetime") as mock_datetime: mock_datetime.now.return_value = datetime(2025, 1, 1, hour, 0, 0) result = generate_greeting("John", time_based=True) assert result == f"{expected_prefix}, John!" @@ -117,7 +117,7 @@ def test_create_greeting_list_empty(): # Random greeting test def test_random_greeting(): """Test random greeting selection.""" - with patch("my_python_package.core.random.choice", return_value="Hi"): + with patch("greeting_toolkit.core.secrets.choice", return_value="Hi"): result = random_greeting("John") assert result == "Hi, John!" diff --git a/tests/test_docstring_coverage_script.py b/tests/test_docstring_coverage_script.py new file mode 100644 index 0000000..fb437a1 --- /dev/null +++ b/tests/test_docstring_coverage_script.py @@ -0,0 +1,14 @@ +import subprocess +import sys +from pathlib import Path + + +def test_docstring_coverage_script_runs(): + script = Path("scripts/check_docstrings_coverage.py") + result = subprocess.run( + [sys.executable, str(script), "--dir", "src/greeting_toolkit"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/test_logging.py b/tests/test_logging.py index 3934062..e1b4371 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -3,15 +3,13 @@ import io import logging import os -import sys -import tempfile +import shutil from pathlib import Path from unittest.mock import patch import pytest -from my_python_package.logging import ( - DEFAULT_FORMAT, +from greeting_toolkit.logging import ( configure_logging, get_logger, logger, @@ -39,7 +37,7 @@ def reset_logger(): def test_default_logger_configuration(): """Test the default logger configuration.""" # Verify the logger exists and has the correct name - assert logger.name == "my_python_package" + assert logger.name == "greeting_toolkit" # Verify at least one handler is configured (console output) assert len(logger.handlers) > 0 @@ -88,9 +86,7 @@ def test_configure_logging_propagate(reset_logger): def test_configure_logging_file(reset_logger): """Test configuring logging to a file.""" - # Create a temporary file for logging - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp_path = tmp.name + tmp_path = Path("test.log") try: # Configure with file @@ -102,32 +98,27 @@ def test_configure_logging_file(reset_logger): # Verify the file handler has the correct path for handler in file_handlers: - assert handler.baseFilename == tmp_path + assert Path(handler.baseFilename) == tmp_path.resolve() # Write a log message test_message = "Test log message to file" logger.info(test_message) # Verify the message was written to the file - with open(tmp_path, "r") as f: - content = f.read() - assert test_message in content + content = tmp_path.read_text() + assert test_message in content finally: - # Clean up - if os.path.exists(tmp_path): - os.unlink(tmp_path) + if tmp_path.exists(): + tmp_path.unlink() def test_configure_logging_file_directory_creation(reset_logger): """Test logging to a file in a non-existent directory.""" - # Create a temporary directory - with tempfile.TemporaryDirectory() as tmp_dir: - # Create a path to a subdirectory that doesn't exist - log_dir = Path(tmp_dir) / "logs" - log_file = log_dir / "test.log" + log_dir = Path("logs") / "subdir" + log_file = log_dir / "test.log" - # Configure logging to this file + try: configure_logging(log_file=log_file) # Verify the directory was created @@ -141,6 +132,9 @@ def test_configure_logging_file_directory_creation(reset_logger): assert log_file.exists() content = log_file.read_text() assert test_message in content + finally: + if log_dir.parent.exists(): + shutil.rmtree(log_dir.parent) def test_get_logger(): @@ -150,7 +144,7 @@ def test_get_logger(): module_logger = get_logger(module_name) # Verify the logger has the correct name - assert module_logger.name == f"my_python_package.{module_name}" + assert module_logger.name == f"greeting_toolkit.{module_name}" # Verify it's a proper logger instance assert isinstance(module_logger, logging.Logger) @@ -183,36 +177,38 @@ def test_environment_variable_configuration(): original_env = os.environ.copy() try: - # Create a temporary file for logging - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp_path = tmp.name + tmp_path = Path("env.log") - # Directly call the function we want to test - from my_python_package.logging import _configure_from_env + from greeting_toolkit.logging import _configure_from_env - # Set environment variables - os.environ["MY_PYTHON_PACKAGE_LOG_LEVEL"] = "DEBUG" - os.environ["MY_PYTHON_PACKAGE_LOG_FILE"] = tmp_path + os.environ["GREETING_TOOLKIT_LOG_LEVEL"] = "DEBUG" + os.environ["GREETING_TOOLKIT_LOG_FILE"] = str(tmp_path) - # Call the function with patching - with patch("my_python_package.logging.configure_logging") as mock_configure: + with patch("greeting_toolkit.logging.configure_logging") as mock_configure: _configure_from_env() - # Verify the function was called with the right parameters mock_configure.assert_called_once() args, kwargs = mock_configure.call_args assert kwargs.get("level") == "DEBUG" - assert kwargs.get("log_file") == tmp_path + assert Path(kwargs.get("log_file")) == tmp_path finally: - # Clean up - if os.path.exists(tmp_path): - os.unlink(tmp_path) - # Restore original environment + if tmp_path.exists(): + tmp_path.unlink() os.environ.clear() os.environ.update(original_env) +def test_configure_logging_rejects_external_path(reset_logger): + """Logging to paths outside CWD should be ignored.""" + outside = Path("/etc/passwd") + with patch.object(logger, "warning") as warn: + configure_logging(log_file=outside) + warn.assert_called() + + assert not any(isinstance(h, logging.FileHandler) for h in logger.handlers) + + def test_logging_levels(reset_logger): """Test different logging levels.""" # Create a StringIO for capturing output @@ -277,4 +273,4 @@ def test_nested_logger(): nested_logger = get_logger("module.submodule") # Verify the logger has the correct name - assert nested_logger.name == "my_python_package.module.submodule" + assert nested_logger.name == "greeting_toolkit.module.submodule" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..d28f266 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,16 @@ +"""Tests for invoking the package as a module.""" + +import subprocess +import sys + + +def test_module_execution(): + """Running ``python -m greeting_toolkit`` should execute the CLI.""" + result = subprocess.run( # noqa: S603 + [sys.executable, "-m", "greeting_toolkit", "hello", "World"], + capture_output=True, + text=True, + check=True, + ) + assert result.returncode == 0 + assert "Hello, World!" in result.stdout diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..948c6a3 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,108 @@ +"""Tests for the package __init__ module.""" + +import importlib +import re +from unittest.mock import patch + +import pytest + +import greeting_toolkit + + +def test_package_version(): + """Test that the package has a valid version string.""" + assert hasattr(greeting_toolkit, "__version__") + assert isinstance(greeting_toolkit.__version__, str) + # Check that it follows semantic versioning (major.minor.patch) + assert re.match(r"^\d+\.\d+\.\d+", greeting_toolkit.__version__) + + +def test_package_author(): + """Test that the package has an author string.""" + assert hasattr(greeting_toolkit, "__author__") + assert isinstance(greeting_toolkit.__author__, str) + assert greeting_toolkit.__author__ == "Diogo Ribeiro" + + +def test_package_exports(): + """Test that the package exports the expected functions.""" + # Check __all__ contents + assert hasattr(greeting_toolkit, "__all__") + assert isinstance(greeting_toolkit.__all__, list) + + # Check expected functions are in __all__ + expected_functions = [ + "hello", + "generate_greeting", + "random_greeting", + "validate_name", + "create_greeting_list", + "format_greeting", + ] + for func in expected_functions: + assert func in greeting_toolkit.__all__ + + # Check functions are actually exported + for func in greeting_toolkit.__all__: + assert hasattr(greeting_toolkit, func) + assert callable(getattr(greeting_toolkit, func)) + + +def test_module_imports(): + """Test that all package imports work properly.""" + # Test importing the main module + importlib.reload(greeting_toolkit) + + # Test importing submodules + from greeting_toolkit import cli, config, core, logging + + # Verify the modules loaded correctly + assert hasattr(config, "Config") + assert hasattr(core, "hello") + assert hasattr(cli, "main") + assert hasattr(logging, "logger") + + +def test_main_function(): + """Test the _main function that handles module execution.""" + # Create a mock for sys.exit and cli.main + with patch("greeting_toolkit.cli.main", return_value=42) as mock_main: + with patch("sys.exit") as mock_exit: + # Call _main function + greeting_toolkit._main() + + # Verify cli.main was called + mock_main.assert_called_once() + + # Verify sys.exit was called with the return value from cli.main + mock_exit.assert_called_once_with(42) + + +def test_direct_execution(): + """Test module execution behavior.""" + # We can't directly test __name__ == "__main__" code paths in an imported module, + # but we can test the behavior indirectly by simulating it + + # Save original __name__ + original_name = greeting_toolkit.__name__ + + try: + # Set up the module as if it's being run directly + with patch.object(greeting_toolkit, "__name__", "__main__"): + with pytest.raises(SystemExit): + exec(open(greeting_toolkit.__file__).read(), vars(greeting_toolkit)) + finally: + # Restore original __name__ + greeting_toolkit.__name__ = original_name + + +def test_doctest_examples(): + """Test that the doctest examples in __init__ work correctly.""" + import doctest + + # Run doctests on the module + result = doctest.testmod(greeting_toolkit) + + # Verify all tests passed (failures == 0) + assert result.failed == 0 + assert result.attempted > 0 # Make sure some tests were actually run diff --git a/tox.ini b/tox.ini index f15d622..24697c0 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ deps = pytest-mock pytest-sugar commands = - pytest {posargs:tests} --cov=my_python_package --cov-report=term-missing + pytest {posargs:tests} --cov=greeting_toolkit --cov-report=term-missing [testenv:lint] deps = @@ -40,4 +40,4 @@ commands = deps = pdoc commands = - pdoc --html --output-dir docs src/my_python_package + pdoc --html --output-dir docs src/greeting_toolkit