"""Public API surface snapshot.
Guards the backwards-compatibility contract while the quality-audit
refactors land: every public module keeps its public names, and every
public callable keeps its parameter names and kinds.
The snapshot deliberately records only parameter *names* and *kinds*
(positional / keyword / var-positional / var-keyword) plus whether a
default exists. Annotations and default-value reprs are excluded so a
single snapshot is stable across Python 3.10-3.15 and so widening a type
annotation does not require a snapshot update. Removing or renaming a
parameter, changing its kind, or dropping a public name fails the test.
Regenerate after a deliberate, reviewed API addition with:
PROGRESSBAR_UPDATE_API_SNAPSHOT=1 pytest tests/test_api_surface.py
"""
from __future__ import annotations
import enum
import importlib
import inspect
import json
import os
import pathlib
import types
import typing
import pytest
SNAPSHOT_PATH: pathlib.Path = (
pathlib.Path(__file__).parent / 'api_surface_snapshot.json'
)
PUBLIC_MODULES: tuple[str, ...] = (
'progressbar',
'progressbar.algorithms',
'progressbar.bar',
'progressbar.base',
'progressbar.env',
'progressbar.fast',
'progressbar.multi',
'progressbar.shortcuts',
'progressbar.terminal',
'progressbar.terminal.base',
'progressbar.terminal.colors',
'progressbar.terminal.stream',
'progressbar.utils',
'progressbar.widgets',
)
def _describe_signature(obj: typing.Any) -> str:
"""Return a version-stable signature descriptor for a callable."""
try:
signature = inspect.signature(obj)
except (ValueError, TypeError):
return 'signature-unavailable'
parts: list[str] = []
for name, parameter in signature.parameters.items():
prefix = {
inspect.Parameter.VAR_POSITIONAL: '*',
inspect.Parameter.VAR_KEYWORD: '**',
}.get(parameter.kind, '')
suffix = '=?' if parameter.default is not parameter.empty else ''
parts.append(f'{prefix}{name}{suffix}')
if parameter.kind is inspect.Parameter.KEYWORD_ONLY and (
'*' not in ''.join(parts[:-1])
):
parts.insert(len(parts) - 1, '*')
return f'({", ".join(parts)})'
def _describe(obj: typing.Any) -> str:
if (
typing.get_origin(obj) is not None
or getattr(type(obj), '__module__', '') == 'typing'
):
return 'type-alias'
if inspect.isclass(obj):
if issubclass(obj, enum.Enum):
enum_class = typing.cast('type[enum.Enum]', obj)
members = ','.join(member.name for member in enum_class)
return f'enum({members})'
if not getattr(obj, '__module__', '').startswith('progressbar'):
return 're-export'
return f'class{_describe_signature(obj)}'
if callable(obj):
return f'callable{_describe_signature(obj)}'
return next(
cls.__name__
for cls in type(obj).__mro__
if 'freezegun' not in (getattr(cls, '__module__', '') or '')
)
def _public_names(module: types.ModuleType) -> list[str]:
explicit = getattr(module, '__all__', None)
if explicit is not None:
return sorted(explicit)
return sorted(
name
for name in dir(module)
if not name.startswith('_')
and not isinstance(getattr(module, name), types.ModuleType)
)
def build_surface() -> dict[str, dict[str, str]]:
surface: dict[str, dict[str, str]] = {}
for module_name in PUBLIC_MODULES:
module = importlib.import_module(module_name)
surface[module_name] = {
name: _describe(getattr(module, name))
for name in _public_names(module)
}
return surface
@pytest.mark.no_freezegun
def test_api_surface_snapshot() -> None:
surface: dict[str, dict[str, str]] = build_surface()
if os.environ.get('PROGRESSBAR_UPDATE_API_SNAPSHOT'):
SNAPSHOT_PATH.write_text(
json.dumps(surface, indent=2, sort_keys=True) + '\n',
)
pytest.skip('API surface snapshot regenerated')
assert SNAPSHOT_PATH.exists(), (
'Missing API snapshot; generate it with '
'PROGRESSBAR_UPDATE_API_SNAPSHOT=1 pytest tests/test_api_surface.py'
)
snapshot: dict[str, dict[str, str]] = json.loads(
SNAPSHOT_PATH.read_text(),
)
problems: list[str] = []
for module_name, expected in snapshot.items():
current = surface.get(module_name)
if current is None:
problems.append(f'module removed: {module_name}')
continue
for name, descriptor in expected.items():
if name not in current:
problems.append(f'{module_name}.{name}: removed')
elif current[name] != descriptor:
problems.append(
f'{module_name}.{name}: {descriptor} -> {current[name]}',
)
assert not problems, (
'Public API changed; if the change is a deliberate, reviewed '
'widening, regenerate the snapshot:\n' + '\n'.join(problems)
)