diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5bf500b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" + # Python + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/dist-python.yml b/.github/workflows/dist-python.yml new file mode 100644 index 0000000..43668ee --- /dev/null +++ b/.github/workflows/dist-python.yml @@ -0,0 +1,75 @@ +name: Python Dist + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+.post[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+[a-b][0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + workflow_dispatch: + pull_request: + workflow_call: + inputs: + ref: + required: true + type: string + +concurrency: + group: dist-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash -eux {0} + +jobs: + make_dist: + name: Make Dist + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + # Build sdist on lowest supported Python + python-version: '3.9' + + - name: Install python requirements + run: | + python -m pip install uv rust-just build twine + + - name: Build Dist + run: | + python -m build . + + - name: Test SDist + run: | + python -m twine check --strict dist/*.* + python -m pip install dist/*.gz + cd .. + python -c "from flask_pymongo import PyMongo" + + - uses: actions/upload-artifact@v4 + with: + name: "dist" + path: ./dist/*.* + + collect_dist: + runs-on: ubuntu-latest + needs: [make_dist] + name: Download Dist + steps: + - name: Download all workflow run artifacts + uses: actions/download-artifact@v4 + - name: Flatten directory + working-directory: . + run: | + find . -mindepth 2 -type f -exec mv {} . \; + find . -type d -empty -delete + - uses: actions/upload-artifact@v4 + with: + name: all-dist-${{ github.run_id }} + path: "./*" diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml new file mode 100644 index 0000000..31553b0 --- /dev/null +++ b/.github/workflows/release-python.yml @@ -0,0 +1,107 @@ +name: Release + +on: + workflow_dispatch: + inputs: + following_version: + description: "The post (dev) version to set" + dry_run: + description: "Dry Run?" + default: false + type: boolean + schedule: + - cron: '30 5 * * *' + +env: + # Changes per repo + PRODUCT_NAME: Flask-PyMongo + # Constant + # inputs will be empty on a scheduled run. so, we only set dry_run + # to 'false' when the input is set to 'false'. + DRY_RUN: ${{ ! contains(inputs.dry_run, 'false') }} + FOLLOWING_VERSION: ${{ inputs.following_version || '' }} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash -eux {0} + +jobs: + pre-publish: + environment: release + runs-on: ubuntu-latest + if: github.repository_owner == 'mongodb-labs' || github.event_name == 'workflow_dispatch' + permissions: + id-token: write + contents: write + outputs: + version: ${{ steps.pre-publish.outputs.version }} + steps: + - uses: mongodb-labs/drivers-github-tools/secure-checkout@v2 + with: + app_id: ${{ vars.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: mongodb-labs/drivers-github-tools/setup@v2 + with: + aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} + aws_region_name: ${{ vars.AWS_REGION_NAME }} + aws_secret_id: ${{ secrets.AWS_SECRET_ID }} + artifactory_username: ${{ vars.ARTIFACTORY_USERNAME }} + - uses: mongodb-labs/drivers-github-tools/python-labs/pre-publish@v2 + id: pre-publish + with: + dry_run: ${{ env.DRY_RUN }} + + build-dist: + needs: [pre-publish] + uses: ./.github/workflows/dist-python.yml + with: + ref: ${{ needs.pre-publish.outputs.version }} + + publish: + # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#publishing-the-distribution-to-pypi + needs: [build-dist] + if: (github.repository_owner == 'mongodb-labs' && github.event_name != 'pull_request') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: all-dist-${{ github.run_id }} + path: dist/ + - name: Publish distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags/') + uses: pypa/gh-action-pypi-publish@release/v1 + + post-publish: + needs: [publish] + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + contents: write + attestations: write + security-events: write + steps: + - uses: mongodb-labs/drivers-github-tools/secure-checkout@v2 + with: + app_id: ${{ vars.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: mongodb-labs/drivers-github-tools/setup@v2 + with: + aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} + aws_region_name: ${{ vars.AWS_REGION_NAME }} + aws_secret_id: ${{ secrets.AWS_SECRET_ID }} + artifactory_username: ${{ vars.ARTIFACTORY_USERNAME }} + - uses: mongodb-labs/drivers-github-tools/python-labs/post-publish@v2 + with: + following_version: ${{ env.FOLLOWING_VERSION }} + product_name: ${{ env.PRODUCT_NAME }} + token: ${{ github.token }} + dry_run: ${{ env.DRY_RUN }} diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml new file mode 100644 index 0000000..583dbca --- /dev/null +++ b/.github/workflows/test-python.yml @@ -0,0 +1,107 @@ +name: Python Tests + +on: + push: + branches: ["main"] + pull_request: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash -eux {0} + +env: + MIN_PYTHON: "3.9" + MIN_MONGODB: "4.0" + MAX_MONGODB: "8.0" + +jobs: + static: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - uses: extractions/setup-just@v3 + - run: just install + - run: just lint + - run: just docs + - run: just doctest + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: ["ubuntu-latest", "macos-latest", "windows-latest"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + fail-fast: false + name: CPython ${{ matrix.python-version }}-${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - uses: extractions/setup-just@v3 + - name: Start MongoDB on Linux + if: ${{ startsWith(runner.os, 'Linux') }} + uses: supercharge/mongodb-github-action@1.12.0 + with: + mongodb-version: ${{ env.MAX_MONGODB }} + mongodb-replica-set: test-rs + - name: Start MongoDB on MacOS + if: ${{ startsWith(runner.os, 'macOS') }} + run: | + brew tap mongodb/brew + brew install mongodb/brew/mongodb-community@${MAX_MONGODB} + brew services start mongodb-community@${MAX_MONGODB} + - name: Start MongoDB on Windows + if: ${{ startsWith(runner.os, 'Windows') }} + shell: powershell + run: | + mkdir data + mongod --remove + mongod --install --dbpath=$(pwd)/data --logpath=$PWD/mongo.log + net start MongoDB + - run: just install + - run: just test + + build-min: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ env.MIN_PYTHON }} + - uses: extractions/setup-just@v3 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ env.MIN_PYTHON }} + - uses: extractions/setup-just@v3 + - uses: supercharge/mongodb-github-action@1.12.0 + with: + mongodb-version: ${{ env.MIN_MONGODB }} + mongodb-replica-set: test-rs + - name: Run unit tests with minimum dependency versions + run: | + uv sync --python=${MIN_PYTHON} --resolution=lowest-direct + just test diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..0fbdbd6 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,32 @@ +name: GitHub Actions Security Analysis with zizmor + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +jobs: + zizmor: + name: zizmor latest via Cargo + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Get zizmor + run: cargo install zizmor + - name: Run zizmor + run: zizmor --format sarif . > results.sarif + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif + category: zizmor diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b79d6dd..1808b53 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,67 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks + repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.3 - hooks: - - id: ruff - args: [ --fix ] - - id: ruff-format +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: forbid-new-submodules + - id: trailing-whitespace + +# We use the Python version instead of the original version which seems to require Docker +# https://github.com/koalaman/shellcheck-precommit +- repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + name: shellcheck + args: ["--severity=warning"] + stages: [manual] + +- repo: https://github.com/sirosen/check-jsonschema + rev: 0.31.0 + hooks: + - id: check-github-workflows + args: ["--verbose"] + +- repo: https://github.com/codespell-project/codespell + rev: "v2.3.0" + hooks: + - id: codespell + args: ["-L", "nd"] + stages: [manual] + +- repo: https://github.com/adamchainz/blacken-docs + rev: "1.19.1" + hooks: + - id: blacken-docs + additional_dependencies: [black==24.*] + +- repo: https://github.com/pre-commit/pygrep-hooks + rev: "v1.10.0" + hooks: + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal + +- repo: https://github.com/hukkin/mdformat + rev: 0.7.21 + hooks: + - id: mdformat + # Optionally add plugins + additional_dependencies: + - mdformat-gfm + +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.9.1 + hooks: + # Run the linter. + - id: ruff + args: [ --fix, --show-fixes ] + # Run the formatter. + - id: ruff-format diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff41f17 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# django-mongodb-extensions diff --git a/django_mongodb_extensions/tests/__init__.py b/django_mongodb_extensions/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_mongodb_extensions/tests/base.py b/django_mongodb_extensions/tests/base.py new file mode 100644 index 0000000..3f40261 --- /dev/null +++ b/django_mongodb_extensions/tests/base.py @@ -0,0 +1,132 @@ +import contextvars +from typing import Optional + +import html5lib +from asgiref.local import Local +from django.http import HttpResponse +from django.test import ( + AsyncClient, + AsyncRequestFactory, + Client, + RequestFactory, + TestCase, + TransactionTestCase, +) + +from debug_toolbar.panels import Panel +from debug_toolbar.toolbar import DebugToolbar + +data_contextvar = contextvars.ContextVar("djdt_toolbar_test_client") + + +class ToolbarTestClient(Client): + def request(self, **request): + # Use a thread/async task context-local variable to guard against a + # concurrent _created signal from a different thread/task. + data = Local() + data.toolbar = None + + def handle_toolbar_created(sender, toolbar=None, **kwargs): + data.toolbar = toolbar + + DebugToolbar._created.connect(handle_toolbar_created) + try: + response = super().request(**request) + finally: + DebugToolbar._created.disconnect(handle_toolbar_created) + response.toolbar = data.toolbar + + return response + + +class AsyncToolbarTestClient(AsyncClient): + async def request(self, **request): + # Use a thread/async task context-local variable to guard against a + # concurrent _created signal from a different thread/task. + # In cases testsuite will have both regular and async tests or + # multiple async tests running in an eventloop making async_client calls. + data_contextvar.set(None) + + def handle_toolbar_created(sender, toolbar=None, **kwargs): + data_contextvar.set(toolbar) + + DebugToolbar._created.connect(handle_toolbar_created) + try: + response = await super().request(**request) + finally: + DebugToolbar._created.disconnect(handle_toolbar_created) + response.toolbar = data_contextvar.get() + + return response + + +rf = RequestFactory() +arf = AsyncRequestFactory() + + +class BaseMixin: + _is_async = False + client_class = ToolbarTestClient + async_client_class = AsyncToolbarTestClient + + panel: Optional[Panel] = None + panel_id = None + + def setUp(self): + super().setUp() + self._get_response = lambda request: HttpResponse() + self.request = rf.get("/") + if self._is_async: + self.request = arf.get("/") + self.toolbar = DebugToolbar(self.request, self.get_response_async) + else: + self.toolbar = DebugToolbar(self.request, self.get_response) + self.toolbar.stats = {} + + if self.panel_id: + self.panel = self.toolbar.get_panel_by_id(self.panel_id) + self.panel.enable_instrumentation() + else: + self.panel = None + + def tearDown(self): + if self.panel: + self.panel.disable_instrumentation() + super().tearDown() + + def get_response(self, request): + return self._get_response(request) + + async def get_response_async(self, request): + return self._get_response(request) + + def assertValidHTML(self, content): + parser = html5lib.HTMLParser() + parser.parseFragment(content) + if parser.errors: + msg_parts = ["Invalid HTML:"] + lines = content.split("\n") + for position, errorcode, datavars in parser.errors: + msg_parts.append(f" {html5lib.constants.E[errorcode]}" % datavars) + msg_parts.append(f" {lines[position[0] - 1]}") + raise self.failureException("\n".join(msg_parts)) + + +class BaseTestCase(BaseMixin, TestCase): + pass + + +class BaseMultiDBTestCase(BaseMixin, TransactionTestCase): + databases = {"default", "replica"} + + +class IntegrationTestCase(TestCase): + """Base TestCase for tests involving clients making requests.""" + + def setUp(self): + # The HistoryPanel keeps track of previous stores in memory. + # This bleeds into other tests and violates their idempotency. + # Clear the store before each test. + for key in list(DebugToolbar._store.keys()): + del DebugToolbar._store[key] + super().setUp() diff --git a/django_mongodb_extensions/tests/panels/__init__.py b/django_mongodb_extensions/tests/panels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_mongodb_extensions/tests/panels/test_mql.py b/django_mongodb_extensions/tests/panels/test_mql.py new file mode 100644 index 0000000..02a0800 --- /dev/null +++ b/django_mongodb_extensions/tests/panels/test_mql.py @@ -0,0 +1,27 @@ +from django.contrib.auth.models import User + + +from ..base import BaseTestCase + + +class MQLPanelTestCase(BaseTestCase): + panel_id = "MQLPanel" + + def test_disabled(self): + config = { + "DISABLE_PANELS": { + "django_mongodb_extensions.debug_toolbar.panels.mql.MQLPanel" + } + } + self.assertTrue(self.panel.enabled) + with self.settings(DEBUG_TOOLBAR_CONFIG=config): + self.assertFalse(self.panel.enabled) + + def test_not_insert_locals(self): + """ + Test that the panel does not insert locals() content. + """ + list(User.objects.filter(username="café")) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + self.assertNotIn("djdt-locals", self.panel.content) diff --git a/justfile b/justfile index 789b1ea..1270741 100644 --- a/justfile +++ b/justfile @@ -1,2 +1,28 @@ +docs_build := "docs/_build" +sphinx_opts:= "-d " + docs_build + "/doctrees docs" + +# Default target executed when no arguments are given. +[private] default: - echo 'Hello, world!' + @just --list + +install: + uv sync + uv run pre-commit install + +test *args: + uv run pytest {{args}} + +lint: + uv run pre-commit run --hook-stage manual --all-files + +docs: + uv run sphinx-build -T -b html {{sphinx_opts}} {{docs_build}} + +doctest: + uv run python -m doctest -v examples/wiki/wiki.py + uv run sphinx-build -E -b doctest {{sphinx_opts}} {{docs_build}}/doctest + uv run sphinx-build -b linkcheck {{sphinx_opts}} {{docs_build}}/linkcheck + +typing: + uv run mypy --install-types --non-interactive . diff --git a/pyproject.toml b/pyproject.toml index 62f5f65..7b3e010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,3 +5,7 @@ build-backend = "hatchling.build" [project] name = "django-mongodb-extensions" version = "0.1.0" +dependencies = [ + "django-mongodb-backend", +] +requires-python = ">=3.9"