Skip to content

Commit

Permalink
Merge pull request #1 from mam-dev/code
Browse files Browse the repository at this point in the history
Initial code
  • Loading branch information
bunny-therapist authored Aug 4, 2023
2 parents 65b0922 + 9aa1fd1 commit 0f1464a
Show file tree
Hide file tree
Showing 18 changed files with 955 additions and 1 deletion.
28 changes: 28 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build:
strategy:
matrix:
python_version: ["3.9", "3.10", "3.11"]
runs-on: "ubuntu-latest"
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python_version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python_version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel
python -m pip install tox==4.* tox-gh-actions==3.*
- name: Run tox
run: tox
- name: System test
run: ./tests/system_test/system_test.sh
19 changes: 19 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Thanks to: Sean Hammond
# https://www.seanh.cc/2022/05/21/publishing-python-packages-from-github-actions
name: Publish to PyPI.org
on:
release:
types: [published]
jobs:
pypi:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- run: python3 -m pip install --upgrade build && python3 -m build
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,4 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# pytest-litter

Pytest plugin that, when installed, will fail test cases which
create or delete files. Tests should not modify the file tree,
because it can be a cause of test pollution as well as accidental
committing of files to the repo.

To use it, simply run
```
pip install pytest-litter
```
The only dependency is `pytest` itself.
129 changes: 129 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
[project]
name = "pytest-litter"
description = "Pytest plugin which verifies that tests do not modify file trees."
readme = "README.md"
license = {file = "LICENSE"}
urls = {repo = "https://github.com/mam-dev/pytest-litter"}
requires-python = ">=3.9"
dependencies = ["pytest >= 6.1"]
dynamic = ["version"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Software Development :: Testing",
"Topic :: Software Development :: Testing :: Unit",
"Framework :: Pytest",
"License :: OSI Approved :: Apache Software License",
]

[project.entry-points.pytest11]
pytest-litter = "pytest_litter.plugin.plugin"

[build-system]
requires = ["setuptools>=51", "wheel", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"

[tool.setuptools_scm]

[tool.setuptools.packages.find]
where = ["src"]
namespaces = false

[tool.pytest.ini_options]
addopts = "--random-order -p no:pytest-litter -p pytester -vv"
testpaths = ["tests"]
pytester_example_dir = "tests/pytester"

[tool.coverage.run]
branch = true
source_pkgs = ["pytest_litter"]

[tool.coverage.report]
show_missing = true
fail_under = 100

[tool.mypy]
files = ["src", "tests"]
warn_no_return = true
warn_return_any = true
warn_unused_configs = true
warn_unused_ignores = true
warn_redundant_casts = true
warn_unreachable = true
check_untyped_defs = true
disallow_any_generics = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
no_implicit_reexport = true
strict_equality = true
strict_concatenate = true

[tool.ruff]
src = ["src", "tests"]
select = [
"E", # pycodestyle
"F", # pyflakes
"UP", # pyupgrade
"S", # flake8-bandit
"D", # pydocstyle
"PT", # flake8-pytest-style
"I", # isort
"RUF", # Ruff-specific rules
"PTH", # flake8-use-pathlib
"ERA", # eradicate
"PL", # pylint
"FBT", # flake8-boolean-trap
"B", # flake8-bugbear
"A", # flake8-builtins
"ISC", # flake8-implicit-str-concat
"INP", # flake8-no-pep420
"SLF", # flake8-self
"SIM", # flake8-simplify
"TID", # flake8-tidy-imports
"ARG", # flake8-unused-arguments
"TRY", # tryceratops
"FLY", # flynt
"RSE", # flake8-raise
"RET", # flake8-return
"FIX", # flake8-fixme
"Q", # flake8-quotes
"C4", # flake8-comprehensions
"DTZ", # flake8-datetimez
"T10", # flake8-debugger
"T20", # flake8-print
"TCH", # flake8-type-checking
]
ignore = [
"D100", "D102", "D103", "D104", "D105", "D107",
"PTH123",
"TRY003", "TRY301",
]

[tool.ruff.per-file-ignores]
"tests/**/*.py" = [
"S101", "S105",
"D103",
"FBT001",
"SLF001",
"PLR2004", "PLR0913",
"ARG001",
"INP001",
]

[tool.ruff.pydocstyle]
convention = "google"

[tool.ruff.flake8-pytest-style]
fixture-parentheses = false
mark-parentheses = false
parametrize-names-type = "csv"
parametrize-values-type = "list"

[tool.ruff.flake8-tidy-imports]
ban-relative-imports = "parents"
3 changes: 3 additions & 0 deletions requirements-lint.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ruff
black
mypy
4 changes: 4 additions & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pytest
coverage>=5.3
pytest-random-order
pytest-integration
1 change: 1 addition & 0 deletions src/pytest_litter/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Pytest plugin which verifies that tests do not modify file trees."""
1 change: 1 addition & 0 deletions src/pytest_litter/plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Pytest plugin code."""
44 changes: 44 additions & 0 deletions src/pytest_litter/plugin/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Pytest plugin code."""

from typing import Optional

import pytest

from pytest_litter.plugin.utils import (
COMPARATOR_KEY,
SNAPSHOT_KEY,
raise_test_error_from_comparison,
run_snapshot_comparison,
)
from pytest_litter.snapshots import (
DirectoryIgnoreSpec,
IgnoreSpec,
RegexIgnoreSpec,
SnapshotComparator,
TreeSnapshot,
)


def pytest_configure(config: pytest.Config) -> None:
"""Configure pytest-litter plugin (pytest hook function)."""
ignore_specs: list[IgnoreSpec] = []
basetemp: Optional[str] = config.getoption("basetemp", None)
if basetemp is not None:
ignore_specs.append(
DirectoryIgnoreSpec(
directory=config.rootpath / basetemp,
)
)
ignore_specs.append(RegexIgnoreSpec(regex=r".*/__pycache__.*"))
config.stash[SNAPSHOT_KEY] = TreeSnapshot(root=config.rootpath)
config.stash[COMPARATOR_KEY] = SnapshotComparator(ignore_specs=ignore_specs)


@pytest.hookimpl(hookwrapper=True) # type: ignore[misc]
def pytest_runtest_call(item: pytest.Item): # type: ignore[no-untyped-def]
yield
run_snapshot_comparison(
test_name=item.name,
config=item.config,
mismatch_cb=raise_test_error_from_comparison,
)
60 changes: 60 additions & 0 deletions src/pytest_litter/plugin/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from collections.abc import Iterable
from pathlib import Path
from typing import Callable

import pytest

from pytest_litter.snapshots import SnapshotComparator, SnapshotComparison, TreeSnapshot

SNAPSHOT_KEY = pytest.StashKey[TreeSnapshot]()
COMPARATOR_KEY = pytest.StashKey[SnapshotComparator]()


class ProblematicTestLitterError(Exception):
"""Raised when a test causes littering, i.e., modifies file tree."""


def format_test_snapshot_mismatch_message(
test_name: str, paths_added: Iterable[Path], paths_deleted: Iterable[Path]
) -> str:
def _iterable_to_human_readable(iterable: Iterable[Path]) -> str:
return ", ".join(f"'{x}'" for x in iterable)

message = f"The test '{test_name}'"
if paths_added:
message += f" added {_iterable_to_human_readable(paths_added)}"
if paths_deleted:
message += " and"
if paths_deleted:
message += f" deleted {_iterable_to_human_readable(paths_deleted)}"
return message


def raise_test_error_from_comparison(
test_name: str, comparison: SnapshotComparison
) -> None:
"""Raise ProblematicTestLitterError for test_name based on comparison."""
raise ProblematicTestLitterError(
format_test_snapshot_mismatch_message(
test_name=test_name,
paths_added=tuple(p.path for p in comparison.only_b),
paths_deleted=tuple(p.path for p in comparison.only_a),
)
)


def run_snapshot_comparison(
test_name: str,
config: pytest.Config,
mismatch_cb: Callable[[str, SnapshotComparison], None],
) -> None:
"""Compare current and old snapshots and call mismatch_cb if there is a mismatch."""
original_snapshot: TreeSnapshot = config.stash[SNAPSHOT_KEY]
new_snapshot: TreeSnapshot = TreeSnapshot(root=original_snapshot.root)
config.stash[SNAPSHOT_KEY] = new_snapshot

comparator: SnapshotComparator = config.stash[COMPARATOR_KEY]
comparison: SnapshotComparison = comparator.compare(original_snapshot, new_snapshot)

if not comparison.matches:
mismatch_cb(test_name, comparison)
Loading

0 comments on commit 0f1464a

Please sign in to comment.