Skip to content

Commit

Permalink
Typechecking (#40)
Browse files Browse the repository at this point in the history
* typechecking

* use double quotes in pyproject.toml
  • Loading branch information
samuelcolvin authored Nov 15, 2024
1 parent ee6bee2 commit 18a17f1
Show file tree
Hide file tree
Showing 11 changed files with 146 additions and 89 deletions.
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,10 @@ repos:
types: [python]
language: system
pass_filenames: false
- id: typecheck
name: Typecheck
entry: make
args: [typecheck]
language: system
types: [python]
pass_filenames: false
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ lint:
uv run ruff format --check
uv run ruff check

.PHONY: typecheck
typecheck:
uv run pyright

.PHONY: test
test:
uv run pytest
Expand All @@ -41,4 +45,4 @@ testcov:
@uv run coverage html

.PHONY: all
all: format lint testcov
all: format lint typecheck testcov
138 changes: 73 additions & 65 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,116 +1,124 @@
[build-system]
requires = ['hatchling']
build-backend = 'hatchling.build'
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.sdist]
# limit which files are included in the sdist (.tar.gz) asset,
# see https://github.com/pydantic/pydantic/pull/4542
include = ['/README.md', '/Makefile', '/pytest_examples', '/tests']
include = ["/README.md", "/Makefile", "/pytest_examples", "/tests"]

[project]
name = 'pytest-examples'
version = '0.0.14'
description = 'Pytest plugin for testing examples in docstrings and markdown files.'
name = "pytest-examples"
version = "0.0.14"
description = "Pytest plugin for testing examples in docstrings and markdown files."
authors = [
{name = 'Samuel Colvin', email = 's@muelcolvin.com'},
{name = "Samuel Colvin", email = "s@muelcolvin.com"},
]
license = 'MIT'
readme = 'README.md'
license = "MIT"
readme = "README.md"
classifiers = [
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.8',
'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',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: Unix',
'Operating System :: POSIX :: Linux',
'Environment :: Console',
'Environment :: MacOS X',
'Framework :: Pytest',
'Topic :: Software Development :: Libraries :: Python Modules',
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"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",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: Unix",
"Operating System :: POSIX :: Linux",
"Environment :: Console",
"Environment :: MacOS X",
"Framework :: Pytest",
"Topic :: Software Development :: Libraries :: Python Modules",
]
requires-python = '>=3.8'
requires-python = ">=3.8"
dependencies = [
'pytest>=7',
'black>=23',
'ruff>=0.5.0',
"pytest>=7",
"black>=23",
"ruff>=0.5.0",
]

[project.entry-points.pytest11]
examples = 'pytest_examples'
examples = "pytest_examples"

[project.urls]
repository = 'https://github.com/pydantic/pytest-examples'
repository = "https://github.com/pydantic/pytest-examples"

[dependency-groups]
dev = [
'coverage[toml]>=7.6.1',
'pytest-pretty>=1.2.0',
"coverage[toml]>=7.6.1",
"pytest-pretty>=1.2.0",
]
lint = [
'pre-commit>=3.5.0',
'ruff>=0.7.4',
"pre-commit>=3.5.0",
"pyright>=1.1.389",
"ruff>=0.7.4",
]

[tool.pytest.ini_options]
testpaths = ['tests', 'example']
filterwarnings = 'error'
testpaths = ["tests", "example"]
filterwarnings = "error"
xfail_strict = true

[tool.ruff]
line-length = 120
target-version = 'py39'
target-version = "py39"
include = [
'pytest_examples/**/*.py',
'tests/**/*.py',
'examples/**/*.py',
"pytest_examples/**/*.py",
"tests/**/*.py",
"examples/**/*.py",
]
exclude = ['tests/cases_update/*.py']
exclude = ["tests/cases_update/*.py"]

[tool.ruff.lint]
extend-select = [
'Q',
'RUF100',
'C90',
'UP',
'I',
'D',
"Q",
"RUF100",
"C90",
"UP",
"I",
"D",
]
flake8-quotes = { inline-quotes = 'single', multiline-quotes = 'double' }
isort = { combine-as-imports = true, known-first-party = ['pytest_examples'] }
flake8-quotes = { inline-quotes = "single", multiline-quotes = "double" }
isort = { combine-as-imports = true, known-first-party = ["pytest_examples"] }
mccabe = { max-complexity = 15 }
ignore = [
'D101', # ignore missing docstring in public class
'D102', # ignore missing docstring in public method
'D103', # ignore missing docstring in public function
'D107', # ignore missing docstring in __init__ methods
'D100', # ignore missing docstring in module
'D104', # ignore missing docstring in public package
'D105', # ignore missing docstring in magic methods
"D100", # ignore missing docstring in module
"D101", # ignore missing docstring in public class
"D102", # ignore missing docstring in public method
"D103", # ignore missing docstring in public function
"D104", # ignore missing docstring in public package
"D105", # ignore missing docstring in magic methods
"D107", # ignore missing docstring in __init__ methods
]

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

[tool.ruff.format]
docstring-code-format = true
quote-style = 'single'
quote-style = "single"

[tool.coverage.run]
source = ['pytest_examples']
source = ["pytest_examples"]
branch = true

[tool.coverage.report]
precision = 2
exclude_lines = [
'pragma: no cover',
'raise NotImplementedError',
'if TYPE_CHECKING:',
'@overload',
"pragma: no cover",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"@overload",
]

[tool.pyright]
#typeCheckingMode = "strict"
reportUnnecessaryTypeIgnoreComment = true
reportMissingTypeStubs = false
include = ["pytest_examples"]
venvPath = ".venv"
9 changes: 6 additions & 3 deletions pytest_examples/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations as _annotations

from collections.abc import Iterator
from importlib.metadata import version
from pathlib import Path

Expand All @@ -12,7 +13,8 @@
__all__ = 'find_examples', 'CodeExample', 'EvalExample'


def pytest_addoption(parser):
def pytest_addoption(parser) -> None:
"""Add options to the pytest command line."""
group = parser.getgroup('examples')
group.addoption(
'--update-examples',
Expand All @@ -33,7 +35,7 @@ def pytest_addoption(parser):


@pytest.fixture(scope='session')
def _examples_to_update(pytestconfig: pytest.Config) -> list[CodeExample]:
def _examples_to_update(pytestconfig: pytest.Config) -> Iterator[list[CodeExample]]:
"""Don't use this directly, it's just used by."""
global summary

Expand All @@ -48,7 +50,7 @@ def _examples_to_update(pytestconfig: pytest.Config) -> list[CodeExample]:


@pytest.fixture(name='eval_example')
def eval_example(tmp_path: Path, request: pytest.FixtureRequest, _examples_to_update) -> EvalExample:
def eval_example(tmp_path: Path, request: pytest.FixtureRequest, _examples_to_update) -> Iterator[EvalExample]:
"""Fixture to return a `EvalExample` instance for running and linting examples."""
eval_ex = EvalExample(tmp_path=tmp_path, pytest_request=request)
yield eval_ex
Expand All @@ -57,5 +59,6 @@ def eval_example(tmp_path: Path, request: pytest.FixtureRequest, _examples_to_up


def pytest_terminal_summary() -> None:
"""Customise pytest to print the summary of updated examples at the end of the test run."""
if summary:
print(summary)
3 changes: 2 additions & 1 deletion pytest_examples/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from pathlib import Path
from typing import TYPE_CHECKING

from black.mode import DEFAULT_LINE_LENGTH, Mode as BlackMode, TargetVersion as BlackTargetVersion
from black.const import DEFAULT_LINE_LENGTH
from black.mode import Mode as BlackMode, TargetVersion as BlackTargetVersion

if TYPE_CHECKING:
from typing import Literal
Expand Down
2 changes: 1 addition & 1 deletion pytest_examples/eval_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def set_config(

@property
def update_examples(self) -> bool:
return self._pytest_config.getoption('update_examples')
return bool(self._pytest_config.getoption('update_examples'))

def run(
self,
Expand Down
2 changes: 1 addition & 1 deletion pytest_examples/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class FormatError(ValueError):

def ruff_format(
example: CodeExample,
config: ExamplesConfig | None,
config: ExamplesConfig,
*,
ignore_errors: bool = False,
) -> str:
Expand Down
4 changes: 2 additions & 2 deletions pytest_examples/modify_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ def _modify_files(examples: list[CodeExample]) -> str:
for ex in examples:
s = str(ex)
if s in unique_examples:
examples = '\n'.join(f' {ex} (test: {ex.test_id})' for ex in examples)
raise RuntimeError(f'Cannot update the same example in separate tests!\nexamples:\n{examples}')
examples_str = '\n'.join(f' {ex} (test: {ex.test_id})' for ex in examples)
raise RuntimeError(f'Cannot update the same example in separate tests!\nexamples:\n{examples_str}')
unique_examples.add(s)

# same file should not appear in more than one group
Expand Down
47 changes: 33 additions & 14 deletions pytest_examples/run_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from unittest.mock import patch

import pytest
from black import InvalidInput
from black.parsing import InvalidInput

from .lint import black_format, code_diff
from .traceback import create_example_traceback
Expand All @@ -40,9 +40,26 @@ def run_code(
module_globals: dict[str, Any] | None,
call: str | None,
) -> tuple[InsertPrintStatements, dict[str, Any]]:
"""Run the code example.
Args:
example: The `CodeExample` to run.
python_file: The path to the python file.
loader: optional loader to use to load the module.
config: The `ExamplesConfig` to use.
enable_print_mock: If True, mock the `print` function.
print_callback: If not None, a callback to call on `print`.
module_globals: The extra globals to add before calling the module.
call: If not None, a (coroutine) function to call in the module.
Returns:
A tuple of the `InsertPrintStatements` instance and the module's globals.
"""
__tracebackhide__ = True

spec = importlib.util.spec_from_file_location('__main__', str(python_file), loader=loader)
assert spec is not None, f'Could not load {python_file}'
assert spec.loader is not None, f'Loader is None for {python_file}'
module = importlib.util.module_from_spec(spec)

# does nothing if insert_print_statements is False
Expand Down Expand Up @@ -76,37 +93,39 @@ def run_code(

@dataclass(init=False)
class Arg:
string: str | None = None
code: str | None = None
"""A single argument to a print statement."""

data: str
is_str: bool = False

def __init__(self, v: Any):
if isinstance(v, str):
self.string = v
self.data = v
self.is_str = True
elif isinstance(v, set):
# NOTE! this is not recursive
ordered = ', '.join(repr(x) for x in sorted(v))
self.string = f'{{{ordered}}}'
self.data = f'{{{ordered}}}'
else:
self.code = re.sub('0x[a-f0-9]{8,12}>', '0x0123456789ab>', str(v))
self.data = re.sub('0x[a-f0-9]{8,12}>', '0x0123456789ab>', str(v))

def __str__(self) -> str:
if self.string is not None:
return self.string
else:
return self.code
return self.data

def format(self, config: ExamplesConfig) -> str:
if self.string is not None:
return self.string
if self.is_str:
return self.data
else:
try:
return black_format(self.code, config)
return black_format(self.data, config)
except InvalidInput:
return self.code
return self.data


@dataclass
class PrintStatement:
"""A single print statement."""

line_no: int
sep: str
args: list[Arg]
Expand Down
2 changes: 1 addition & 1 deletion pytest_examples/traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def create_custom_frame(frame: FrameType, example: CodeExample) -> FrameType:
)
ctypes.pythonapi.PyFrame_New.restype = ctypes.py_object # PyFrameObject*

ctypes.pythonapi.PyThreadState_Get.argtypes = None
ctypes.pythonapi.PyThreadState_Get.argtypes = None # type: ignore
ctypes.pythonapi.PyThreadState_Get.restype = P_MEM_TYPE

f_code = frame.f_code
Expand Down
Loading

0 comments on commit 18a17f1

Please sign in to comment.