diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..86de643 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,23 @@ +# Changes here will be overwritten by Copier +_commit: 1.5.0 +_src_path: gh:pawamoy/copier-uv +author_email: dev@pawamoy.fr +author_fullname: Timothée Mazzucotelli +author_username: pawamoy +copyright_date: '2023' +copyright_holder: Timothée Mazzucotelli +copyright_holder_email: dev@pawamoy.fr +copyright_license: ISC License +insiders: true +insiders_email: insiders@pawamoy.fr +insiders_repository_name: griffe-pydantic +project_description: Griffe extension for Pydantic. +project_name: griffe-pydantic +public_release: true +python_package_command_line_name: '' +python_package_distribution_name: griffe-pydantic +python_package_import_name: griffe_pydantic +repository_name: griffe-pydantic +repository_namespace: mkdocstrings +repository_provider: github.com + diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..f9d77ee --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +PATH_add scripts diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a502284 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +github: pawamoy +ko_fi: pawamoy +polar: pawamoy +custom: +- https://www.paypal.me/pawamoy diff --git a/.github/ISSUE_TEMPLATE/1-bug.md b/.github/ISSUE_TEMPLATE/1-bug.md new file mode 100644 index 0000000..76f5577 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug.md @@ -0,0 +1,61 @@ +--- +name: Bug report +about: Create a bug report to help us improve. +title: "bug: " +labels: unconfirmed +assignees: [pawamoy] +--- + +### Description of the bug + + +### To Reproduce + + +``` +WRITE MRE / INSTRUCTIONS HERE +``` + +### Full traceback + + +
Full traceback + +```python +PASTE TRACEBACK HERE +``` + +
+ +### Expected behavior + + +### Environment information + + +```bash +python -m griffe_pydantic.debug # | xclip -selection clipboard +``` + +PASTE MARKDOWN OUTPUT HERE + +### Additional context + diff --git a/.github/ISSUE_TEMPLATE/2-feature.md b/.github/ISSUE_TEMPLATE/2-feature.md new file mode 100644 index 0000000..2df98fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project. +title: "feature: " +labels: feature +assignees: pawamoy +--- + +### Is your feature request related to a problem? Please describe. + + +### Describe the solution you'd like + + +### Describe alternatives you've considered + + +### Additional context + diff --git a/.github/ISSUE_TEMPLATE/3-docs.md b/.github/ISSUE_TEMPLATE/3-docs.md new file mode 100644 index 0000000..92ac8ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-docs.md @@ -0,0 +1,16 @@ +--- +name: Documentation update +about: Point at unclear, missing or outdated documentation. +title: "docs: " +labels: docs +assignees: pawamoy +--- + +### Is something unclear, missing or outdated in our documentation? + + +### Relevant code snippets + + +### Link to the relevant documentation section + diff --git a/.github/ISSUE_TEMPLATE/4-change.md b/.github/ISSUE_TEMPLATE/4-change.md new file mode 100644 index 0000000..dc9a8f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-change.md @@ -0,0 +1,18 @@ +--- +name: Change request +about: Suggest any other kind of change for this project. +title: "change: " +assignees: pawamoy +--- + +### Is your change request related to a problem? Please describe. + + +### Describe the solution you'd like + + +### Describe alternatives you've considered + + +### Additional context + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..f4a5617 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: +- name: I have a question / I need help + url: https://github.com/mkdocstrings/griffe-pydantic/discussions/new?category=q-a + about: Ask and answer questions in the Discussions tab. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eaf98e2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,128 @@ +name: ci + +on: + push: + pull_request: + branches: + - main + +defaults: + run: + shell: bash + +env: + LANG: en_US.utf-8 + LC_ALL: en_US.utf-8 + PYTHONIOENCODING: UTF-8 + PYTHON_VERSIONS: "" + +jobs: + + quality: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch all tags + run: git fetch --depth=1 --tags + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + + - name: Install dependencies + run: make setup + + - name: Check if the documentation builds correctly + run: make check-docs + + - name: Check the code quality + run: make check-quality + + - name: Check if the code is correctly typed + run: make check-types + + - name: Check for breaking changes in the API + run: make check-api + + exclude-test-jobs: + runs-on: ubuntu-latest + outputs: + jobs: ${{ steps.exclude-jobs.outputs.jobs }} + steps: + - id: exclude-jobs + run: | + if ${{ github.repository_owner == 'pawamoy-insiders' }}; then + echo 'jobs=[ + {"os": "macos-latest"}, + {"os": "windows-latest"}, + {"python-version": "3.10"}, + {"python-version": "3.11"}, + {"python-version": "3.12"}, + {"python-version": "3.13"}, + {"python-version": "3.14"} + ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT + else + echo 'jobs=[ + {"os": "macos-latest", "resolution": "lowest-direct"}, + {"os": "windows-latest", "resolution": "lowest-direct"} + ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT + fi + + tests: + + needs: exclude-test-jobs + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + python-version: + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + resolution: + - highest + - lowest-direct + exclude: ${{ fromJSON(needs.exclude-test-jobs.outputs.jobs) }} + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.python-version == '3.14' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + cache-suffix: py${{ matrix.python-version }} + + - name: Install dependencies + env: + UV_RESOLUTION: ${{ matrix.resolution }} + run: make setup + + - name: Run the test suite + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..619d1ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: release + +on: push +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Fetch all tags + run: git fetch --depth=1 --tags + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Setup uv + uses: astral-sh/setup-uv@v3 + - name: Build dists + if: github.repository_owner == 'pawamoy-insiders' + run: uv tool run --from build pyproject-build + - name: Upload dists artifact + uses: actions/upload-artifact@v4 + if: github.repository_owner == 'pawamoy-insiders' + with: + name: griffe-pydantic-insiders + path: ./dist/* + - name: Prepare release notes + if: github.repository_owner != 'pawamoy-insiders' + run: uv tool run git-changelog --release-notes > release-notes.md + - name: Create release with assets + uses: softprops/action-gh-release@v2 + if: github.repository_owner == 'pawamoy-insiders' + with: + files: ./dist/* + - name: Create release + uses: softprops/action-gh-release@v2 + if: github.repository_owner != 'pawamoy-insiders' + with: + body_path: release-notes.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fea047 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# editors +.idea/ +.vscode/ + +# python +*.egg-info/ +*.py[cod] +.venv/ +.venvs/ +/build/ +/dist/ + +# tools +.coverage* +/.pdm-build/ +/htmlcov/ +/site/ +uv.lock + +# cache +.cache/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a87281b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..255e0ee --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +dev@pawamoy.fr. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c2e5686 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,148 @@ +# Contributing + +Contributions are welcome, and they are greatly appreciated! +Every little bit helps, and credit will always be given. + +## Environment setup + +Nothing easier! + +Fork and clone the repository, then: + +```bash +cd griffe-pydantic +make setup +``` + +> NOTE: +> If it fails for some reason, +> you'll need to install +> [uv](https://github.com/astral-sh/uv) +> manually. +> +> You can install it with: +> +> ```bash +> curl -LsSf https://astral.sh/uv/install.sh | sh +> ``` +> +> Now you can try running `make setup` again, +> or simply `uv sync`. + +You now have the dependencies installed. + +Run `make help` to see all the available actions! + +## Tasks + +The entry-point to run commands and tasks is the `make` Python script, +located in the `scripts` directory. Try running `make` to show the available commands and tasks. +The *commands* do not need the Python dependencies to be installed, +while the *tasks* do. +The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). + +If you work in VSCode, we provide +[an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) +for the project. + +## Development + +As usual: + +1. create a new branch: `git switch -c feature-or-bugfix-name` +1. edit the code and/or the documentation + +**Before committing:** + +1. run `make format` to auto-format the code +1. run `make check` to check everything (fix any warning) +1. run `make test` to run the tests (fix any issue) +1. if you updated the documentation or the project dependencies: + 1. run `make docs` + 1. go to http://localhost:8000 and check that everything looks good +1. follow our [commit message convention](#commit-message-convention) + +If you are unsure about how to fix or ignore a warning, +just let the continuous integration fail, +and we will help you during review. + +Don't bother updating the changelog, we will take care of this. + +## Commit message convention + +Commit messages must follow our convention based on the +[Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) +or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html): + +``` +[(scope)]: Subject + +[Body] +``` + +**Subject and body must be valid Markdown.** +Subject must have proper casing (uppercase for first letter +if it makes sense), but no dot at the end, and no punctuation +in general. + +Scope and body are optional. Type can be: + +- `build`: About packaging, building wheels, etc. +- `chore`: About packaging or repo/files management. +- `ci`: About Continuous Integration. +- `deps`: Dependencies update. +- `docs`: About documentation. +- `feat`: New feature. +- `fix`: Bug fix. +- `perf`: About performance. +- `refactor`: Changes that are not features or bug fixes. +- `style`: A change in code style/format. +- `tests`: About tests. + +If you write a body, please add trailers at the end +(for example issues and PR references, or co-authors), +without relying on GitHub's flavored Markdown: + +``` +Body. + +Issue #10: https://github.com/namespace/project/issues/10 +Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15 +``` + +These "trailers" must appear at the end of the body, +without any blank lines between them. The trailer title +can contain any character except colons `:`. +We expect a full URI for each trailer, not just GitHub autolinks +(for example, full GitHub URLs for commits and issues, +not the hash or the #issue-number). + +We do not enforce a line length on commit messages summary and body, +but please avoid very long summaries, and very long lines in the body, +unless they are part of code blocks that must not be wrapped. + +## Pull requests guidelines + +Link to any related issue in the Pull Request message. + +During the review, we recommend using fixups: + +```bash +# SHA is the SHA of the commit you want to fix +git commit --fixup=SHA +``` + +Once all the changes are approved, you can squash your commits: + +```bash +git rebase -i --autosquash main +``` + +And force-push: + +```bash +git push -f +``` + +If this seems all too complicated, you can push or force-push each new commit, +and we will squash them ourselves if needed, before merging. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18d0bf3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2023, Timothée Mazzucotelli + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5e88121 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +# If you have `direnv` loaded in your shell, and allow it in the repository, +# the `make` command will point at the `scripts/make` shell script. +# This Makefile is just here to allow auto-completion in the terminal. + +actions = \ + allrun \ + changelog \ + check \ + check-api \ + check-docs \ + check-quality \ + check-types \ + clean \ + coverage \ + docs \ + docs-deploy \ + format \ + help \ + multirun \ + release \ + run \ + setup \ + test \ + vscode + +.PHONY: $(actions) +$(actions): + @python scripts/make "$@" diff --git a/README.md b/README.md index 39633e5..e45b194 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,56 @@ # griffe-pydantic -This project is currently available to [sponsors](https://github.com/sponsors/pawamoy) only. -See the documentation here: https://pawamoy.github.io/griffe-pydantic. +[![ci](https://github.com/mkdocstrings/griffe-pydantic/workflows/ci/badge.svg)](https://github.com/mkdocstrings/griffe-pydantic/actions?query=workflow%3Aci) +[![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://mkdocstrings.github.io/griffe-pydantic/) +[![pypi version](https://img.shields.io/pypi/v/griffe-pydantic.svg)](https://pypi.org/project/griffe-pydantic/) +[![gitpod](https://img.shields.io/badge/gitpod-workspace-708FCC.svg?style=flat)](https://gitpod.io/#https://github.com/mkdocstrings/griffe-pydantic) +[![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#griffe-pydantic:gitter.im) + +[Griffe](https://mkdocstrings.github.io/griffe/) extension for [Pydantic](https://github.com/pydantic/pydantic). + +## Installation + +```bash +pip install griffe-pydantic +``` + +## Usage + +### Command-line + +```bash +griffe dump mypackage -e griffe_pydantic +``` + +See [command-line usage in Griffe's documentation](https://mkdocstrings.github.io/griffe/extensions/#on-the-command-line). + +### Python + +```python +import griffe + +griffe.load( + "mypackage", + extensions=griffe.load_extensions( + [{"griffe_pydantic": {"schema": True}}] + ) +) +``` + +See [programmatic usage in Griffe's documentation](https://mkdocstrings.github.io/griffe/extensions/#programmatically). + +### MkDocs + +```yaml title="mkdocs.yml" +plugins: +- mkdocstrings: + handlers: + python: + options: + extensions: + - griffe_pydantic: + schema: true +``` + + +See [MkDocs usage in Griffe's documentation](https://mkdocstrings.github.io/griffe/extensions/#in-mkdocs). diff --git a/config/coverage.ini b/config/coverage.ini new file mode 100644 index 0000000..b56a286 --- /dev/null +++ b/config/coverage.ini @@ -0,0 +1,25 @@ +[coverage:run] +branch = true +parallel = true +source = + src/ + tests/ + +[coverage:paths] +equivalent = + src/ + .venv/lib/*/site-packages/ + .venvs/*/lib/*/site-packages/ + +[coverage:report] +precision = 2 +omit = + src/*/__init__.py + src/*/__main__.py + tests/__init__.py +exclude_lines = + pragma: no cover + if TYPE_CHECKING + +[coverage:json] +output = htmlcov/coverage.json diff --git a/config/git-changelog.toml b/config/git-changelog.toml new file mode 100644 index 0000000..57114e0 --- /dev/null +++ b/config/git-changelog.toml @@ -0,0 +1,9 @@ +bump = "auto" +convention = "angular" +in-place = true +output = "CHANGELOG.md" +parse-refs = false +parse-trailers = true +sections = ["build", "deps", "feat", "fix", "refactor"] +template = "keepachangelog" +versioning = "pep440" diff --git a/config/mypy.ini b/config/mypy.ini new file mode 100644 index 0000000..814e2ac --- /dev/null +++ b/config/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +ignore_missing_imports = true +exclude = tests/fixtures/ +warn_unused_ignores = true +show_error_codes = true diff --git a/config/pytest.ini b/config/pytest.ini new file mode 100644 index 0000000..052a2f1 --- /dev/null +++ b/config/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +python_files = + test_*.py +addopts = + --cov + --cov-config config/coverage.ini +testpaths = + tests + +# action:message_regex:warning_class:module_regex:line +filterwarnings = + error + # TODO: remove once pytest-xdist 4 is released + ignore:.*rsyncdir:DeprecationWarning:xdist diff --git a/config/ruff.toml b/config/ruff.toml new file mode 100644 index 0000000..6f6b119 --- /dev/null +++ b/config/ruff.toml @@ -0,0 +1,84 @@ +target-version = "py39" +line-length = 120 + +[lint] +exclude = [ + "tests/fixtures/*.py", +] +select = [ + "A", "ANN", "ARG", + "B", "BLE", + "C", "C4", + "COM", + "D", "DTZ", + "E", "ERA", "EXE", + "F", "FBT", + "G", + "I", "ICN", "INP", "ISC", + "N", + "PGH", "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", "PYI", + "Q", + "RUF", "RSE", "RET", + "S", "SIM", "SLF", + "T", "T10", "T20", "TCH", "TID", "TRY", + "UP", + "W", + "YTT", +] +ignore = [ + "A001", # Variable is shadowing a Python builtin + "ANN101", # Missing type annotation for self + "ANN102", # Missing type annotation for cls + "ANN204", # Missing return type annotation for special method __str__ + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "ARG005", # Unused lambda argument + "C901", # Too complex + "D105", # Missing docstring in magic method + "D417", # Missing argument description in the docstring + "E501", # Line too long + "ERA001", # Commented out code + "G004", # Logging statement uses f-string + "PLR0911", # Too many return statements + "PLR0912", # Too many branches + "PLR0913", # Too many arguments to function call + "PLR0915", # Too many statements + "SLF001", # Private member accessed + "TRY003", # Avoid specifying long messages outside the exception class +] + +[lint.per-file-ignores] +"src/*/cli.py" = [ + "T201", # Print statement +] +"src/*/debug.py" = [ + "T201", # Print statement +] +"scripts/*.py" = [ + "INP001", # File is part of an implicit namespace package + "T201", # Print statement +] +"tests/*.py" = [ + "ARG005", # Unused lambda argument + "FBT001", # Boolean positional arg in function definition + "PLR2004", # Magic value used in comparison + "S101", # Use of assert detected +] + +[lint.flake8-quotes] +docstring-quotes = "double" + +[lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[lint.isort] +known-first-party = ["griffe_pydantic"] + +[lint.pydocstyle] +convention = "google" + +[format] +exclude = [ + "tests/fixtures/*.py", +] +docstring-code-format = true +docstring-code-line-length = 80 diff --git a/config/vscode/launch.json b/config/vscode/launch.json new file mode 100644 index 0000000..e328838 --- /dev/null +++ b/config/vscode/launch.json @@ -0,0 +1,47 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "python (current file)", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "docs", + "type": "debugpy", + "request": "launch", + "module": "mkdocs", + "justMyCode": false, + "args": [ + "serve", + "-v" + ] + }, + { + "name": "test", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "justMyCode": false, + "args": [ + "-c=config/pytest.ini", + "-vvv", + "--no-cov", + "--dist=no", + "tests", + "-k=${input:tests_selection}" + ] + } + ], + "inputs": [ + { + "id": "tests_selection", + "type": "promptString", + "description": "Tests selection", + "default": "" + } + ] +} \ No newline at end of file diff --git a/config/vscode/settings.json b/config/vscode/settings.json new file mode 100644 index 0000000..949856d --- /dev/null +++ b/config/vscode/settings.json @@ -0,0 +1,33 @@ +{ + "files.watcherExclude": { + "**/.venv*/**": true, + "**/.venvs*/**": true, + "**/venv*/**": true + }, + "mypy-type-checker.args": [ + "--config-file=config/mypy.ini" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "--config-file=config/pytest.ini" + ], + "ruff.enable": true, + "ruff.format.args": [ + "--config=config/ruff.toml" + ], + "ruff.lint.args": [ + "--config=config/ruff.toml" + ], + "yaml.schemas": { + "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" + }, + "yaml.customTags": [ + "!ENV scalar", + "!ENV sequence", + "!relative scalar", + "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", + "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", + "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" + ] +} \ No newline at end of file diff --git a/config/vscode/tasks.json b/config/vscode/tasks.json new file mode 100644 index 0000000..73145ee --- /dev/null +++ b/config/vscode/tasks.json @@ -0,0 +1,97 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "changelog", + "type": "process", + "command": "scripts/make", + "args": ["changelog"] + }, + { + "label": "check", + "type": "process", + "command": "scripts/make", + "args": ["check"] + }, + { + "label": "check-quality", + "type": "process", + "command": "scripts/make", + "args": ["check-quality"] + }, + { + "label": "check-types", + "type": "process", + "command": "scripts/make", + "args": ["check-types"] + }, + { + "label": "check-docs", + "type": "process", + "command": "scripts/make", + "args": ["check-docs"] + }, + { + "label": "check-api", + "type": "process", + "command": "scripts/make", + "args": ["check-api"] + }, + { + "label": "clean", + "type": "process", + "command": "scripts/make", + "args": ["clean"] + }, + { + "label": "docs", + "type": "process", + "command": "scripts/make", + "args": ["docs"] + }, + { + "label": "docs-deploy", + "type": "process", + "command": "scripts/make", + "args": ["docs-deploy"] + }, + { + "label": "format", + "type": "process", + "command": "scripts/make", + "args": ["format"] + }, + { + "label": "release", + "type": "process", + "command": "scripts/make", + "args": ["release", "${input:version}"] + }, + { + "label": "setup", + "type": "process", + "command": "scripts/make", + "args": ["setup"] + }, + { + "label": "test", + "type": "process", + "command": "scripts/make", + "args": ["test", "coverage"], + "group": "test" + }, + { + "label": "vscode", + "type": "process", + "command": "scripts/make", + "args": ["vscode"] + } + ], + "inputs": [ + { + "id": "version", + "type": "promptString", + "description": "Version" + } + ] +} \ No newline at end of file diff --git a/docs/.overrides/main.html b/docs/.overrides/main.html new file mode 100644 index 0000000..1e95685 --- /dev/null +++ b/docs/.overrides/main.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block announce %} + + Fund this project through + sponsorship + + {% include ".icons/octicons/heart-fill-16.svg" %} + — + + Follow + @pawamoy on + + + {% include ".icons/fontawesome/brands/mastodon.svg" %} + + Fosstodon + + for updates +{% endblock %} diff --git a/docs/.overrides/partials/comments.html b/docs/.overrides/partials/comments.html new file mode 100644 index 0000000..ec51086 --- /dev/null +++ b/docs/.overrides/partials/comments.html @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..786b75d --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +--8<-- "CHANGELOG.md" diff --git a/docs/code_of_conduct.md b/docs/code_of_conduct.md new file mode 100644 index 0000000..01f2ea2 --- /dev/null +++ b/docs/code_of_conduct.md @@ -0,0 +1 @@ +--8<-- "CODE_OF_CONDUCT.md" diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..ea38c9b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +--8<-- "CONTRIBUTING.md" diff --git a/docs/credits.md b/docs/credits.md new file mode 100644 index 0000000..f758db8 --- /dev/null +++ b/docs/credits.md @@ -0,0 +1,10 @@ +--- +hide: +- toc +--- + + +```python exec="yes" +--8<-- "scripts/gen_credits.py" +``` + diff --git a/docs/css/insiders.css b/docs/css/insiders.css new file mode 100644 index 0000000..e7b9c74 --- /dev/null +++ b/docs/css/insiders.css @@ -0,0 +1,124 @@ +@keyframes heart { + + 0%, + 40%, + 80%, + 100% { + transform: scale(1); + } + + 20%, + 60% { + transform: scale(1.15); + } +} + +@keyframes vibrate { + 0%, 2%, 4%, 6%, 8%, 10%, 12%, 14%, 16%, 18% { + -webkit-transform: translate3d(-2px, 0, 0); + transform: translate3d(-2px, 0, 0); + } + 1%, 3%, 5%, 7%, 9%, 11%, 13%, 15%, 17%, 19% { + -webkit-transform: translate3d(2px, 0, 0); + transform: translate3d(2px, 0, 0); + } + 20%, 100% { + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} + +.heart { + color: #e91e63; +} + +.pulse { + animation: heart 1000ms infinite; +} + +.vibrate { + animation: vibrate 2000ms infinite; +} + +.new-feature svg { + fill: var(--md-accent-fg-color) !important; +} + +a.insiders { + color: #e91e63; +} + +.sponsorship-list { + width: 100%; +} + +.sponsorship-item { + border-radius: 100%; + display: inline-block; + height: 1.6rem; + margin: 0.1rem; + overflow: hidden; + width: 1.6rem; +} + +.sponsorship-item:focus, .sponsorship-item:hover { + transform: scale(1.1); +} + +.sponsorship-item img { + filter: grayscale(100%) opacity(75%); + height: auto; + width: 100%; +} + +.sponsorship-item:focus img, .sponsorship-item:hover img { + filter: grayscale(0); +} + +.sponsorship-item.private { + background: var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + font-size: .6rem; + font-weight: 700; + line-height: 1.6rem; + text-align: center; +} + +.mastodon { + color: #897ff8; + border-radius: 100%; + box-shadow: inset 0 0 0 .05rem currentcolor; + display: inline-block; + height: 1.2rem !important; + padding: .25rem; + transition: all .25s; + vertical-align: bottom !important; + width: 1.2rem; +} + +.premium-sponsors { + text-align: center; +} + +#silver-sponsors img { + height: 140px; +} + +#bronze-sponsors img { + height: 140px; +} + +#bronze-sponsors p { + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +#bronze-sponsors a { + display: block; + flex-shrink: 0; +} + +.sponsors-total { + font-weight: bold; +} \ No newline at end of file diff --git a/docs/css/material.css b/docs/css/material.css new file mode 100644 index 0000000..9e8c14a --- /dev/null +++ b/docs/css/material.css @@ -0,0 +1,4 @@ +/* More space at the bottom of the page. */ +.md-main__inner { + margin-bottom: 1.5rem; +} diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css new file mode 100644 index 0000000..88c7357 --- /dev/null +++ b/docs/css/mkdocstrings.css @@ -0,0 +1,27 @@ +/* Indentation. */ +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: .05rem solid var(--md-typeset-table-color); +} + +/* Mark external links as such. */ +a.external::after, +a.autorefs-external::after { + /* https://primer.style/octicons/arrow-up-right-24 */ + mask-image: url('data:image/svg+xml,'); + -webkit-mask-image: url('data:image/svg+xml,'); + content: ' '; + + display: inline-block; + vertical-align: middle; + position: relative; + + height: 1em; + width: 1em; + background-color: currentColor; +} + +a.external:hover::after, +a.autorefs-external:hover::after { + background-color: var(--md-accent-fg-color); +} \ No newline at end of file diff --git a/docs/examples/model_ext.py b/docs/examples/model_ext.py new file mode 100644 index 0000000..6466777 --- /dev/null +++ b/docs/examples/model_ext.py @@ -0,0 +1,28 @@ +from pydantic import field_validator, ConfigDict, BaseModel, Field + + +class ExampleModel(BaseModel): + """An example model.""" + + model_config = ConfigDict(frozen=False) + + field_without_default: str + """Shows the *[Required]* marker in the signature.""" + + field_plain_with_validator: int = 100 + """Show standard field with type annotation.""" + + field_with_validator_and_alias: str = Field("FooBar", alias="BarFoo", validation_alias="BarFoo") + """Shows corresponding validator with link/anchor.""" + + field_with_constraints_and_description: int = Field( + default=5, ge=0, le=100, description="Shows constraints within doc string." + ) + + @field_validator("field_with_validator_and_alias", "field_without_default", mode="before") + @classmethod + def check_max_length_ten(cls, v) -> str: + """Show corresponding field with link/anchor.""" + if len(v) >= 10: + raise ValueError("No more than 10 characters allowed") + return v diff --git a/docs/examples/model_noext.py b/docs/examples/model_noext.py new file mode 120000 index 0000000..7b1b4ee --- /dev/null +++ b/docs/examples/model_noext.py @@ -0,0 +1 @@ +model_ext.py \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..77b213f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,45 @@ +--- +hide: +- feedback +--- + +--8<-- "README.md" + + + +## Examples + +/// tab | Pydantic model + +```python exec="1" result="python" +print('--8<-- "docs/examples/model_ext.py"') +``` + +/// + +/// tab | Without extension + +::: model_noext.ExampleModel + options: + heading_level: 3 + +/// + + +/// tab | With extension + +::: model_ext.ExampleModel + options: + heading_level: 3 + extensions: + - griffe_pydantic + +/// diff --git a/docs/insiders/changelog.md b/docs/insiders/changelog.md new file mode 100644 index 0000000..87241a1 --- /dev/null +++ b/docs/insiders/changelog.md @@ -0,0 +1,19 @@ +# Changelog + +## griffe-pydantic Insiders + +### 1.0.1 May 27, 2024 { id="1.0.1" } + +- Depend on Griffe 0.38 minimum +- Detect inheritance when `BaseModel` is imported from `pydantic.main` +- Don't crash on keyword arguments in `@field_validator` decorators + +### 1.0.0 March 20, 2023 { id="1.0.0" } + +- Support Pydantic v2 +- Support both static and dynamic analysis +- Detect when classes inherit from Pydantic models + +### 1.0.0a0 July 13, 2023 { id="1.0.0a0" } + +- Release first Insiders version (alpha) diff --git a/docs/insiders/goals.yml b/docs/insiders/goals.yml new file mode 100644 index 0000000..c03d225 --- /dev/null +++ b/docs/insiders/goals.yml @@ -0,0 +1,16 @@ +goals: + 500: + name: PlasmaVac User Guide + features: [] + 1000: + name: GraviFridge Fluid Renewal + features: + - name: "[Project] Griffe extension for Pydantic" + ref: / + since: 2023/07/13 + 1500: + name: HyperLamp Navigation Tips + features: [] + 2000: + name: FusionDrive Ejection Configuration + features: [] diff --git a/docs/insiders/index.md b/docs/insiders/index.md new file mode 100644 index 0000000..1721b53 --- /dev/null +++ b/docs/insiders/index.md @@ -0,0 +1,239 @@ +# Insiders + +*griffe-pydantic* follows the **sponsorware** release strategy, which means +that new features are first exclusively released to sponsors as part of +[Insiders][insiders]. Read on to learn [what sponsorships achieve][sponsorship], +[how to become a sponsor][sponsors] to get access to Insiders, +and [what's in it for you][features]! + +## What is Insiders? + +*griffe-pydantic Insiders* is a private fork of *griffe-pydantic*, hosted as +a private GitHub repository. Almost[^1] [all new features][features] +are developed as part of this fork, which means that they are immediately +available to all eligible sponsors, as they are made collaborators of this +repository. + + [^1]: + In general, every new feature is first exclusively released to sponsors, but + sometimes upstream dependencies enhance + existing features that must be supported by *griffe-pydantic*. + +Every feature is tied to a [funding goal][funding] in monthly subscriptions. When a +funding goal is hit, the features that are tied to it are merged back into +*griffe-pydantic* and released for general availability, making them available +to all users. Bugfixes are always released in tandem. + +Sponsorships start as low as [**$10 a month**][sponsors].[^2] + + [^2]: + Note that $10 a month is the minimum amount to become eligible for + Insiders. While GitHub Sponsors also allows to sponsor lower amounts or + one-time amounts, those can't be granted access to Insiders due to + technical reasons. Such contributions are still very much welcome as + they help ensuring the project's sustainability. + + +## What sponsorships achieve + +Sponsorships make this project sustainable, as they buy the maintainers of this +project time – a very scarce resource – which is spent on the development of new +features, bug fixing, stability improvement, issue triage and general support. +The biggest bottleneck in Open Source is time.[^3] + + [^3]: + Making an Open Source project sustainable is exceptionally hard: maintainers + burn out, projects are abandoned. That's not great and very unpredictable. + The sponsorware model ensures that if you decide to use *griffe-pydantic*, + you can be sure that bugs are fixed quickly and new features are added + regularly. + +If you're unsure if you should sponsor this project, check out the list of +[completed funding goals][goals completed] to learn whether you're already using features that +were developed with the help of sponsorships. You're most likely using at least +a handful of them, [thanks to our awesome sponsors][sponsors]! + +## What's in it for me? + +```python exec="1" session="insiders" +data_source = "docs/insiders/goals.yml" +``` + + +```python exec="1" session="insiders" idprefix="" +--8<-- "scripts/insiders.py" + +if unreleased_features: + print( + "The moment you [become a sponsor](#how-to-become-a-sponsor), you'll get **immediate " + f"access to {len(unreleased_features)} additional features** that you can start using right away, and " + "which are currently exclusively available to sponsors:\n" + ) + + for feature in unreleased_features: + feature.render(badge=True) + + print( + "\n\nThese are just the features related to this project. " + "[See the complete feature list on the author's main Insiders page](https://pawamoy.github.io/insiders/#whats-in-it-for-me)." + ) +else: + print( + "The moment you [become a sponsor](#how-to-become-a-sponsor), you'll get immediate " + "access to all released features that you can start using right away, and " + "which are exclusively available to sponsors. At this moment, there are no " + "Insiders features for this project, but checkout the [next funding goals](#goals) " + "to see what's coming, as well as **[the feature list for all Insiders projects](https://pawamoy.github.io/insiders/#whats-in-it-for-me).**" + ) +``` + + +## How to become a sponsor + +Thanks for your interest in sponsoring! In order to become an eligible sponsor +with your GitHub account, visit [pawamoy's sponsor profile][github sponsor profile], +and complete a sponsorship of **$10 a month or more**. +You can use your individual or organization GitHub account for sponsoring. + +Sponsorships lower than $10 a month are also very much appreciated, and useful. +They won't grant you access to Insiders, but they will be counted towards reaching sponsorship goals. +*Every* sponsorship helps us implementing new features and releasing them to the public. + +**Important**: If you're sponsoring **[@pawamoy][github sponsor profile]** +through a GitHub organization, please send a short email +to insiders@pawamoy.fr with the name of your +organization and the GitHub account of the individual +that should be added as a collaborator.[^4] + +You can cancel your sponsorship anytime.[^5] + + [^4]: + It's currently not possible to grant access to each member of an + organization, as GitHub only allows for adding users. Thus, after + sponsoring, please send an email to insiders@pawamoy.fr, stating which + account should become a collaborator of the Insiders repository. We're + working on a solution which will make access to organizations much simpler. + To ensure that access is not tied to a particular individual GitHub account, + create a bot account (i.e. a GitHub account that is not tied to a specific + individual), and use this account for the sponsoring. After being added to + the list of collaborators, the bot account can create a private fork of the + private Insiders GitHub repository, and grant access to all members of the + organizations. + + [^5]: + If you cancel your sponsorship, GitHub schedules a cancellation request + which will become effective at the end of the billing cycle. This means + that even though you cancel your sponsorship, you will keep your access to + Insiders as long as your cancellation isn't effective. All charges are + processed by GitHub through Stripe. As we don't receive any information + regarding your payment, and GitHub doesn't offer refunds, sponsorships are + non-refundable. + + +[:octicons-heart-fill-24:{ .pulse }   Join our awesome sponsors](https://github.com/sponsors/pawamoy){ .md-button .md-button--primary } + +
+
+
+
+
+
+
+ +
+ + + If you sponsor publicly, you're automatically added here with a link to + your profile and avatar to show your support for *griffe-pydantic*. + Alternatively, if you wish to keep your sponsorship private, you'll be a + silent +1. You can select visibility during checkout and change it + afterwards. + + +## Funding + +### Goals + +The following section lists all funding goals. Each goal contains a list of +features prefixed with a checkmark symbol, denoting whether a feature is +:octicons-check-circle-fill-24:{ style="color: #00e676" } already available or +:octicons-check-circle-fill-24:{ style="color: var(--md-default-fg-color--lightest)" } planned, +but not yet implemented. When the funding goal is hit, +the features are released for general availability. + +```python exec="1" session="insiders" idprefix="" +for goal in goals.values(): + if not goal.complete: + goal.render() +``` + +### Goals completed + +This section lists all funding goals that were previously completed, which means +that those features were part of Insiders, but are now generally available and +can be used by all users. + +```python exec="1" session="insiders" idprefix="" +for goal in goals.values(): + if goal.complete: + goal.render() +``` + +## Frequently asked questions + +### Compatibility + +> We're building an open source project and want to allow outside collaborators +to use *griffe-pydantic* locally without having access to Insiders. +Is this still possible? + +Yes. Insiders is compatible with *griffe-pydantic*. Almost all new features +and configuration options are either backward-compatible or implemented behind +feature flags. Most Insiders features enhance the overall experience, +though while these features add value for the users of your project, they +shouldn't be necessary for previewing when making changes to content. + +### Payment + +> We don't want to pay for sponsorship every month. Are there any other options? + +Yes. You can sponsor on a yearly basis by [switching your GitHub account to a +yearly billing cycle][billing cycle]. If for some reason you cannot do that, you +could also create a dedicated GitHub account with a yearly billing cycle, which +you only use for sponsoring (some sponsors already do that). + +If you have any problems or further questions, please reach out to insiders@pawamoy.fr. + +### Terms + +> Are we allowed to use Insiders under the same terms and conditions as +*griffe-pydantic*? + +Yes. Whether you're an individual or a company, you may use *griffe-pydantic +Insiders* precisely under the same terms as *griffe-pydantic*, which are given +by the [ISC License][license]. However, we kindly ask you to respect our +**fair use policy**: + +- Please **don't distribute the source code** of Insiders. You may freely use + it for public, private or commercial projects, privately fork or mirror it, + but please don't make the source code public, as it would counteract the + sponsorware strategy. + +- If you cancel your subscription, you're automatically removed as a + collaborator and will miss out on all future updates of Insiders. However, you + may **use the latest version** that's available to you **as long as you like**. + Just remember that [GitHub deletes private forks][private forks]. + +[insiders]: #what-is-insiders +[sponsorship]: #what-sponsorships-achieve +[sponsors]: #how-to-become-a-sponsor +[features]: #whats-in-it-for-me +[funding]: #funding +[goals completed]: #goals-completed +[github sponsor profile]: https://github.com/sponsors/pawamoy +[billing cycle]: https://docs.github.com/en/github/setting-up-and-managing-billing-and-payments-on-github/changing-the-duration-of-your-billing-cycle +[license]: ../license.md +[private forks]: https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/removing-a-collaborator-from-a-personal-repository + + + diff --git a/docs/insiders/installation.md b/docs/insiders/installation.md new file mode 100644 index 0000000..aa37ea3 --- /dev/null +++ b/docs/insiders/installation.md @@ -0,0 +1,88 @@ +--- +title: Getting started with Insiders +--- + +# Getting started with Insiders + +*griffe-pydantic Insiders* is a compatible drop-in replacement for *griffe-pydantic*, +and can be installed similarly using `pip` or `git`. +Note that in order to access the Insiders repository, +you need to [become an eligible sponsor] of @pawamoy on GitHub. + + [become an eligible sponsor]: index.md#how-to-become-a-sponsor + +## Installation + +### with PyPI Insiders + +[PyPI Insiders](https://pawamoy.github.io/pypi-insiders/) +is a tool that helps you keep up-to-date versions +of Insiders projects in the PyPI index of your choice +(self-hosted, Google registry, Artifactory, etc.). + +See [how to install it](https://pawamoy.github.io/pypi-insiders/#installation) +and [how to use it](https://pawamoy.github.io/pypi-insiders/#usage). + +**We kindly ask that you do not upload the distributions to public registries, +as it is against our [Terms of use](index.md#terms).** + +### with pip (ssh/https) + +*griffe-pydantic Insiders* can be installed with `pip` [using SSH][using ssh]: + +```bash +pip install git+ssh://git@github.com/pawamoy-insiders/griffe-pydantic.git +``` + + [using ssh]: https://docs.github.com/en/authentication/connecting-to-github-with-ssh + +Or using HTTPS: + +```bash +pip install git+https://${GH_TOKEN}@github.com/pawamoy-insiders/griffe-pydantic.git +``` + +>? NOTE: **How to get a GitHub personal access token** +> The `GH_TOKEN` environment variable is a GitHub token. +> It can be obtained by creating a [personal access token] for +> your GitHub account. It will give you access to the Insiders repository, +> programmatically, from the command line or GitHub Actions workflows: +> +> 1. Go to https://github.com/settings/tokens +> 2. Click on [Generate a new token] +> 3. Enter a name and select the [`repo`][scopes] scope +> 4. Generate the token and store it in a safe place +> +> [personal access token]: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token +> [Generate a new token]: https://github.com/settings/tokens/new +> [scopes]: https://docs.github.com/en/developers/apps/scopes-for-oauth-apps#available-scopes +> +> Note that the personal access +> token must be kept secret at all times, as it allows the owner to access your +> private repositories. + +### with Git + +Of course, you can use *griffe-pydantic Insiders* directly using Git: + +``` +git clone git@github.com:pawamoy-insiders/griffe-pydantic +``` + +When cloning with Git, the package must be installed: + +``` +pip install -e griffe-pydantic +``` + +## Upgrading + +When upgrading Insiders, you should always check the version of *griffe-pydantic* +which makes up the first part of the version qualifier. For example, a version like +`8.x.x.4.x.x` means that Insiders `4.x.x` is currently based on `8.x.x`. + +If the major version increased, it's a good idea to consult the [changelog] +and go through the steps to ensure your configuration is up to date and +all necessary changes have been made. + + [changelog]: ./changelog.md diff --git a/docs/js/feedback.js b/docs/js/feedback.js new file mode 100644 index 0000000..f97321a --- /dev/null +++ b/docs/js/feedback.js @@ -0,0 +1,14 @@ +const feedback = document.forms.feedback; +feedback.hidden = false; + +feedback.addEventListener("submit", function(ev) { + ev.preventDefault(); + const commentElement = document.getElementById("feedback"); + commentElement.style.display = "block"; + feedback.firstElementChild.disabled = true; + const data = ev.submitter.getAttribute("data-md-value"); + const note = feedback.querySelector(".md-feedback__note [data-md-value='" + data + "']"); + if (note) { + note.hidden = false; + } +}) diff --git a/docs/js/insiders.js b/docs/js/insiders.js new file mode 100644 index 0000000..8bb6848 --- /dev/null +++ b/docs/js/insiders.js @@ -0,0 +1,74 @@ +function humanReadableAmount(amount) { + const strAmount = String(amount); + if (strAmount.length >= 4) { + return `${strAmount.slice(0, strAmount.length - 3)},${strAmount.slice(-3)}`; + } + return strAmount; +} + +function getJSON(url, callback) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.responseType = 'json'; + xhr.onload = function () { + var status = xhr.status; + if (status === 200) { + callback(null, xhr.response); + } else { + callback(status, xhr.response); + } + }; + xhr.send(); +} + +function updatePremiumSponsors(dataURL, rank) { + let capRank = rank.charAt(0).toUpperCase() + rank.slice(1); + getJSON(dataURL + `/sponsors${capRank}.json`, function (err, sponsors) { + const sponsorsDiv = document.getElementById(`${rank}-sponsors`); + if (sponsors.length > 0) { + let html = ''; + html += `${capRank} sponsors

` + sponsors.forEach(function (sponsor) { + html += ` + + ${sponsor.name} + + ` + }); + html += '

' + sponsorsDiv.innerHTML = html; + } + }); +} + +function updateInsidersPage(author_username) { + const sponsorURL = `https://github.com/sponsors/${author_username}` + const dataURL = `https://raw.githubusercontent.com/${author_username}/sponsors/main`; + getJSON(dataURL + '/numbers.json', function (err, numbers) { + document.getElementById('sponsors-count').innerHTML = numbers.count; + Array.from(document.getElementsByClassName('sponsors-total')).forEach(function (element) { + element.innerHTML = '$ ' + humanReadableAmount(numbers.total); + }); + getJSON(dataURL + '/sponsors.json', function (err, sponsors) { + const sponsorsElem = document.getElementById('sponsors'); + const privateSponsors = numbers.count - sponsors.length; + sponsors.forEach(function (sponsor) { + sponsorsElem.innerHTML += ` + + + + `; + }); + if (privateSponsors > 0) { + sponsorsElem.innerHTML += ` + + +${privateSponsors} + + `; + } + }); + }); + updatePremiumSponsors(dataURL, "gold"); + updatePremiumSponsors(dataURL, "silver"); + updatePremiumSponsors(dataURL, "bronze"); +} diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000..e81c0ed --- /dev/null +++ b/docs/license.md @@ -0,0 +1,10 @@ +--- +hide: +- feedback +--- + +# License + +``` +--8<-- "LICENSE" +``` diff --git a/duties.py b/duties.py new file mode 100644 index 0000000..367fc17 --- /dev/null +++ b/duties.py @@ -0,0 +1,235 @@ +"""Development tasks.""" + +from __future__ import annotations + +import os +import sys +from contextlib import contextmanager +from importlib.metadata import version as pkgversion +from pathlib import Path +from typing import TYPE_CHECKING + +from duty import duty, tools + +if TYPE_CHECKING: + from collections.abc import Iterator + + from duty.context import Context + + +PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) +PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) +PY_SRC = " ".join(PY_SRC_LIST) +CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""} +WINDOWS = os.name == "nt" +PTY = not WINDOWS and not CI +MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" + + +def pyprefix(title: str) -> str: # noqa: D103 + if MULTIRUN: + prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" + return f"{prefix:14}{title}" + return title + + +@contextmanager +def material_insiders() -> Iterator[bool]: # noqa: D103 + if "+insiders" in pkgversion("mkdocs-material"): + os.environ["MATERIAL_INSIDERS"] = "true" + try: + yield True + finally: + os.environ.pop("MATERIAL_INSIDERS") + else: + yield False + + +@duty +def changelog(ctx: Context, bump: str = "") -> None: + """Update the changelog in-place with latest commits. + + Parameters: + bump: Bump option passed to git-changelog. + """ + ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") + + +@duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) +def check(ctx: Context) -> None: + """Check it all!""" + + +@duty +def check_quality(ctx: Context) -> None: + """Check the code quality.""" + ctx.run( + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), + title=pyprefix("Checking code quality"), + ) + + +@duty +def check_docs(ctx: Context) -> None: + """Check if the documentation builds correctly.""" + Path("htmlcov").mkdir(parents=True, exist_ok=True) + Path("htmlcov/index.html").touch(exist_ok=True) + with material_insiders(): + ctx.run( + tools.mkdocs.build(strict=True, verbose=True), + title=pyprefix("Building documentation"), + ) + + +@duty +def check_types(ctx: Context) -> None: + """Check that the code is correctly typed.""" + ctx.run( + tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), + title=pyprefix("Type-checking"), + ) + + +@duty +def check_api(ctx: Context, *cli_args: str) -> None: + """Check for API breaking changes.""" + ctx.run( + tools.griffe.check("griffe_pydantic", search=["src"], color=True).add_args(*cli_args), + title="Checking for API breaking changes", + nofail=True, + ) + + +@duty +def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: + """Serve the documentation (localhost:8000). + + Parameters: + host: The host to serve the docs from. + port: The port to serve the docs on. + """ + with material_insiders(): + ctx.run( + tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), + title="Serving documentation", + capture=False, + ) + + +@duty +def docs_deploy(ctx: Context, *, force: bool = False) -> None: + """Deploy the documentation to GitHub pages. + + Parameters: + force: Whether to force deployment, even from non-Insiders version. + """ + os.environ["DEPLOY"] = "true" + with material_insiders() as insiders: + if not insiders: + ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") + origin = ctx.run("git config --get remote.origin.url", silent=True, allow_overrides=False) + if "pawamoy-insiders/griffe-pydantic" in origin: + ctx.run( + "git remote add upstream git@github.com:mkdocstrings/griffe-pydantic", + silent=True, + nofail=True, + allow_overrides=False, + ) + ctx.run( + tools.mkdocs.gh_deploy(remote_name="upstream", force=True), + title="Deploying documentation", + ) + elif force: + ctx.run( + tools.mkdocs.gh_deploy(force=True), + title="Deploying documentation", + ) + else: + ctx.run( + lambda: False, + title="Not deploying docs from public repository (do that from insiders instead!)", + nofail=True, + ) + + +@duty +def format(ctx: Context) -> None: + """Run formatting tools on the code.""" + ctx.run( + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), + title="Auto-fixing code", + ) + ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") + + +@duty +def build(ctx: Context) -> None: + """Build source and wheel distributions.""" + ctx.run( + tools.build(), + title="Building source and wheel distributions", + pty=PTY, + ) + + +@duty +def publish(ctx: Context) -> None: + """Publish source and wheel distributions to PyPI.""" + if not Path("dist").exists(): + ctx.run("false", title="No distribution files found") + dists = [str(dist) for dist in Path("dist").iterdir()] + ctx.run( + tools.twine.upload(*dists, skip_existing=True), + title="Publishing source and wheel distributions to PyPI", + pty=PTY, + ) + + +@duty(post=["build", "publish", "docs-deploy"]) +def release(ctx: Context, version: str = "") -> None: + """Release a new Python package. + + Parameters: + version: The new version number to use. + """ + origin = ctx.run("git config --get remote.origin.url", silent=True) + if "pawamoy-insiders/griffe-pydantic" in origin: + ctx.run( + lambda: False, + title="Not releasing from insiders repository (do that from public repo instead!)", + ) + if not (version := (version or input("> Version to release: ")).strip()): + ctx.run("false", title="A version must be provided") + ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) + ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) + ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) + ctx.run("git push", title="Pushing commits", pty=False) + ctx.run("git push --tags", title="Pushing tags", pty=False) + + +@duty(silent=True, aliases=["cov"]) +def coverage(ctx: Context) -> None: + """Report coverage as text and HTML.""" + ctx.run(tools.coverage.combine(), nofail=True) + ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) + ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) + + +@duty +def test(ctx: Context, *cli_args: str, match: str = "") -> None: + """Run the test suite. + + Parameters: + match: A pytest expression to filter selected tests. + """ + py_version = f"{sys.version_info.major}{sys.version_info.minor}" + os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" + ctx.run( + tools.pytest( + "tests", + config_file="config/pytest.ini", + select=match, + color="yes", + ).add_args("-n", "auto", *cli_args), + title=pyprefix("Running tests"), + ) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ca7069d --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,176 @@ +site_name: "griffe-pydantic" +site_description: "Griffe extension for Pydantic." +site_url: "https://mkdocstrings.github.io/griffe-pydantic" +repo_url: "https://github.com/mkdocstrings/griffe-pydantic" +repo_name: "mkdocstrings/griffe-pydantic" +site_dir: "site" +watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/griffe_pydantic] +copyright: Copyright © 2023 Timothée Mazzucotelli +edit_uri: edit/main/docs/ + +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + +nav: +- Home: + - Overview: index.md + - Changelog: changelog.md + - Credits: credits.md + - License: license.md +# defer to gen-files + literate-nav +- API reference: + - griffe-pydantic: reference/ +- Development: + - Contributing: contributing.md + - Code of Conduct: code_of_conduct.md + # - Coverage report: coverage.md +- Insiders: + - insiders/index.md + - Getting started: + - Installation: insiders/installation.md + - Changelog: insiders/changelog.md +- Author's website: https://pawamoy.github.io/ + +theme: + name: material + custom_dir: docs/.overrides + icon: + logo: material/currency-sign + features: + - announce.dismiss + - content.action.edit + - content.action.view + - content.code.annotate + - content.code.copy + - content.tooltips + - navigation.footer + - navigation.indexes + - navigation.sections + - navigation.tabs + - navigation.tabs.sticky + - navigation.top + - search.highlight + - search.suggest + - toc.follow + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: teal + accent: purple + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: lime + toggle: + icon: material/weather-night + name: Switch to system preference + +extra_css: +- css/material.css +- css/mkdocstrings.css +- css/insiders.css + +extra_javascript: +- js/feedback.js + +markdown_extensions: +- attr_list +- admonition +- callouts +- footnotes +- pymdownx.blocks.tab: + alternate_style: true + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower +- pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg +- pymdownx.magiclink +- pymdownx.snippets: + base_path: [!relative $config_dir] + check_paths: true +- pymdownx.superfences +- pymdownx.tasklist: + custom_checkbox: true +- toc: + permalink: "¤" + +plugins: +- search +- markdown-exec +- gen-files: + scripts: + - scripts/gen_ref_nav.py +- literate-nav: + nav_file: SUMMARY.md +# - coverage +- mkdocstrings: + handlers: + python: + paths: [src, docs/examples] + import: + - https://docs.python.org/3/objects.inv + - https://mkdocstrings.github.io/griffe/objects.inv + - https://docs.pydantic.dev/latest/objects.inv + options: + docstring_options: + ignore_init_summary: true + docstring_section_style: list + filters: ["!^_"] + heading_level: 1 + inherited_members: true + merge_init_into_class: true + separate_signature: true + show_root_heading: true + show_root_full_path: false + show_signature_annotations: true + show_source: false + show_symbol_type_heading: true + show_symbol_type_toc: true + signature_crossrefs: true + summary: true +- git-revision-date-localized: + enabled: !ENV [DEPLOY, false] + enable_creation_date: true + type: timeago +- minify: + minify_html: !ENV [DEPLOY, false] +- group: + enabled: !ENV [MATERIAL_INSIDERS, false] + plugins: + - typeset + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/pawamoy + - icon: fontawesome/brands/mastodon + link: https://fosstodon.org/@pawamoy + - icon: fontawesome/brands/twitter + link: https://twitter.com/pawamoy + - icon: fontawesome/brands/gitter + link: https://gitter.im/griffe-pydantic/community + - icon: fontawesome/brands/python + link: https://pypi.org/project/griffe-pydantic/ + analytics: + feedback: + title: Was this page helpful? + ratings: + - icon: material/emoticon-happy-outline + name: This page was helpful + data: 1 + note: Thanks for your feedback! + - icon: material/emoticon-sad-outline + name: This page could be improved + data: 0 + note: Let us know how we can improve this page. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7ccf898 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,108 @@ +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + +[project] +name = "griffe-pydantic" +description = "Griffe extension for Pydantic." +authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}] +license = {text = "ISC"} +readme = "README.md" +requires-python = ">=3.9" +keywords = [] +dynamic = ["version"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Software Development", + "Topic :: Utilities", + "Typing :: Typed", +] +dependencies = [ + "griffe>=0.49", +] + +[project.urls] +Homepage = "https://mkdocstrings.github.io/griffe-pydantic" +Documentation = "https://mkdocstrings.github.io/griffe-pydantic" +Changelog = "https://mkdocstrings.github.io/griffe-pydantic/changelog" +Repository = "https://github.com/mkdocstrings/griffe-pydantic" +Issues = "https://github.com/mkdocstrings/griffe-pydantic/issues" +Discussions = "https://github.com/mkdocstrings/griffe-pydantic/discussions" +Gitter = "https://gitter.im/mkdocstrings/griffe-pydantic" +Funding = "https://github.com/sponsors/pawamoy" + +[project.entry-points."mkdocstrings.python.templates"] +griffe-pydantic = "griffe_pydantic:get_templates_path" + +[tool.pdm] +version = {source = "scm"} + +[tool.pdm.build] +package-dir = "src" +editable-backend = "editables" +excludes = ["**/.pytest_cache"] +source-includes = [ + "config", + "docs", + "scripts", + "share", + "tests", + "duties.py", + "mkdocs.yml", + "*.md", + "LICENSE", +] + +[tool.pdm.build.wheel-data] +data = [ + {path = "share/**/*", relative-to = "."}, +] + +[tool.uv] +dev-dependencies = [ + # dev + "editables>=0.5", + + # maintenance + "build>=1.2", + "git-changelog>=2.5", + "twine>=5.1", + + # ci + "duty>=1.4", + "ruff>=0.4", + "pytest>=8.2", + "pytest-cov>=5.0", + "pytest-randomly>=3.15", + "pytest-xdist>=3.6", + "mypy>=1.10", + "pydantic>=2.6", + "types-markdown>=3.6", + "types-pyyaml>=6.0", + + # docs + "black>=24.4", + "markdown-callouts>=0.4", + "markdown-exec>=1.8", + "mkdocs>=1.6", + "mkdocs-coverage>=1.0", + "mkdocs-gen-files>=0.5", + "mkdocs-git-revision-date-localized-plugin>=1.2", + "mkdocs-literate-nav>=0.6", + "mkdocs-material>=9.5", + "mkdocs-minify-plugin>=0.8", + "mkdocstrings[python]>=0.25", + # YORE: EOL 3.10: Remove line. + "tomli>=2.0; python_version < '3.11'", +] \ No newline at end of file diff --git a/scripts/gen_credits.py b/scripts/gen_credits.py new file mode 100644 index 0000000..ab60181 --- /dev/null +++ b/scripts/gen_credits.py @@ -0,0 +1,179 @@ +"""Script to generate the project's credits.""" + +from __future__ import annotations + +import os +import sys +from collections import defaultdict +from collections.abc import Iterable +from importlib.metadata import distributions +from itertools import chain +from pathlib import Path +from textwrap import dedent +from typing import Union + +from jinja2 import StrictUndefined +from jinja2.sandbox import SandboxedEnvironment +from packaging.requirements import Requirement + +# YORE: EOL 3.10: Replace block with line 2. +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +project_dir = Path(os.getenv("MKDOCS_CONFIG_DIR", ".")) +with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) +project = pyproject["project"] +project_name = project["name"] +devdeps = [dep for dep in pyproject["tool"]["uv"]["dev-dependencies"] if not dep.startswith("-e")] + +PackageMetadata = dict[str, Union[str, Iterable[str]]] +Metadata = dict[str, PackageMetadata] + + +def _merge_fields(metadata: dict) -> PackageMetadata: + fields = defaultdict(list) + for header, value in metadata.items(): + fields[header.lower()].append(value.strip()) + return { + field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0] + for field, value in fields.items() + } + + +def _norm_name(name: str) -> str: + return name.replace("_", "-").replace(".", "-").lower() + + +def _requirements(deps: list[str]) -> dict[str, Requirement]: + return {_norm_name((req := Requirement(dep)).name): req for dep in deps} + + +def _extra_marker(req: Requirement) -> str | None: + if not req.marker: + return None + try: + return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra") + except StopIteration: + return None + + +def _get_metadata() -> Metadata: + metadata = {} + for pkg in distributions(): + name = _norm_name(pkg.name) # type: ignore[attr-defined,unused-ignore] + metadata[name] = _merge_fields(pkg.metadata) # type: ignore[arg-type] + metadata[name]["spec"] = set() + metadata[name]["extras"] = set() + metadata[name].setdefault("summary", "") + _set_license(metadata[name]) + return metadata + + +def _set_license(metadata: PackageMetadata) -> None: + license_field = metadata.get("license-expression", metadata.get("license", "")) + license_name = license_field if isinstance(license_field, str) else " + ".join(license_field) + check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n") + if check_classifiers: + license_names = [] + for classifier in metadata["classifier"]: + if classifier.startswith("License ::"): + license_names.append(classifier.rsplit("::", 1)[1].strip()) + license_name = " + ".join(license_names) + metadata["license"] = license_name or "?" + + +def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata: + deps = {} + for dep_name, dep_req in base_deps.items(): + if dep_name not in metadata or dep_name == "griffe-pydantic": + continue + metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator] + metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator] + deps[dep_name] = metadata[dep_name] + + again = True + while again: + again = False + for pkg_name in metadata: + if pkg_name in deps: + for pkg_dependency in metadata[pkg_name].get("requires-dist", []): + requirement = Requirement(pkg_dependency) + dep_name = _norm_name(requirement.name) + extra_marker = _extra_marker(requirement) + if ( + dep_name in metadata + and dep_name not in deps + and dep_name != project["name"] + and (not extra_marker or extra_marker in deps[pkg_name]["extras"]) + ): + metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # type: ignore[operator] + deps[dep_name] = metadata[dep_name] + again = True + + return deps + + +def _render_credits() -> str: + metadata = _get_metadata() + dev_dependencies = _get_deps(_requirements(devdeps), metadata) + prod_dependencies = _get_deps( + _requirements( + chain( # type: ignore[arg-type] + project.get("dependencies", []), + chain(*project.get("optional-dependencies", {}).values()), + ), + ), + metadata, + ) + + template_data = { + "project_name": project_name, + "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), + "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), + "more_credits": "http://pawamoy.github.io/credits/", + } + template_text = dedent( + """ + # Credits + + These projects were used to build *{{ project_name }}*. **Thank you!** + + [Python](https://www.python.org/) | + [uv](https://github.com/astral-sh/uv) | + [copier-uv](https://github.com/pawamoy/copier-uv) + + {% macro dep_line(dep) -%} + [{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} + {%- endmacro %} + + {% if prod_dependencies -%} + ### Runtime dependencies + + Project | Summary | Version (accepted) | Version (last resolved) | License + ------- | ------- | ------------------ | ----------------------- | ------- + {% for dep in prod_dependencies -%} + {{ dep_line(dep) }} + {% endfor %} + + {% endif -%} + {% if dev_dependencies -%} + ### Development dependencies + + Project | Summary | Version (accepted) | Version (last resolved) | License + ------- | ------- | ------------------ | ----------------------- | ------- + {% for dep in dev_dependencies -%} + {{ dep_line(dep) }} + {% endfor %} + + {% endif -%} + {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} + """, + ) + jinja_env = SandboxedEnvironment(undefined=StrictUndefined) + return jinja_env.from_string(template_text).render(**template_data) + + +print(_render_credits()) diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py new file mode 100644 index 0000000..6939e86 --- /dev/null +++ b/scripts/gen_ref_nav.py @@ -0,0 +1,37 @@ +"""Generate the code reference pages and navigation.""" + +from pathlib import Path + +import mkdocs_gen_files + +nav = mkdocs_gen_files.Nav() +mod_symbol = '' + +root = Path(__file__).parent.parent +src = root / "src" + +for path in sorted(src.rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") + full_doc_path = Path("reference", doc_path) + + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + nav_parts = [f"{mod_symbol} {part}" for part in parts] + nav[tuple(nav_parts)] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(parts) + fd.write(f"---\ntitle: {ident}\n---\n\n::: {ident}") + + mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path.relative_to(root)) + +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/scripts/insiders.py b/scripts/insiders.py new file mode 100644 index 0000000..849c631 --- /dev/null +++ b/scripts/insiders.py @@ -0,0 +1,206 @@ +"""Functions related to Insiders funding goals.""" + +from __future__ import annotations + +import json +import logging +import os +import posixpath +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from itertools import chain +from pathlib import Path +from typing import TYPE_CHECKING, cast +from urllib.error import HTTPError +from urllib.parse import urljoin +from urllib.request import urlopen + +import yaml + +if TYPE_CHECKING: + from collections.abc import Iterable + +logger = logging.getLogger(f"mkdocs.logs.{__name__}") + + +def human_readable_amount(amount: int) -> str: # noqa: D103 + str_amount = str(amount) + if len(str_amount) >= 4: # noqa: PLR2004 + return f"{str_amount[:len(str_amount)-3]},{str_amount[-3:]}" + return str_amount + + +@dataclass +class Project: + """Class representing an Insiders project.""" + + name: str + url: str + + +@dataclass +class Feature: + """Class representing an Insiders feature.""" + + name: str + ref: str | None + since: date | None + project: Project | None + + def url(self, rel_base: str = "..") -> str | None: # noqa: D102 + if not self.ref: + return None + if self.project: + rel_base = self.project.url + return posixpath.join(rel_base, self.ref.lstrip("/")) + + def render(self, rel_base: str = "..", *, badge: bool = False) -> None: # noqa: D102 + new = "" + if badge: + recent = self.since and date.today() - self.since <= timedelta(days=60) # noqa: DTZ011 + if recent: + ft_date = self.since.strftime("%B %d, %Y") # type: ignore[union-attr] + new = f' :material-alert-decagram:{{ .new-feature .vibrate title="Added on {ft_date}" }}' + project = f"[{self.project.name}]({self.project.url}) — " if self.project else "" + feature = f"[{self.name}]({self.url(rel_base)})" if self.ref else self.name + print(f"- [{'x' if self.since else ' '}] {project}{feature}{new}") + + +@dataclass +class Goal: + """Class representing an Insiders goal.""" + + name: str + amount: int + features: list[Feature] + complete: bool = False + + @property + def human_readable_amount(self) -> str: # noqa: D102 + return human_readable_amount(self.amount) + + def render(self, rel_base: str = "..") -> None: # noqa: D102 + print(f"#### $ {self.human_readable_amount} — {self.name}\n") + if self.features: + for feature in self.features: + feature.render(rel_base) + print("") + else: + print("There are no features in this goal for this project. ") + print( + "[See the features in this goal **for all Insiders projects.**]" + f"(https://pawamoy.github.io/insiders/#{self.amount}-{self.name.lower().replace(' ', '-')})", + ) + + +def load_goals(data: str, funding: int = 0, project: Project | None = None) -> dict[int, Goal]: + """Load goals from JSON data. + + Parameters: + data: The JSON data. + funding: The current total funding, per month. + origin: The origin of the data (URL). + + Returns: + A dictionaries of goals, keys being their target monthly amount. + """ + goals_data = yaml.safe_load(data)["goals"] + return { + amount: Goal( + name=goal_data["name"], + amount=amount, + complete=funding >= amount, + features=[ + Feature( + name=feature_data["name"], + ref=feature_data.get("ref"), + since=feature_data.get("since") and datetime.strptime(feature_data["since"], "%Y/%m/%d").date(), # noqa: DTZ007 + project=project, + ) + for feature_data in goal_data["features"] + ], + ) + for amount, goal_data in goals_data.items() + } + + +def _load_goals_from_disk(path: str, funding: int = 0) -> dict[int, Goal]: + project_dir = os.getenv("MKDOCS_CONFIG_DIR", ".") + try: + data = Path(project_dir, path).read_text() + except OSError as error: + raise RuntimeError(f"Could not load data from disk: {path}") from error + return load_goals(data, funding) + + +def _load_goals_from_url(source_data: tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: + project_name, project_url, data_fragment = source_data + data_url = urljoin(project_url, data_fragment) + try: + with urlopen(data_url) as response: # noqa: S310 + data = response.read() + except HTTPError as error: + raise RuntimeError(f"Could not load data from network: {data_url}") from error + return load_goals(data, funding, project=Project(name=project_name, url=project_url)) + + +def _load_goals(source: str | tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: + if isinstance(source, str): + return _load_goals_from_disk(source, funding) + return _load_goals_from_url(source, funding) + + +def funding_goals(source: str | list[str | tuple[str, str, str]], funding: int = 0) -> dict[int, Goal]: + """Load funding goals from a given data source. + + Parameters: + source: The data source (local file path or URL). + funding: The current total funding, per month. + + Returns: + A dictionaries of goals, keys being their target monthly amount. + """ + if isinstance(source, str): + return _load_goals_from_disk(source, funding) + goals = {} + for src in source: + source_goals = _load_goals(src, funding) + for amount, goal in source_goals.items(): + if amount not in goals: + goals[amount] = goal + else: + goals[amount].features.extend(goal.features) + return {amount: goals[amount] for amount in sorted(goals)} + + +def feature_list(goals: Iterable[Goal]) -> list[Feature]: + """Extract feature list from funding goals. + + Parameters: + goals: A list of funding goals. + + Returns: + A list of features. + """ + return list(chain.from_iterable(goal.features for goal in goals)) + + +def load_json(url: str) -> str | list | dict: # noqa: D103 + with urlopen(url) as response: # noqa: S310 + return json.loads(response.read().decode()) + + +data_source = globals()["data_source"] +sponsor_url = "https://github.com/sponsors/pawamoy" +data_url = "https://raw.githubusercontent.com/pawamoy/sponsors/main" +numbers: dict[str, int] = load_json(f"{data_url}/numbers.json") # type: ignore[assignment] +sponsors: list[dict] = load_json(f"{data_url}/sponsors.json") # type: ignore[assignment] +current_funding = numbers["total"] +sponsors_count = numbers["count"] +goals = funding_goals(data_source, funding=current_funding) +ongoing_goals = [goal for goal in goals.values() if not goal.complete] +unreleased_features = sorted( + (ft for ft in feature_list(ongoing_goals) if ft.since), + key=lambda ft: cast(date, ft.since), + reverse=True, +) diff --git a/scripts/make b/scripts/make new file mode 100755 index 0000000..ac43062 --- /dev/null +++ b/scripts/make @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Management commands.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from textwrap import dedent +from typing import Any, Iterator + +PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13 3.14").split() + + +def shell(cmd: str, capture_output: bool = False, **kwargs: Any) -> str | None: + """Run a shell command.""" + if capture_output: + return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602 + subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602 + return None + + +@contextmanager +def environ(**kwargs: str) -> Iterator[None]: + """Temporarily set environment variables.""" + original = dict(os.environ) + os.environ.update(kwargs) + try: + yield + finally: + os.environ.clear() + os.environ.update(original) + + +def uv_install(venv: Path) -> None: + """Install dependencies using uv.""" + with environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"): + if "CI" in os.environ: + shell("uv sync --no-editable") + else: + shell("uv sync") + + +def setup() -> None: + """Setup the project.""" + if not shutil.which("uv"): + raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") + + print("Installing dependencies (default environment)") # noqa: T201 + default_venv = Path(".venv") + if not default_venv.exists(): + shell("uv venv --python python") + uv_install(default_venv) + + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + print(f"\nInstalling dependencies (python{version})") # noqa: T201 + venv_path = Path(f".venvs/{version}") + if not venv_path.exists(): + shell(f"uv venv --python {version} {venv_path}") + with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())): + uv_install(venv_path) + + +def run(version: str, cmd: str, *args: str, no_sync: bool = False, **kwargs: Any) -> None: + """Run a command in a virtual environment.""" + kwargs = {"check": True, **kwargs} + uv_run = ["uv", "run"] + if no_sync: + uv_run.append("--no-sync") + if version == "default": + with environ(UV_PROJECT_ENVIRONMENT=".venv"): + subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 + else: + with environ(UV_PROJECT_ENVIRONMENT=f".venvs/{version}", MULTIRUN="1"): + subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 + + +def multirun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command for all configured Python versions.""" + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + run(version, cmd, *args, **kwargs) + else: + run("default", cmd, *args, **kwargs) + + +def allrun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command in all virtual environments.""" + run("default", cmd, *args, **kwargs) + if PYTHON_VERSIONS: + multirun(cmd, *args, **kwargs) + + +def clean() -> None: + """Delete build artifacts and cache files.""" + paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"] + for path in paths_to_clean: + shell(f"rm -rf {path}") + + cache_dirs = {".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"} + for dirpath in Path(".").rglob("*/"): + if dirpath.parts[0] not in (".venv", ".venvs") and dirpath.name in cache_dirs: + shutil.rmtree(dirpath, ignore_errors=True) + + +def vscode() -> None: + """Configure VSCode to work on this project.""" + Path(".vscode").mkdir(parents=True, exist_ok=True) + shell("cp -v config/vscode/* .vscode") + + +def main() -> int: + """Main entry point.""" + args = list(sys.argv[1:]) + if not args or args[0] == "help": + if len(args) > 1: + run("default", "duty", "--help", args[1]) + else: + print( + dedent( + """ + Available commands + help Print this help. Add task name to print help. + setup Setup all virtual environments (install dependencies). + run Run a command in the default virtual environment. + multirun Run a command for all configured Python versions. + allrun Run a command in all virtual environments. + 3.x Run a command in the virtual environment for Python 3.x. + clean Delete build artifacts and cache files. + vscode Configure VSCode to work on this project. + """ + ), + flush=True, + ) # noqa: T201 + if os.path.exists(".venv"): + print("\nAvailable tasks", flush=True) # noqa: T201 + run("default", "duty", "--list", no_sync=True) + return 0 + + while args: + cmd = args.pop(0) + + if cmd == "run": + run("default", *args) + return 0 + + if cmd == "multirun": + multirun(*args) + return 0 + + if cmd == "allrun": + allrun(*args) + return 0 + + if cmd.startswith("3."): + run(cmd, *args) + return 0 + + opts = [] + while args and (args[0].startswith("-") or "=" in args[0]): + opts.append(args.pop(0)) + + if cmd == "clean": + clean() + elif cmd == "setup": + setup() + elif cmd == "vscode": + vscode() + elif cmd == "check": + multirun("duty", "check-quality", "check-types", "check-docs") + run("default", "duty", "check-api") + elif cmd in {"check-quality", "check-docs", "check-types", "test"}: + multirun("duty", cmd, *opts) + else: + run("default", "duty", cmd, *opts) + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except subprocess.CalledProcessError as process: + if process.output: + print(process.output, file=sys.stderr) # noqa: T201 + sys.exit(process.returncode) diff --git a/src/griffe_pydantic/__init__.py b/src/griffe_pydantic/__init__.py new file mode 100644 index 0000000..cc05b18 --- /dev/null +++ b/src/griffe_pydantic/__init__.py @@ -0,0 +1,18 @@ +"""griffe-pydantic package. + +Griffe extension for Pydantic. +""" + +from __future__ import annotations + +from pathlib import Path + +from griffe_pydantic.extension import PydanticExtension + + +def get_templates_path() -> Path: + """Return the templates directory path.""" + return Path(__file__).parent / "templates" + + +__all__: list[str] = ["get_templates_path", "PydanticExtension"] diff --git a/src/griffe_pydantic/common.py b/src/griffe_pydantic/common.py new file mode 100644 index 0000000..612a063 --- /dev/null +++ b/src/griffe_pydantic/common.py @@ -0,0 +1,78 @@ +"""Griffe extension for Pydantic.""" + +from __future__ import annotations + +import json +from functools import partial +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + from griffe import Attribute, Class, Function + from pydantic import BaseModel + +self_namespace = "griffe_pydantic" +mkdocstrings_namespace = "mkdocstrings" + +field_constraints = { + "gt", + "ge", + "lt", + "le", + "multiple_of", + "min_length", + "max_length", + "pattern", + "allow_inf_nan", + "max_digits", + "decimal_place", +} + + +def _model_fields(cls: Class) -> dict[str, Attribute]: + return {name: attr for name, attr in cls.members.items() if "pydantic-field" in attr.labels} # type: ignore[misc] + + +def _model_validators(cls: Class) -> dict[str, Function]: + return {name: func for name, func in cls.members.items() if "pydantic-validator" in func.labels} # type: ignore[misc] + + +def json_schema(model: type[BaseModel]) -> str: + """Produce a model schema as JSON. + + Parameters: + model: A Pydantic model. + + Returns: + A schema as JSON. + """ + return json.dumps(model.model_json_schema(), indent=2) + + +def process_class(cls: Class) -> None: + """Set metadata on a Pydantic model. + + Parameters: + cls: The Griffe class representing the Pydantic model. + """ + cls.labels.add("pydantic-model") + cls.extra[self_namespace]["fields"] = partial(_model_fields, cls) + cls.extra[self_namespace]["validators"] = partial(_model_validators, cls) + cls.extra[mkdocstrings_namespace]["template"] = "pydantic_model.html.jinja" + + +def process_function(func: Function, cls: Class, fields: Sequence[str]) -> None: + """Set metadata on a Pydantic validator. + + Parameters: + cls: A Griffe function representing the Pydantic validator. + """ + func.labels = {"pydantic-validator"} + targets = [cls.members[field] for field in fields] + + func.extra[self_namespace].setdefault("targets", []) + func.extra[self_namespace]["targets"].extend(targets) + for target in targets: + target.extra[self_namespace].setdefault("validators", []) + target.extra[self_namespace]["validators"].append(func) diff --git a/src/griffe_pydantic/debug.py b/src/griffe_pydantic/debug.py new file mode 100644 index 0000000..c4c161c --- /dev/null +++ b/src/griffe_pydantic/debug.py @@ -0,0 +1,109 @@ +"""Debugging utilities.""" + +from __future__ import annotations + +import os +import platform +import sys +from dataclasses import dataclass +from importlib import metadata + + +@dataclass +class Variable: + """Dataclass describing an environment variable.""" + + name: str + """Variable name.""" + value: str + """Variable value.""" + + +@dataclass +class Package: + """Dataclass describing a Python package.""" + + name: str + """Package name.""" + version: str + """Package version.""" + + +@dataclass +class Environment: + """Dataclass to store environment information.""" + + interpreter_name: str + """Python interpreter name.""" + interpreter_version: str + """Python interpreter version.""" + interpreter_path: str + """Path to Python executable.""" + platform: str + """Operating System.""" + packages: list[Package] + """Installed packages.""" + variables: list[Variable] + """Environment variables.""" + + +def _interpreter_name_version() -> tuple[str, str]: + if hasattr(sys, "implementation"): + impl = sys.implementation.version + version = f"{impl.major}.{impl.minor}.{impl.micro}" + kind = impl.releaselevel + if kind != "final": + version += kind[0] + str(impl.serial) + return sys.implementation.name, version + return "", "0.0.0" + + +def get_version(dist: str = "griffe-pydantic") -> str: + """Get version of the given distribution. + + Parameters: + dist: A distribution name. + + Returns: + A version number. + """ + try: + return metadata.version(dist) + except metadata.PackageNotFoundError: + return "0.0.0" + + +def get_debug_info() -> Environment: + """Get debug/environment information. + + Returns: + Environment information. + """ + py_name, py_version = _interpreter_name_version() + packages = ["griffe-pydantic"] + variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("GRIFFE_PYDANTIC")]] + return Environment( + interpreter_name=py_name, + interpreter_version=py_version, + interpreter_path=sys.executable, + platform=platform.platform(), + variables=[Variable(var, val) for var in variables if (val := os.getenv(var))], + packages=[Package(pkg, get_version(pkg)) for pkg in packages], + ) + + +def print_debug_info() -> None: + """Print debug/environment information.""" + info = get_debug_info() + print(f"- __System__: {info.platform}") + print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})") + print("- __Environment variables__:") + for var in info.variables: + print(f" - `{var.name}`: `{var.value}`") + print("- __Installed packages__:") + for pkg in info.packages: + print(f" - `{pkg.name}` v{pkg.version}") + + +if __name__ == "__main__": + print_debug_info() diff --git a/src/griffe_pydantic/dynamic.py b/src/griffe_pydantic/dynamic.py new file mode 100644 index 0000000..f0ca360 --- /dev/null +++ b/src/griffe_pydantic/dynamic.py @@ -0,0 +1,55 @@ +"""Griffe extension for Pydantic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from griffe import ( + Attribute, + Class, + Docstring, + Function, + get_logger, +) +from pydantic.fields import FieldInfo + +from griffe_pydantic import common + +if TYPE_CHECKING: + from griffe import ObjectNode + +logger = get_logger(__name__) + + +def process_attribute(node: ObjectNode, attr: Attribute, cls: Class) -> None: + """Handle Pydantic fields.""" + if attr.name == "model_config": + cls.extra[common.self_namespace]["config"] = node.obj + return + + if not isinstance(node.obj, FieldInfo): + return + + attr.labels = {"pydantic-field"} + attr.value = node.obj.default + constraints = {} + for constraint in common.field_constraints: + if (value := getattr(node.obj, constraint, None)) is not None: + constraints[constraint] = value + attr.extra[common.self_namespace]["constraints"] = constraints + + # Populate docstring from the field's `description` argument. + if not attr.docstring and (docstring := node.obj.description): + attr.docstring = Docstring(docstring, parent=attr) + + +def process_function(node: ObjectNode, func: Function, cls: Class) -> None: + """Handle Pydantic field validators.""" + if dec_info := getattr(node.obj, "decorator_info", None): + common.process_function(func, cls, dec_info.fields) + + +def process_class(node: ObjectNode, cls: Class) -> None: + """Detect and prepare Pydantic models.""" + common.process_class(cls) + cls.extra[common.self_namespace]["schema"] = common.json_schema(node.obj) diff --git a/src/griffe_pydantic/extension.py b/src/griffe_pydantic/extension.py new file mode 100644 index 0000000..8b61799 --- /dev/null +++ b/src/griffe_pydantic/extension.py @@ -0,0 +1,89 @@ +"""Griffe extension for Pydantic.""" + +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING, Any + +from griffe import ( + Attribute, + Class, + Extension, + Function, + Module, + get_logger, +) + +from griffe_pydantic import dynamic, static + +if TYPE_CHECKING: + from griffe import ObjectNode + + +logger = get_logger(__name__) + + +class PydanticExtension(Extension): + """Griffe extension for Pydantic.""" + + def __init__(self, *, schema: bool = False) -> None: + """Initialize the extension. + + Parameters: + schema: Whether to compute and store the JSON schema of models. + """ + super().__init__() + self.schema = schema + self.in_model: list[Class] = [] + self.processed: set[str] = set() + + def on_package_loaded(self, *, pkg: Module, **kwargs: Any) -> None: # noqa: ARG002 + """Detect models once the whole package is loaded.""" + static.process_module(pkg, processed=self.processed, schema=self.schema) + + def on_class_instance(self, *, node: ast.AST | ObjectNode, cls: Class, **kwargs: Any) -> None: # noqa: ARG002 + """Detect and prepare Pydantic models.""" + # Prevent running during static analysis. + if isinstance(node, ast.AST): + return + + try: + import pydantic + except ImportError: + logger.warning("could not import pydantic - models will not be detected") + return + + if issubclass(node.obj, pydantic.BaseModel): + self.in_model.append(cls) + dynamic.process_class(node, cls) + self.processed.add(cls.canonical_path) + + def on_attribute_instance(self, *, node: ast.AST | ObjectNode, attr: Attribute, **kwargs: Any) -> None: # noqa: ARG002 + """Handle Pydantic fields.""" + # Prevent running during static analysis. + if isinstance(node, ast.AST): + return + if self.in_model: + cls = self.in_model[-1] + dynamic.process_attribute(node, attr, cls) + self.processed.add(attr.canonical_path) + + def on_function_instance(self, *, node: ast.AST | ObjectNode, func: Function, **kwargs: Any) -> None: # noqa: ARG002 + """Handle Pydantic field validators.""" + # Prevent running during static analysis. + if isinstance(node, ast.AST): + return + if self.in_model: + cls = self.in_model[-1] + dynamic.process_function(node, func, cls) + self.processed.add(func.canonical_path) + + def on_class_members(self, *, node: ast.AST | ObjectNode, cls: Class, **kwargs: Any) -> None: # noqa: ARG002 + """Finalize the Pydantic model data.""" + # Prevent running during static analysis. + if isinstance(node, ast.AST): + return + + if self.in_model and cls is self.in_model[-1]: + # Pop last class from the heap. + self.in_model.pop() diff --git a/src/griffe_pydantic/py.typed b/src/griffe_pydantic/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/griffe_pydantic/static.py b/src/griffe_pydantic/static.py new file mode 100644 index 0000000..d7fd046 --- /dev/null +++ b/src/griffe_pydantic/static.py @@ -0,0 +1,168 @@ +"""Griffe extension for Pydantic.""" + +from __future__ import annotations + +import ast +import sys +from typing import TYPE_CHECKING + +from griffe import ( + Alias, + Attribute, + Class, + Docstring, + Expr, + ExprCall, + ExprKeyword, + ExprName, + Function, + Module, + dynamic_import, + get_logger, +) + +from griffe_pydantic import common + +if TYPE_CHECKING: + from pathlib import Path + + +logger = get_logger(__name__) + + +def inherits_pydantic(cls: Class) -> bool: + """Tell whether a class inherits from a Pydantic model. + + Parameters: + cls: A Griffe class. + + Returns: + True/False. + """ + for base in cls.bases: + if isinstance(base, (ExprName, Expr)): + base = base.canonical_path # noqa: PLW2901 + if base in {"pydantic.BaseModel", "pydantic.main.BaseModel"}: + return True + + return any(inherits_pydantic(parent_class) for parent_class in cls.mro()) + + +def pydantic_field_validator(func: Function) -> ExprCall | None: + """Return a function's `pydantic.field_validator` decorator if it exists. + + Parameters: + func: A Griffe function. + + Returns: + A decorator value (Griffe expression). + """ + for decorator in func.decorators: + if isinstance(decorator.value, ExprCall) and decorator.callable_path == "pydantic.field_validator": + return decorator.value + return None + + +def process_attribute(attr: Attribute, cls: Class, *, processed: set[str]) -> None: + """Handle Pydantic fields.""" + if attr.canonical_path in processed: + return + processed.add(attr.canonical_path) + + kwargs = {} + if isinstance(attr.value, ExprCall): + kwargs = { + argument.name: argument.value for argument in attr.value.arguments if isinstance(argument, ExprKeyword) + } + + if ( + attr.value.function.canonical_path == "pydantic.Field" + and len(attr.value.arguments) >= 1 + and not isinstance(attr.value.arguments[0], ExprKeyword) + and attr.value.arguments[0] != "..." # handle Field(...), i.e. no default + ): + kwargs["default"] = attr.value.arguments[0] + + elif attr.value is not None: + kwargs["default"] = attr.value + + if attr.name == "model_config": + cls.extra[common.self_namespace]["config"] = kwargs + return + + attr.labels = {"pydantic-field"} + attr.value = kwargs.get("default", None) + constraints = {kwarg: value for kwarg, value in kwargs.items() if kwarg not in {"default", "description"}} + attr.extra[common.self_namespace]["constraints"] = constraints + + # Populate docstring from the field's `description` argument. + if not attr.docstring and (docstring := kwargs.get("description", None)): + attr.docstring = Docstring(ast.literal_eval(docstring), parent=attr) # type: ignore[arg-type] + + +def process_function(func: Function, cls: Class, *, processed: set[str]) -> None: + """Handle Pydantic field validators.""" + if func.canonical_path in processed: + return + processed.add(func.canonical_path) + + if isinstance(func, Alias): + logger.warning(f"cannot yet process {func}") + return + + if decorator := pydantic_field_validator(func): + fields = [ast.literal_eval(field) for field in decorator.arguments if isinstance(field, str)] + common.process_function(func, cls, fields) + + +def process_class(cls: Class, *, processed: set[str], schema: bool = False) -> None: + """Finalize the Pydantic model data.""" + if cls.canonical_path in processed: + return + + if not inherits_pydantic(cls): + return + + processed.add(cls.canonical_path) + + common.process_class(cls) + + if schema: + import_path: Path | list[Path] = cls.package.filepath + if isinstance(import_path, list): + import_path = import_path[-1] + if import_path.name == "__init__.py": + import_path = import_path.parent + import_path = import_path.parent + try: + true_class = dynamic_import(cls.path, import_paths=[import_path, *sys.path]) + except ImportError: + logger.debug(f"Could not import class {cls.path} for JSON schema") + return + cls.extra[common.self_namespace]["schema"] = common.json_schema(true_class) + + for member in cls.all_members.values(): + if isinstance(member, Attribute): + process_attribute(member, cls, processed=processed) + elif isinstance(member, Function): + process_function(member, cls, processed=processed) + elif isinstance(member, Class): + process_class(member, processed=processed, schema=schema) + + +def process_module( + mod: Module, + *, + processed: set[str], + schema: bool = False, +) -> None: + """Handle Pydantic models in a module.""" + if mod.canonical_path in processed: + return + processed.add(mod.canonical_path) + + for cls in mod.classes.values(): + process_class(cls, processed=processed, schema=schema) + + for submodule in mod.modules.values(): + process_module(submodule, processed=processed, schema=schema) diff --git a/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja b/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja new file mode 100644 index 0000000..196c13d --- /dev/null +++ b/src/griffe_pydantic/templates/material/_base/pydantic_model.html.jinja @@ -0,0 +1,65 @@ +{% extends "_base/class.html.jinja" %} + +{% block contents %} + {% block bases %}{{ super() }}{% endblock %} + {% block docstring %}{{ super() }}{% endblock %} + + {% block schema scoped %} + {% if class.extra.griffe_pydantic.schema %} +
Show JSON schema: + {{ class.extra.griffe_pydantic.schema | highlight(language="json") }} +
+ {% endif %} + {% endblock schema %} + + {% block config scoped %} + {% if class.extra.griffe_pydantic.config %} +

Config:

+ + {% endif %} + {% endblock config %} + + {% block fields scoped %} + {% with fields = class.extra.griffe_pydantic.fields() %} + {% if fields %} +

Fields:

+ + {% endif %} + {% endwith %} + {% endblock fields %} + + {% block validators scoped %} + {% with validators = class.extra.griffe_pydantic.validators() %} + {% if validators %} +

Validators:

+ + {% endif %} + {% endwith %} + {% endblock validators %} + + {% block source %}{{ super() }}{% endblock %} + {% block children %}{{ super() }}{% endblock %} +{% endblock contents %} diff --git a/src/griffe_pydantic/templates/material/pydantic_model.html.jinja b/src/griffe_pydantic/templates/material/pydantic_model.html.jinja new file mode 100644 index 0000000..1181fb9 --- /dev/null +++ b/src/griffe_pydantic/templates/material/pydantic_model.html.jinja @@ -0,0 +1 @@ +{% extends "_base/pydantic_model.html.jinja" %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..458daa6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +"""Tests suite for `griffe_pydantic`.""" + +from pathlib import Path + +TESTS_DIR = Path(__file__).parent +TMP_DIR = TESTS_DIR / "tmp" +FIXTURES_DIR = TESTS_DIR / "fixtures" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3be27ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +"""Configuration for the pytest test suite.""" diff --git a/tests/test_extension.py b/tests/test_extension.py new file mode 100644 index 0000000..b29340e --- /dev/null +++ b/tests/test_extension.py @@ -0,0 +1,72 @@ +"""Tests for the `extension` module.""" + +from __future__ import annotations + +from griffe import Extensions, temporary_visited_package + +from griffe_pydantic.extension import PydanticExtension + +code = """ + from pydantic import field_validator, ConfigDict, BaseModel, Field + + + class ExampleParentModel(BaseModel): + '''An example parent model.''' + parent_field: str = Field(..., description="Parent field.") + + + class ExampleModel(ExampleParentModel): + '''An example child model.''' + + model_config = ConfigDict(frozen=False) + + field_without_default: str + '''Shows the *[Required]* marker in the signature.''' + + field_plain_with_validator: int = 100 + '''Show standard field with type annotation.''' + + field_with_validator_and_alias: str = Field("FooBar", alias="BarFoo", validation_alias="BarFoo") + '''Shows corresponding validator with link/anchor.''' + + field_with_constraints_and_description: int = Field( + default=5, ge=0, le=100, description="Shows constraints within doc string." + ) + + @field_validator("field_with_validator_and_alias", "field_plain_with_validator", mode="before") + @classmethod + def check_max_length_ten(cls, v): + '''Show corresponding field with link/anchor.''' + if len(v) >= 10: + raise ValueError("No more than 10 characters allowed") + return v + + def regular_method(self): + pass + + + class RegularClass(object): + regular_attr = 1 +""" + + +def test_extension() -> None: + """Test the extension.""" + with temporary_visited_package( + "package", + modules={"__init__.py": code}, + extensions=Extensions(PydanticExtension(schema=True)), + ) as package: + assert package + + assert "ExampleParentModel" in package.classes + assert package.classes["ExampleParentModel"].labels == {"pydantic-model"} + + assert "ExampleModel" in package.classes + assert package.classes["ExampleModel"].labels == {"pydantic-model"} + + config = package.classes["ExampleModel"].extra["griffe_pydantic"]["config"] + assert config == {"frozen": "False"} + + schema = package.classes["ExampleModel"].extra["griffe_pydantic"]["schema"] + assert schema.startswith('{\n "description"')