Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 64 additions & 12 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,45 @@
---
name: Test

on: [push, pull_request]

jobs:
build:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Set up Python 3.13
uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: Cache
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-lint-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-lint-

- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel
python -m pip install sphinx
python -m pip install ".[test,twisted,dev]"

- name: Ruff
run: |
python -m ruff check .
python -m ruff format --check .

- name: Type Check
run: |
python -m mypy testtools

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand All @@ -24,9 +60,7 @@ jobs:
uses: actions/cache@v5
with:
path: ~/.cache/pip
key:
${{ matrix.os }}-${{ matrix.python-version }}-${{ hashFiles('**/setup.py')
}}
key: ${{ matrix.os }}-${{ matrix.python-version }}-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ matrix.os }}-${{ matrix.python-version }}-

Expand All @@ -36,24 +70,42 @@ jobs:
python -m pip install sphinx
python -m pip install ".[test,twisted,dev]"

- name: Ruff
- name: Run tests
run: |
python -m ruff check .
python -W once -m testtools.run testtools.tests.test_suite

- name: Format
run: |
python -m ruff format --check .
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Set up Python 3.13
uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: Tests
- name: Cache
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-docs-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-docs-

- name: Install dependencies
run: |
python -W once -m testtools.run testtools.tests.test_suite
python -m pip install --upgrade pip wheel
python -m pip install sphinx
python -m pip install ".[test,twisted,dev]"

- name: Docs
run: |
make clean-sphinx docs

success:
needs: build
needs: ["lint", "test", "docs"]
runs-on: ubuntu-latest
name: test successful
steps:
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ include MANIFEST.in
include NEWS
include README.rst
include .gitignore
include testtools/py.typed
recursive-include doc *.rst *.py
prune doc/_build
45 changes: 38 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ Homepage = "https://github.com/testing-cabal/testtools"
[project.optional-dependencies]
test = ["testscenarios", "testresources"]
twisted = ["Twisted", "fixtures"]
dev = ["ruff==0.14.9"]
dev = [
"ruff==0.14.9",
"mypy>=1.0.0",
]

[tool.hatch.version]
source = "vcs"
Expand All @@ -54,18 +57,47 @@ __version__ = {version_tuple!r}
only-include = ["testtools"]

[tool.mypy]
warn_redundant_casts = true
warn_unused_configs = true
check_untyped_defs = true
python_version = "3.10"
show_column_numbers = true
show_error_context = true
strict = true
# this is here temporarily due to the rapid changes in typing across
# dependencies
warn_unused_ignores = false
# FIXME(stephenfin): We should remove this
implicit_reexport = true
exclude = 'doc'

[[tool.mypy.overrides]]
module = [
"fixtures.*",
"testresources.*",
"testscenarios.*",
]
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = [
# FIXME(stephenfin): We would like to remove all modules from this list
# except testtools.tests (we're not sadists)
"testtools.assertions",
"testtools.compat",
"testtools.content",
"testtools.content_type",
"testtools.matchers.*",
"testtools.monkey",
"testtools.run",
"testtools.runtest",
"testtools.testcase",
"testtools.testresult.*",
"testtools.testsuite",
"testtools.twistedsupport.*",
"testtools.tests.*",
]
disallow_untyped_calls = false
disallow_untyped_defs = false
disallow_subclassing_any = false
disallow_any_generics = false

[tool.ruff.lint]
select = [
"E",
Expand All @@ -76,8 +108,7 @@ select = [
"RSE",
"RUF",
]
ignore = [
]
ignore = []

[tool.ruff.lint.pydocstyle]
convention = "google"
9 changes: 4 additions & 5 deletions testtools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,10 @@
"skip",
"skipIf",
"skipUnless",
"try_import",
"unique_text_generator",
"version",
]

from testtools.helpers import try_import
from testtools.matchers._impl import Matcher # noqa: F401
from testtools.runtest import (
MultipleExceptions,
Expand Down Expand Up @@ -96,7 +94,7 @@
)


def __get_git_version():
def __get_git_version() -> str | None:
import os
import subprocess

Expand Down Expand Up @@ -137,9 +135,10 @@ def __get_git_version():
from ._version import __version__, version
except ModuleNotFoundError:
# package is not installed
if version := __get_git_version():
if v := __get_git_version():
version = v
# we're in a git repo
__version__ = tuple([int(v) if v.isdigit() else v for v in version.split(".")])
__version__ = tuple([int(x) if x.isdigit() else x for x in version.split(".")])
else:
# we're working with a tarball or similar
version = "0.0.0"
Expand Down
5 changes: 5 additions & 0 deletions testtools/_version.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Type stub for auto-generated _version module
from typing import Union

__version__: tuple[Union[int, str], ...] # noqa: UP007
version: str
14 changes: 9 additions & 5 deletions testtools/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@
import locale
import os
import sys
import typing
import types
import unicodedata
from io import BytesIO, StringIO # for backwards-compat
from typing import Any, NoReturn

# Ensure retro-compatibility with older testtools releases
from io import BytesIO, StringIO


def reraise(exc_class, exc_obj, exc_tb, _marker=object()) -> typing.NoReturn:
def reraise(
exc_class: type[BaseException],
exc_obj: BaseException,
exc_tb: types.TracebackType,
_marker: Any = object(),
) -> NoReturn:
"""Re-raise an exception received from sys.exc_info() or similar."""
raise exc_obj.with_traceback(exc_tb)

Expand Down
4 changes: 2 additions & 2 deletions testtools/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,9 @@ def filter_stack(stack):

def json_content(json_data):
"""Create a JSON Content object from JSON-encodeable data."""
data = json.dumps(json_data)
json_str = json.dumps(json_data)
# The json module perversely returns native str not bytes
data = data.encode("utf8")
data = json_str.encode("utf8")
return Content(JSON, lambda: [data])


Expand Down
62 changes: 10 additions & 52 deletions testtools/helpers.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,15 @@
# Copyright (c) 2010-2012 testtools developers. See LICENSE for details.

import sys
from collections.abc import Callable
from typing import Any, TypeVar

T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")
R = TypeVar("R")

def try_import(name, alternative=None, error_callback=None):
"""Attempt to import a module, with a fallback.

Attempt to import ``name``. If it fails, return ``alternative``. When
supporting multiple versions of Python or optional dependencies, it is
useful to be able to try to import a module.

:param name: The name of the object to import, e.g. ``os.path`` or
``os.path.join``.
:param alternative: The value to return if no module can be imported.
Defaults to None.
:param error_callback: If non-None, a callable that is passed the
ImportError when the module cannot be loaded.
"""
module_segments = name.split(".")
last_error = None
remainder = []

# module_name will be what successfully imports. We cannot walk from the
# __import__ result because in import loops (A imports A.B, which imports
# C, which calls try_import("A.B")) A.B will not yet be set.
while module_segments:
module_name = ".".join(module_segments)
try:
__import__(module_name)
except ImportError:
last_error = sys.exc_info()[1]
remainder.append(module_segments.pop())
continue
else:
break
else:
if last_error is not None and error_callback is not None:
error_callback(last_error)
return alternative

module = sys.modules[module_name]
nonexistent = object()
for segment in reversed(remainder):
module = getattr(module, segment, nonexistent)
if module is nonexistent:
if last_error is not None and error_callback is not None:
error_callback(last_error)
return alternative

return module


def map_values(function, dictionary):
def map_values(function: Callable[[V], R], dictionary: dict[K, V]) -> dict[K, R]:
"""Map ``function`` across the values of ``dictionary``.

:return: A dict with the same keys as ``dictionary``, where the value
Expand All @@ -60,17 +18,17 @@ def map_values(function, dictionary):
return {k: function(dictionary[k]) for k in dictionary}


def filter_values(function, dictionary):
def filter_values(function: Callable[[V], bool], dictionary: dict[K, V]) -> dict[K, V]:
"""Filter ``dictionary`` by its values using ``function``."""
return {k: v for k, v in dictionary.items() if function(v)}


def dict_subtract(a, b):
def dict_subtract(a: dict[K, V], b: dict[K, Any]) -> dict[K, V]:
"""Return the part of ``a`` that's not in ``b``."""
return {k: a[k] for k in set(a) - set(b)}


def list_subtract(a, b):
def list_subtract(a: list[T], b: list[T]) -> list[T]:
"""Return a list ``a`` without the elements of ``b``.

If a particular value is in ``a`` twice and ``b`` once then the returned
Expand Down
Loading