diff --git a/pyproject.toml b/pyproject.toml index 96eca37f..495837b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,10 @@ readme = "README.rst" packages = [ { include = "ftrack_api", from = "source" }, ] +include = [ + { path = "source/ftrack_api/**/*.pyi", format = ["sdist", "wheel"] }, + { path = "source/ftrack_api/py.typed", format = ["sdist", "wheel"] }, +] [tool.poetry-dynamic-versioning] enable = true diff --git a/source/ftrack_api/__init__.pyi b/source/ftrack_api/__init__.pyi new file mode 100644 index 00000000..ec7d10f4 --- /dev/null +++ b/source/ftrack_api/__init__.pyi @@ -0,0 +1,5 @@ +from ._version import __version__ as __version__ +from .session import Session as Session +from _typeshed import Incomplete + +def mixin(instance, mixin_class, name: Incomplete | None = None) -> None: ... diff --git a/source/ftrack_api/_centralized_storage_scenario.pyi b/source/ftrack_api/_centralized_storage_scenario.pyi new file mode 100644 index 00000000..2a54a9f9 --- /dev/null +++ b/source/ftrack_api/_centralized_storage_scenario.pyi @@ -0,0 +1,25 @@ +from _typeshed import Incomplete + +scenario_name: str + +class ConfigureCentralizedStorageScenario: + logger: Incomplete + def __init__(self) -> None: ... + @property + def storage_scenario(self): ... + @property + def existing_centralized_storage_configuration(self): ... + def configure_scenario(self, event): ... + def discover_centralized_scenario(self, event): ... + session: Incomplete + def register(self, session) -> None: ... + +class ActivateCentralizedStorageScenario: + logger: Incomplete + def __init__(self) -> None: ... + def activate(self, event) -> None: ... + session: Incomplete + def register(self, session) -> None: ... + +def register(session) -> None: ... +def register_configuration(session) -> None: ... diff --git a/source/ftrack_api/_python_ntpath.pyi b/source/ftrack_api/_python_ntpath.pyi new file mode 100644 index 00000000..e48b30a7 --- /dev/null +++ b/source/ftrack_api/_python_ntpath.pyi @@ -0,0 +1,85 @@ +from genericpath import * +from _typeshed import Incomplete +from nt import _isdir as isdir + +__all__ = [ + "normcase", + "isabs", + "join", + "splitdrive", + "split", + "splitext", + "basename", + "dirname", + "commonprefix", + "getsize", + "getmtime", + "getatime", + "getctime", + "islink", + "exists", + "lexists", + "isdir", + "isfile", + "ismount", + "walk", + "expanduser", + "expandvars", + "normpath", + "abspath", + "splitunc", + "curdir", + "pardir", + "sep", + "pathsep", + "defpath", + "altsep", + "extsep", + "devnull", + "realpath", + "supports_unicode_filenames", + "relpath", +] + +curdir: str +pardir: str +extsep: str +sep: str +pathsep: str +altsep: str +defpath: str +devnull: str + +def normcase(s): ... +def isabs(s): ... +def join(a, *p): ... +def splitdrive(p): ... +def splitunc(p): ... +def split(p): ... +def splitext(p): ... +def basename(p): ... +def dirname(p): ... +def islink(path): ... + +lexists = exists + +def ismount(path): ... +def walk(top, func, arg) -> None: ... +def expanduser(path): ... +def expandvars(path): ... +def normpath(path): ... +def abspath(path): ... + +realpath = abspath +supports_unicode_filenames: Incomplete + +def relpath(path, start=...): ... + +# Names in __all__ with no definition: +# commonprefix +# exists +# getatime +# getctime +# getmtime +# getsize +# isfile diff --git a/source/ftrack_api/_version.pyi b/source/ftrack_api/_version.pyi new file mode 100644 index 00000000..bda5b5a7 --- /dev/null +++ b/source/ftrack_api/_version.pyi @@ -0,0 +1 @@ +__version__: str diff --git a/source/ftrack_api/accessor/__init__.pyi b/source/ftrack_api/accessor/__init__.pyi new file mode 100644 index 00000000..e69de29b diff --git a/source/ftrack_api/accessor/base.pyi b/source/ftrack_api/accessor/base.pyi new file mode 100644 index 00000000..47935a6f --- /dev/null +++ b/source/ftrack_api/accessor/base.pyi @@ -0,0 +1,25 @@ +import abc + +class Accessor(metaclass=abc.ABCMeta): + def __init__(self) -> None: ... + @abc.abstractmethod + def list(self, resource_identifier): ... + @abc.abstractmethod + def exists(self, resource_identifier): ... + @abc.abstractmethod + def is_file(self, resource_identifier): ... + @abc.abstractmethod + def is_container(self, resource_identifier): ... + @abc.abstractmethod + def is_sequence(self, resource_identifier): ... + @abc.abstractmethod + def open(self, resource_identifier, mode: str = "rb"): ... + @abc.abstractmethod + def remove(self, resource_identifier): ... + @abc.abstractmethod + def make_container(self, resource_identifier, recursive: bool = True): ... + @abc.abstractmethod + def get_container(self, resource_identifier): ... + def remove_container(self, resource_identifier): ... + def get_filesystem_path(self, resource_identifier) -> None: ... + def get_url(self, resource_identifier) -> None: ... diff --git a/source/ftrack_api/accessor/disk.pyi b/source/ftrack_api/accessor/disk.pyi new file mode 100644 index 00000000..d38aba24 --- /dev/null +++ b/source/ftrack_api/accessor/disk.pyi @@ -0,0 +1,29 @@ +import ftrack_api.accessor.base +from _typeshed import Incomplete +from collections.abc import Generator +from ftrack_api.exception import ( + AccessorContainerNotEmptyError as AccessorContainerNotEmptyError, + AccessorFilesystemPathError as AccessorFilesystemPathError, + AccessorOperationFailedError as AccessorOperationFailedError, + AccessorParentResourceNotFoundError as AccessorParentResourceNotFoundError, + AccessorPermissionDeniedError as AccessorPermissionDeniedError, + AccessorResourceInvalidError as AccessorResourceInvalidError, + AccessorResourceNotFoundError as AccessorResourceNotFoundError, + AccessorUnsupportedOperationError as AccessorUnsupportedOperationError, +) + +class DiskAccessor(ftrack_api.accessor.base.Accessor): + prefix: Incomplete + def __init__(self, prefix, **kw) -> None: ... + def list(self, resource_identifier): ... + def exists(self, resource_identifier): ... + def is_file(self, resource_identifier): ... + def is_container(self, resource_identifier): ... + def is_sequence(self, resource_identifier) -> None: ... + def open(self, resource_identifier, mode: str = "rb"): ... + def remove(self, resource_identifier) -> None: ... + def make_container(self, resource_identifier, recursive: bool = True) -> None: ... + def get_container(self, resource_identifier): ... + def get_filesystem_path(self, resource_identifier): ... + +def error_handler(**kw) -> Generator[None]: ... diff --git a/source/ftrack_api/accessor/server.pyi b/source/ftrack_api/accessor/server.pyi new file mode 100644 index 00000000..c54a9a7d --- /dev/null +++ b/source/ftrack_api/accessor/server.pyi @@ -0,0 +1,26 @@ +from ..data import String as String +from .base import Accessor as Accessor +from _typeshed import Incomplete + +class ServerFile(String): + mode: Incomplete + resource_identifier: Incomplete + def __init__(self, resource_identifier, session, mode: str = "rb") -> None: ... + def flush(self) -> None: ... + def read(self, limit: Incomplete | None = None): ... + +class _ServerAccessor(Accessor): + def __init__(self, session, **kw) -> None: ... + def open(self, resource_identifier, mode: str = "rb"): ... + def remove(self, resourceIdentifier) -> None: ... + def get_container(self, resource_identifier) -> None: ... + def make_container(self, resource_identifier, recursive: bool = True) -> None: ... + def list(self, resource_identifier) -> None: ... + def exists(self, resource_identifier): ... + def is_file(self, resource_identifier) -> None: ... + def is_container(self, resource_identifier) -> None: ... + def is_sequence(self, resource_identifier) -> None: ... + def get_url(self, resource_identifier): ... + def get_thumbnail_url( + self, resource_identifier, size: Incomplete | None = None + ): ... diff --git a/source/ftrack_api/attribute.pyi b/source/ftrack_api/attribute.pyi new file mode 100644 index 00000000..e0d18299 --- /dev/null +++ b/source/ftrack_api/attribute.pyi @@ -0,0 +1,67 @@ +import ftrack_api.collection +from _typeshed import Incomplete + +logger: Incomplete + +def merge_references(function): ... + +class Attributes: + def __init__(self, attributes: Incomplete | None = None) -> None: ... + def add(self, attribute) -> None: ... + def remove(self, attribute) -> None: ... + def get(self, name): ... + def keys(self): ... + def __contains__(self, item) -> bool: ... + def __iter__(self): ... + def __len__(self) -> int: ... + +class Attribute: + default_value: Incomplete + def __init__( + self, name, default_value=..., mutable: bool = True, computed: bool = False + ) -> None: ... + def get_entity_storage(self, entity): ... + @property + def name(self): ... + @property + def mutable(self): ... + @property + def computed(self): ... + def get_value(self, entity): ... + def get_local_value(self, entity): ... + def get_remote_value(self, entity): ... + def set_local_value(self, entity, value) -> None: ... + def set_remote_value(self, entity, value) -> None: ... + def populate_remote_value(self, entity) -> None: ... + def is_modified(self, entity): ... + def is_set(self, entity): ... + +class ScalarAttribute(Attribute): + data_type: Incomplete + def __init__(self, name, data_type, **kw) -> None: ... + +class ReferenceAttribute(Attribute): + entity_type: Incomplete + def __init__(self, name, entity_type, **kw) -> None: ... + def populate_remote_value(self, entity) -> None: ... + def is_modified(self, entity): ... + def get_value(self, entity): ... + +class AbstractCollectionAttribute(Attribute): + collection_class: Incomplete + def get_value(self, entity): ... + def set_local_value(self, entity, value) -> None: ... + def set_remote_value(self, entity, value) -> None: ... + +class CollectionAttribute(AbstractCollectionAttribute): + collection_class = ftrack_api.collection.Collection + +class KeyValueMappedCollectionAttribute(AbstractCollectionAttribute): + collection_class = ftrack_api.collection.KeyValueMappedCollectionProxy + creator: Incomplete + key_attribute: Incomplete + value_attribute: Incomplete + def __init__(self, name, creator, key_attribute, value_attribute, **kw) -> None: ... + +class CustomAttributeCollectionAttribute(AbstractCollectionAttribute): + collection_class = ftrack_api.collection.CustomAttributeCollectionProxy diff --git a/source/ftrack_api/cache.pyi b/source/ftrack_api/cache.pyi new file mode 100644 index 00000000..bc167a60 --- /dev/null +++ b/source/ftrack_api/cache.pyi @@ -0,0 +1,90 @@ +import abc +from _typeshed import Incomplete + +class Cache(metaclass=abc.ABCMeta): + @abc.abstractmethod + def get(self, key): ... + @abc.abstractmethod + def set(self, key, value): ... + @abc.abstractmethod + def remove(self, key): ... + def keys(self) -> None: ... + def values(self): ... + def clear(self, pattern: Incomplete | None = None) -> None: ... + +class ProxyCache(Cache): + proxied: Incomplete + def __init__(self, proxied) -> None: ... + def get(self, key): ... + def set(self, key, value): ... + def remove(self, key): ... + def keys(self): ... + +class LayeredCache(Cache): + caches: Incomplete + def __init__(self, caches) -> None: ... + def get(self, key): ... + def set(self, key, value) -> None: ... + def remove(self, key) -> None: ... + def keys(self): ... + +class MemoryCache(Cache): + def __init__(self) -> None: ... + def get(self, key): ... + def set(self, key, value) -> None: ... + def remove(self, key) -> None: ... + def keys(self): ... + +class FileCache(Cache): + path: Incomplete + def __init__(self, path) -> None: ... + def get(self, key): ... + def set(self, key, value) -> None: ... + def remove(self, key) -> None: ... + def keys(self): ... + +class SerialisedCache(ProxyCache): + encode: Incomplete + decode: Incomplete + def __init__( + self, + proxied, + encode: Incomplete | None = None, + decode: Incomplete | None = None, + ) -> None: ... + def get(self, key): ... + def set(self, key, value) -> None: ... + +class KeyMaker(metaclass=abc.ABCMeta): + item_separator: str + def __init__(self) -> None: ... + def key(self, *items): ... + +class StringKeyMaker(KeyMaker): ... + +class ObjectKeyMaker(KeyMaker): + item_separator: bytes + mapping_identifier: bytes + mapping_pair_separator: bytes + iterable_identifier: bytes + name_identifier: bytes + def __init__(self) -> None: ... + +class Memoiser: + cache: Incomplete + key_maker: Incomplete + return_copies: Incomplete + def __init__( + self, + cache: Incomplete | None = None, + key_maker: Incomplete | None = None, + return_copies: bool = True, + ) -> None: ... + def call( + self, function, args: Incomplete | None = None, kw: Incomplete | None = None + ): ... + +def memoise_decorator(memoiser): ... + +memoiser: Incomplete +memoise: Incomplete diff --git a/source/ftrack_api/collection.pyi b/source/ftrack_api/collection.pyi new file mode 100644 index 00000000..0ee087d3 --- /dev/null +++ b/source/ftrack_api/collection.pyi @@ -0,0 +1,63 @@ +import abc +import collections.abc +import ftrack_api.cache +from _typeshed import Incomplete + +class Collection(collections.abc.MutableSequence): + entity: Incomplete + attribute: Incomplete + mutable: bool + def __init__( + self, entity, attribute, mutable: bool = True, data: Incomplete | None = None + ) -> None: ... + def __copy__(self): ... + def insert(self, index, item) -> None: ... + def __contains__(self, value) -> bool: ... + def __getitem__(self, index): ... + def __setitem__(self, index, item) -> None: ... + def __delitem__(self, index) -> None: ... + def __len__(self) -> int: ... + def __eq__(self, other): ... + def __ne__(self, other): ... + +class MappedCollectionProxy(collections.abc.MutableMapping, metaclass=abc.ABCMeta): + logger: Incomplete + collection: Incomplete + def __init__(self, collection) -> None: ... + def __copy__(self): ... + @property + def mutable(self): ... + @mutable.setter + def mutable(self, value) -> None: ... + @property + def attribute(self): ... + @attribute.setter + def attribute(self, value) -> None: ... + +class KeyValueMappedCollectionProxy(MappedCollectionProxy): + creator: Incomplete + key_attribute: Incomplete + value_attribute: Incomplete + def __init__(self, collection, creator, key_attribute, value_attribute) -> None: ... + def __getitem__(self, key): ... + def __setitem__(self, key, value) -> None: ... + def __delitem__(self, key) -> None: ... + def __iter__(self): ... + def __len__(self) -> int: ... + def keys(self): ... + +class PerSessionDefaultKeyMaker(ftrack_api.cache.KeyMaker): ... + +memoise_session: Incomplete + +class CustomAttributeCollectionProxy(MappedCollectionProxy): + key_attribute: str + value_attribute: str + def __init__(self, collection) -> None: ... + def get_configuration_id_from_key(self, key): ... + def __getitem__(self, key): ... + def __setitem__(self, key, value) -> None: ... + def __delitem__(self, key) -> None: ... + def __eq__(self, collection): ... + def __iter__(self): ... + def __len__(self) -> int: ... diff --git a/source/ftrack_api/data.pyi b/source/ftrack_api/data.pyi new file mode 100644 index 00000000..87909843 --- /dev/null +++ b/source/ftrack_api/data.pyi @@ -0,0 +1,33 @@ +from _typeshed import Incomplete +from abc import ABCMeta, abstractmethod + +class Data(metaclass=ABCMeta): + closed: bool + def __init__(self) -> None: ... + @abstractmethod + def read(self, limit: Incomplete | None = None): ... + @abstractmethod + def write(self, content): ... + def flush(self) -> None: ... + def seek(self, offset, whence=...) -> None: ... + def tell(self) -> None: ... + def close(self) -> None: ... + +class FileWrapper(Data): + wrapped_file: Incomplete + def __init__(self, wrapped_file) -> None: ... + def read(self, limit: Incomplete | None = None): ... + def write(self, content) -> None: ... + def flush(self) -> None: ... + def seek(self, offset, whence=...) -> None: ... + def tell(self): ... + def close(self) -> None: ... + +class File(FileWrapper): + def __init__(self, path, mode: str = "rb") -> None: ... + +class String(FileWrapper): + is_binary: bool + def __init__(self, content: Incomplete | None = None) -> None: ... + def write(self, content) -> None: ... + def read(self, limit: Incomplete | None = None): ... diff --git a/source/ftrack_api/entity/__init__.pyi b/source/ftrack_api/entity/__init__.pyi new file mode 100644 index 00000000..e69de29b diff --git a/source/ftrack_api/entity/asset_version.pyi b/source/ftrack_api/entity/asset_version.pyi new file mode 100644 index 00000000..235d286b --- /dev/null +++ b/source/ftrack_api/entity/asset_version.pyi @@ -0,0 +1,8 @@ +import ftrack_api.entity.base +from _typeshed import Incomplete + +class AssetVersion(ftrack_api.entity.base.Entity): + def create_component( + self, path, data: Incomplete | None = None, location: Incomplete | None = None + ): ... + def encode_media(self, media, keep_original: str = "auto"): ... diff --git a/source/ftrack_api/entity/base.pyi b/source/ftrack_api/entity/base.pyi new file mode 100644 index 00000000..8782fe2d --- /dev/null +++ b/source/ftrack_api/entity/base.pyi @@ -0,0 +1,30 @@ +import abc +import collections.abc +from _typeshed import Incomplete + +class _EntityBase: ... +class DynamicEntityTypeMetaclass(abc.ABCMeta): ... + +class Entity( + _EntityBase, collections.abc.MutableMapping, metaclass=DynamicEntityTypeMetaclass +): + entity_type: str + attributes: Incomplete + primary_key_attributes: Incomplete + default_projections: Incomplete + logger: Incomplete + session: Incomplete + def __init__( + self, session, data: Incomplete | None = None, reconstructing: bool = False + ) -> None: ... + def __hash__(self): ... + def __eq__(self, other): ... + def __getitem__(self, key): ... + def __setitem__(self, key, value) -> None: ... + def __delitem__(self, key) -> None: ... + def __iter__(self): ... + def __len__(self) -> int: ... + def values(self): ... + def items(self): ... + def clear(self) -> None: ... + def merge(self, entity, merged: Incomplete | None = None): ... diff --git a/source/ftrack_api/entity/component.pyi b/source/ftrack_api/entity/component.pyi new file mode 100644 index 00000000..428d6fe8 --- /dev/null +++ b/source/ftrack_api/entity/component.pyi @@ -0,0 +1,8 @@ +import ftrack_api.entity.base +from _typeshed import Incomplete + +class Component(ftrack_api.entity.base.Entity): + def get_availability(self, locations: Incomplete | None = None): ... + +class CreateThumbnailMixin: + def create_thumbnail(self, path, data: Incomplete | None = None): ... diff --git a/source/ftrack_api/entity/factory.pyi b/source/ftrack_api/entity/factory.pyi new file mode 100644 index 00000000..202192f7 --- /dev/null +++ b/source/ftrack_api/entity/factory.pyi @@ -0,0 +1,26 @@ +import ftrack_api.cache +from _typeshed import Incomplete + +class Factory: + logger: Incomplete + def __init__(self) -> None: ... + def create(self, schema, bases: Incomplete | None = None): ... + def create_scalar_attribute( + self, class_name, name, mutable, computed, default, data_type + ): ... + def create_reference_attribute(self, class_name, name, mutable, reference): ... + def create_collection_attribute(self, class_name, name, mutable): ... + def create_mapped_collection_attribute( + self, class_name, name, mutable, reference + ) -> None: ... + +class PerSessionDefaultKeyMaker(ftrack_api.cache.KeyMaker): ... + +memoise_defaults: Incomplete +memoise_session: Incomplete + +class StandardFactory(Factory): + def create(self, schema, bases: Incomplete | None = None): ... + def create_mapped_collection_attribute( + self, class_name, name, mutable, reference + ): ... diff --git a/source/ftrack_api/entity/job.pyi b/source/ftrack_api/entity/job.pyi new file mode 100644 index 00000000..2550982b --- /dev/null +++ b/source/ftrack_api/entity/job.pyi @@ -0,0 +1,7 @@ +import ftrack_api.entity.base +from _typeshed import Incomplete + +class Job(ftrack_api.entity.base.Entity): + def __init__( + self, session, data: Incomplete | None = None, reconstructing: bool = False + ) -> None: ... diff --git a/source/ftrack_api/entity/location.pyi b/source/ftrack_api/entity/location.pyi new file mode 100644 index 00000000..8ba59f17 --- /dev/null +++ b/source/ftrack_api/entity/location.pyi @@ -0,0 +1,47 @@ +import abc +import collections.abc +import ftrack_api.entity.base +from _typeshed import Incomplete + +class Location(ftrack_api.entity.base.Entity): + accessor: Incomplete + structure: Incomplete + resource_identifier_transformer: Incomplete + priority: int + def __init__( + self, session, data: Incomplete | None = None, reconstructing: bool = False + ) -> None: ... + def add_component(self, component, source, recursive: bool = True): ... + def add_components( + self, components, sources, recursive: bool = True, _depth: int = 0 + ) -> None: ... + def remove_component(self, component, recursive: bool = True): ... + def remove_components(self, components, recursive: bool = True) -> None: ... + def get_component_availability(self, component): ... + def get_component_availabilities(self, components): ... + def get_resource_identifier(self, component): ... + def get_resource_identifiers(self, components): ... + def get_filesystem_path(self, component): ... + def get_filesystem_paths(self, components): ... + def get_url(self, component): ... + +class MemoryLocationMixin( + ftrack_api.entity.base._EntityBase, + collections.abc.MutableMapping, + metaclass=ftrack_api.entity.base.DynamicEntityTypeMetaclass, +): ... +class UnmanagedLocationMixin( + ftrack_api.entity.base._EntityBase, + collections.abc.MutableMapping, + metaclass=ftrack_api.entity.base.DynamicEntityTypeMetaclass, +): ... +class OriginLocationMixin( + MemoryLocationMixin, UnmanagedLocationMixin, metaclass=abc.ABCMeta +): ... + +class ServerLocationMixin( + ftrack_api.entity.base._EntityBase, + collections.abc.MutableMapping, + metaclass=ftrack_api.entity.base.DynamicEntityTypeMetaclass, +): + def get_thumbnail_url(self, component, size: Incomplete | None = None): ... diff --git a/source/ftrack_api/entity/note.pyi b/source/ftrack_api/entity/note.pyi new file mode 100644 index 00000000..4116ec06 --- /dev/null +++ b/source/ftrack_api/entity/note.pyi @@ -0,0 +1,15 @@ +import ftrack_api.entity.base +from _typeshed import Incomplete + +class Note(ftrack_api.entity.base.Entity): + def create_reply(self, content, author): ... + +class CreateNoteMixin: + def create_note( + self, + content, + author, + recipients: Incomplete | None = None, + category: Incomplete | None = None, + labels: Incomplete | None = None, + ): ... diff --git a/source/ftrack_api/entity/project_schema.pyi b/source/ftrack_api/entity/project_schema.pyi new file mode 100644 index 00000000..3b6f0aac --- /dev/null +++ b/source/ftrack_api/entity/project_schema.pyi @@ -0,0 +1,6 @@ +import ftrack_api.entity.base +from _typeshed import Incomplete + +class ProjectSchema(ftrack_api.entity.base.Entity): + def get_statuses(self, schema, type_id: Incomplete | None = None): ... + def get_types(self, schema): ... diff --git a/source/ftrack_api/entity/user.pyi b/source/ftrack_api/entity/user.pyi new file mode 100644 index 00000000..1d6847ce --- /dev/null +++ b/source/ftrack_api/entity/user.pyi @@ -0,0 +1,14 @@ +import ftrack_api.entity.base +from _typeshed import Incomplete + +class User(ftrack_api.entity.base.Entity): + def start_timer( + self, + context: Incomplete | None = None, + comment: str = "", + name: Incomplete | None = None, + force: bool = False, + ): ... + def stop_timer(self): ... + def send_invite(self) -> None: ... + def reset_api_key(self): ... diff --git a/source/ftrack_api/event/__init__.pyi b/source/ftrack_api/event/__init__.pyi new file mode 100644 index 00000000..e69de29b diff --git a/source/ftrack_api/event/base.pyi b/source/ftrack_api/event/base.pyi new file mode 100644 index 00000000..cac918b7 --- /dev/null +++ b/source/ftrack_api/event/base.pyi @@ -0,0 +1,21 @@ +import collections.abc +from _typeshed import Incomplete + +class Event(collections.abc.MutableMapping): + def __init__( + self, + topic, + id: Incomplete | None = None, + data: Incomplete | None = None, + sent: Incomplete | None = None, + source: Incomplete | None = None, + target: str = "", + in_reply_to_event: Incomplete | None = None, + ) -> None: ... + def stop(self) -> None: ... + def is_stopped(self): ... + def __getitem__(self, key): ... + def __setitem__(self, key, value) -> None: ... + def __delitem__(self, key) -> None: ... + def __iter__(self): ... + def __len__(self) -> int: ... diff --git a/source/ftrack_api/event/expression.pyi b/source/ftrack_api/event/expression.pyi new file mode 100644 index 00000000..05420647 --- /dev/null +++ b/source/ftrack_api/event/expression.pyi @@ -0,0 +1,24 @@ +from _typeshed import Incomplete + +class Parser: + def __init__(self) -> None: ... + def parse(self, expression): ... + +class Expression: + def match(self, candidate): ... + +class All(Expression): + def __init__(self, expressions: Incomplete | None = None) -> None: ... + def match(self, candidate): ... + +class Any(Expression): + def __init__(self, expressions: Incomplete | None = None) -> None: ... + def match(self, candidate): ... + +class Not(Expression): + def __init__(self, expression) -> None: ... + def match(self, candidate): ... + +class Condition(Expression): + def __init__(self, key, operator, value) -> None: ... + def match(self, candidate): ... diff --git a/source/ftrack_api/event/hub.pyi b/source/ftrack_api/event/hub.pyi new file mode 100644 index 00000000..b532575a --- /dev/null +++ b/source/ftrack_api/event/hub.pyi @@ -0,0 +1,82 @@ +import threading +from _typeshed import Incomplete +from typing import NamedTuple + +class SocketIoSession(NamedTuple): + id: Incomplete + heartbeatTimeout: Incomplete + supportedTransports: Incomplete + +class ServerDetails(NamedTuple): + scheme: Incomplete + hostname: Incomplete + port: Incomplete + +class EventHub: + logger: Incomplete + id: Incomplete + server: Incomplete + def __init__( + self, + server_url, + api_user, + api_key, + headers: Incomplete | None = None, + cookies: Incomplete | None = None, + ) -> None: ... + def get_server_url(self): ... + def get_network_location(self): ... + @property + def secure(self): ... + def init_connection(self) -> None: ... + def connect(self) -> None: ... + @property + def connected(self): ... + def disconnect(self, unsubscribe: bool = True, reconnect: bool = False) -> None: ... + def reconnect(self, attempts: int = 10, delay: int = 5) -> None: ... + def wait(self, duration: Incomplete | None = None) -> None: ... + def get_subscriber_by_identifier(self, identifier): ... + def subscribe( + self, + subscription, + callback, + subscriber: Incomplete | None = None, + priority: int = 100, + ): ... + def unsubscribe(self, subscriber_identifier) -> None: ... + def publish( + self, + event, + synchronous: bool = False, + on_reply: Incomplete | None = None, + on_error: str = "raise", + ): ... + def publish_reply( + self, source_event, data, source: Incomplete | None = None + ) -> None: ... + def subscription( + self, + subscription, + callback, + subscriber: Incomplete | None = None, + priority: int = 100, + ): ... + +class _SubscriptionContext: + def __init__(self, hub, subscription, callback, subscriber, priority) -> None: ... + def __enter__(self) -> None: ... + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> None: ... + +class _ProcessorThread(threading.Thread): + daemon: bool + logger: Incomplete + client: Incomplete + done: Incomplete + def __init__(self, client) -> None: ... + def run(self) -> None: ... + def cancel(self) -> None: ... diff --git a/source/ftrack_api/event/subscriber.pyi b/source/ftrack_api/event/subscriber.pyi new file mode 100644 index 00000000..2b3fcd2a --- /dev/null +++ b/source/ftrack_api/event/subscriber.pyi @@ -0,0 +1,9 @@ +from _typeshed import Incomplete + +class Subscriber: + subscription: Incomplete + callback: Incomplete + metadata: Incomplete + priority: Incomplete + def __init__(self, subscription, callback, metadata, priority) -> None: ... + def interested_in(self, event): ... diff --git a/source/ftrack_api/event/subscription.pyi b/source/ftrack_api/event/subscription.pyi new file mode 100644 index 00000000..4524fb40 --- /dev/null +++ b/source/ftrack_api/event/subscription.pyi @@ -0,0 +1,6 @@ +from _typeshed import Incomplete + +class Subscription: + parser: Incomplete + def __init__(self, subscription) -> None: ... + def includes(self, event): ... diff --git a/source/ftrack_api/exception.pyi b/source/ftrack_api/exception.pyi new file mode 100644 index 00000000..d9368aa7 --- /dev/null +++ b/source/ftrack_api/exception.pyi @@ -0,0 +1,152 @@ +from _typeshed import Incomplete + +class Error(Exception): + default_message: str + message: Incomplete + details: Incomplete + traceback: Incomplete + def __init__( + self, message: Incomplete | None = None, details: Incomplete | None = None + ) -> None: ... + +class AuthenticationError(Error): + default_message: str + +class ServerError(Error): + default_message: str + +class ServerCompatibilityError(ServerError): + default_message: str + +class NotFoundError(Error): + default_message: str + +class NotUniqueError(Error): + default_message: str + +class IncorrectResultError(Error): + default_message: str + +class NoResultFoundError(IncorrectResultError): + default_message: str + +class MultipleResultsFoundError(IncorrectResultError): + default_message: str + +class EntityTypeError(Error): + default_message: str + +class UnrecognisedEntityTypeError(EntityTypeError): + default_message: str + def __init__(self, entity_type, **kw) -> None: ... + +class OperationError(Error): + default_message: str + +class InvalidStateError(Error): + default_message: str + +class InvalidStateTransitionError(InvalidStateError): + default_message: str + def __init__(self, current_state, target_state, entity, **kw) -> None: ... + +class AttributeError(Error): + default_message: str + +class ImmutableAttributeError(AttributeError): + default_message: str + def __init__(self, attribute, **kw) -> None: ... + +class CollectionError(Error): + default_message: str + def __init__(self, collection, **kw) -> None: ... + +class ImmutableCollectionError(CollectionError): + default_message: str + +class DuplicateItemInCollectionError(CollectionError): + default_message: str + def __init__(self, item, collection, **kw) -> None: ... + +class ParseError(Error): + default_message: str + +class EventHubError(Error): + default_message: str + +class EventHubConnectionError(EventHubError): + default_message: str + +class EventHubPacketError(EventHubError): + default_message: str + +class PermissionDeniedError(Error): + default_message: str + +class LocationError(Error): + default_message: str + +class ComponentNotInAnyLocationError(LocationError): + default_message: str + +class ComponentNotInLocationError(LocationError): + default_message: str + def __init__(self, components, location, **kw) -> None: ... + +class ComponentInLocationError(LocationError): + default_message: str + def __init__(self, components, location, **kw) -> None: ... + +class AccessorError(Error): + default_message: str + +class AccessorOperationFailedError(AccessorError): + default_message: str + def __init__( + self, + operation: str = "", + resource_identifier: Incomplete | None = None, + error: Incomplete | None = None, + **kw + ) -> None: ... + +class AccessorUnsupportedOperationError(AccessorOperationFailedError): + default_message: str + +class AccessorPermissionDeniedError(AccessorOperationFailedError): + default_message: str + +class AccessorResourceIdentifierError(AccessorError): + default_message: str + def __init__(self, resource_identifier, **kw) -> None: ... + +class AccessorFilesystemPathError(AccessorResourceIdentifierError): + default_message: str + +class AccessorResourceError(AccessorError): + default_message: str + def __init__( + self, + operation: str = "", + resource_identifier: Incomplete | None = None, + error: Incomplete | None = None, + **kw + ) -> None: ... + +class AccessorResourceNotFoundError(AccessorResourceError): + default_message: str + +class AccessorParentResourceNotFoundError(AccessorResourceError): + default_message: str + +class AccessorResourceInvalidError(AccessorResourceError): + default_message: str + +class AccessorContainerNotEmptyError(AccessorResourceError): + default_message: str + +class StructureError(Error): + default_message: str + +class ConnectionClosedError(Error): + default_message: str diff --git a/source/ftrack_api/formatter.pyi b/source/ftrack_api/formatter.pyi new file mode 100644 index 00000000..21596f3b --- /dev/null +++ b/source/ftrack_api/formatter.pyi @@ -0,0 +1,13 @@ +from _typeshed import Incomplete + +FILTER: Incomplete + +def format( + entity, + formatters: Incomplete | None = None, + attribute_filter: Incomplete | None = None, + recursive: bool = False, + indent: int = 0, + indent_first_line: bool = True, + _seen: Incomplete | None = None, +): ... diff --git a/source/ftrack_api/inspection.pyi b/source/ftrack_api/inspection.pyi new file mode 100644 index 00000000..49bfed1b --- /dev/null +++ b/source/ftrack_api/inspection.pyi @@ -0,0 +1,4 @@ +def identity(entity): ... +def primary_key(entity): ... +def state(entity): ... +def states(entities): ... diff --git a/source/ftrack_api/logging.pyi b/source/ftrack_api/logging.pyi new file mode 100644 index 00000000..66174dbb --- /dev/null +++ b/source/ftrack_api/logging.pyi @@ -0,0 +1,9 @@ +from _typeshed import Incomplete + +def deprecation_warning(message): ... + +class LazyLogMessage: + message: Incomplete + args: Incomplete + kwargs: Incomplete + def __init__(self, message, *args, **kwargs) -> None: ... diff --git a/source/ftrack_api/operation.pyi b/source/ftrack_api/operation.pyi new file mode 100644 index 00000000..766feb02 --- /dev/null +++ b/source/ftrack_api/operation.pyi @@ -0,0 +1,32 @@ +from _typeshed import Incomplete + +class Operations: + def __init__(self) -> None: ... + def clear(self) -> None: ... + def push(self, operation) -> None: ... + def pop(self): ... + def __len__(self) -> int: ... + def __iter__(self): ... + +class Operation: ... + +class CreateEntityOperation(Operation): + entity_type: Incomplete + entity_key: Incomplete + entity_data: Incomplete + def __init__(self, entity_type, entity_key, entity_data) -> None: ... + +class UpdateEntityOperation(Operation): + entity_type: Incomplete + entity_key: Incomplete + attribute_name: Incomplete + old_value: Incomplete + new_value: Incomplete + def __init__( + self, entity_type, entity_key, attribute_name, old_value, new_value + ) -> None: ... + +class DeleteEntityOperation(Operation): + entity_type: Incomplete + entity_key: Incomplete + def __init__(self, entity_type, entity_key) -> None: ... diff --git a/source/ftrack_api/plugin.pyi b/source/ftrack_api/plugin.pyi new file mode 100644 index 00000000..cb609fc6 --- /dev/null +++ b/source/ftrack_api/plugin.pyi @@ -0,0 +1,8 @@ +from _typeshed import Incomplete + +def load_source(modname, filename): ... +def discover( + paths, + positional_arguments: Incomplete | None = None, + keyword_arguments: Incomplete | None = None, +) -> None: ... diff --git a/source/ftrack_api/py.typed b/source/ftrack_api/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/source/ftrack_api/query.pyi b/source/ftrack_api/query.pyi new file mode 100644 index 00000000..95f7d0cb --- /dev/null +++ b/source/ftrack_api/query.pyi @@ -0,0 +1,12 @@ +import collections.abc +from _typeshed import Incomplete + +class QueryResult(collections.abc.Sequence): + OFFSET_EXPRESSION: Incomplete + LIMIT_EXPRESSION: Incomplete + def __init__(self, session, expression, page_size: int = 500) -> None: ... + def __getitem__(self, index): ... + def __len__(self) -> int: ... + def all(self): ... + def one(self): ... + def first(self): ... diff --git a/source/ftrack_api/resource_identifier_transformer/__init__.pyi b/source/ftrack_api/resource_identifier_transformer/__init__.pyi new file mode 100644 index 00000000..e69de29b diff --git a/source/ftrack_api/resource_identifier_transformer/base.pyi b/source/ftrack_api/resource_identifier_transformer/base.pyi new file mode 100644 index 00000000..754e9cc9 --- /dev/null +++ b/source/ftrack_api/resource_identifier_transformer/base.pyi @@ -0,0 +1,7 @@ +from _typeshed import Incomplete + +class ResourceIdentifierTransformer: + session: Incomplete + def __init__(self, session) -> None: ... + def encode(self, resource_identifier, context: Incomplete | None = None): ... + def decode(self, resource_identifier, context: Incomplete | None = None): ... diff --git a/source/ftrack_api/session.pyi b/source/ftrack_api/session.pyi new file mode 100644 index 00000000..24b78a78 --- /dev/null +++ b/source/ftrack_api/session.pyi @@ -0,0 +1,142 @@ +import collections.abc +import requests.auth +from _typeshed import Incomplete + +class SessionAuthentication(requests.auth.AuthBase): + api_key: Incomplete + api_user: Incomplete + def __init__(self, api_key, api_user) -> None: ... + def __call__(self, request): ... + +class Session: + logger: Incomplete + recorded_operations: Incomplete + cache_key_maker: Incomplete + cache: Incomplete + merge_lock: Incomplete + request_timeout: Incomplete + schemas: Incomplete + types: Incomplete + def __init__( + self, + server_url: Incomplete | None = None, + api_key: Incomplete | None = None, + api_user: Incomplete | None = None, + auto_populate: bool = True, + plugin_paths: Incomplete | None = None, + cache: Incomplete | None = None, + cache_key_maker: Incomplete | None = None, + auto_connect_event_hub: bool = False, + schema_cache_path: Incomplete | None = None, + plugin_arguments: Incomplete | None = None, + timeout: int = 60, + cookies: Incomplete | None = None, + headers: Incomplete | None = None, + strict_api: bool = False, + ) -> None: ... + def __enter__(self): ... + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> None: ... + @property + def auto_populate(self): ... + @auto_populate.setter + def auto_populate(self, value) -> None: ... + @property + def record_operations(self): ... + @record_operations.setter + def record_operations(self, value) -> None: ... + @property + def closed(self): ... + @property + def server_information(self): ... + @property + def server_url(self): ... + @property + def api_user(self): ... + @property + def api_key(self): ... + @property + def event_hub(self): ... + def check_server_compatibility(self) -> None: ... + def close(self) -> None: ... + def reset(self) -> None: ... + def auto_populating(self, auto_populate): ... + def operation_recording(self, record_operations): ... + @property + def created(self): ... + @property + def modified(self): ... + @property + def deleted(self): ... + def reset_remote(self, reset_type, entity: Incomplete | None = None): ... + def create( + self, entity_type, data: Incomplete | None = None, reconstructing: bool = False + ): ... + def ensure(self, entity_type, data, identifying_keys: Incomplete | None = None): ... + def delete(self, entity) -> None: ... + def get(self, entity_type, entity_key): ... + def query(self, expression, page_size: int = 500): ... + def merge(self, value, merged: Incomplete | None = None): ... + def populate(self, entities, projections) -> None: ... + def commit(self) -> None: ... + def rollback(self) -> None: ... + def call(self, data): ... + def encode(self, data, entity_attribute_strategy: str = "set_only"): ... + def entity_reference(self, entity): ... + def decode(self, string): ... + def pick_location(self, component: Incomplete | None = None): ... + def pick_locations(self, components): ... + def create_component( + self, path, data: Incomplete | None = None, location: str = "auto" + ): ... + def get_component_availability( + self, component, locations: Incomplete | None = None + ): ... + def get_component_availabilities( + self, components, locations: Incomplete | None = None + ): ... + def get_widget_url( + self, name, entity: Incomplete | None = None, theme: Incomplete | None = None + ): ... + def encode_media( + self, media, version_id: Incomplete | None = None, keep_original: str = "auto" + ): ... + def get_upload_metadata( + self, component_id, file_name, file_size, checksum: Incomplete | None = None + ): ... + def send_user_invite(self, user) -> None: ... + def send_user_invites(self, users) -> None: ... + def send_review_session_invite(self, invitee) -> None: ... + def send_review_session_invites(self, invitees) -> None: ... + +class AutoPopulatingContext: + def __init__(self, session, auto_populate) -> None: ... + def __enter__(self) -> None: ... + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> None: ... + +class OperationRecordingContext: + def __init__(self, session, record_operations) -> None: ... + def __enter__(self) -> None: ... + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> None: ... + +class OperationPayload(collections.abc.MutableMapping): + def __init__(self, *args, **kwargs) -> None: ... + def __getitem__(self, key): ... + def __setitem__(self, key, value) -> None: ... + def __delitem__(self, key) -> None: ... + def __iter__(self): ... + def __len__(self) -> int: ... diff --git a/source/ftrack_api/structure/__init__.pyi b/source/ftrack_api/structure/__init__.pyi new file mode 100644 index 00000000..e69de29b diff --git a/source/ftrack_api/structure/base.pyi b/source/ftrack_api/structure/base.pyi new file mode 100644 index 00000000..6b6ccaf3 --- /dev/null +++ b/source/ftrack_api/structure/base.pyi @@ -0,0 +1,9 @@ +from _typeshed import Incomplete +from abc import ABCMeta, abstractmethod + +class Structure(metaclass=ABCMeta): + prefix: Incomplete + path_separator: str + def __init__(self, prefix: str = "") -> None: ... + @abstractmethod + def get_resource_identifier(self, entity, context: Incomplete | None = None): ... diff --git a/source/ftrack_api/structure/entity_id.pyi b/source/ftrack_api/structure/entity_id.pyi new file mode 100644 index 00000000..65b522b9 --- /dev/null +++ b/source/ftrack_api/structure/entity_id.pyi @@ -0,0 +1,5 @@ +import ftrack_api.structure.base +from _typeshed import Incomplete + +class EntityIdStructure(ftrack_api.structure.base.Structure): + def get_resource_identifier(self, entity, context: Incomplete | None = None): ... diff --git a/source/ftrack_api/structure/id.pyi b/source/ftrack_api/structure/id.pyi new file mode 100644 index 00000000..4a34e68d --- /dev/null +++ b/source/ftrack_api/structure/id.pyi @@ -0,0 +1,5 @@ +import ftrack_api.structure.base +from _typeshed import Incomplete + +class IdStructure(ftrack_api.structure.base.Structure): + def get_resource_identifier(self, entity, context: Incomplete | None = None): ... diff --git a/source/ftrack_api/structure/origin.pyi b/source/ftrack_api/structure/origin.pyi new file mode 100644 index 00000000..382625d7 --- /dev/null +++ b/source/ftrack_api/structure/origin.pyi @@ -0,0 +1,5 @@ +from .base import Structure as Structure +from _typeshed import Incomplete + +class OriginStructure(Structure): + def get_resource_identifier(self, entity, context: Incomplete | None = None): ... diff --git a/source/ftrack_api/structure/standard.pyi b/source/ftrack_api/structure/standard.pyi new file mode 100644 index 00000000..43e07dd4 --- /dev/null +++ b/source/ftrack_api/structure/standard.pyi @@ -0,0 +1,13 @@ +import ftrack_api.structure.base +from _typeshed import Incomplete + +class StandardStructure(ftrack_api.structure.base.Structure): + project_versions_prefix: Incomplete + illegal_character_substitute: Incomplete + def __init__( + self, + project_versions_prefix: Incomplete | None = None, + illegal_character_substitute: str = "_", + ) -> None: ... + def sanitise_for_filesystem(self, value): ... + def get_resource_identifier(self, entity, context: Incomplete | None = None): ... diff --git a/source/ftrack_api/symbol.pyi b/source/ftrack_api/symbol.pyi new file mode 100644 index 00000000..3ecdc7b8 --- /dev/null +++ b/source/ftrack_api/symbol.pyi @@ -0,0 +1,22 @@ +from _typeshed import Incomplete + +class Symbol: + name: Incomplete + value: Incomplete + def __init__(self, name, value: bool = True) -> None: ... + def __bool__(self) -> bool: ... + def __copy__(self): ... + +NOT_SET: Incomplete +CREATED: Incomplete +MODIFIED: Incomplete +DELETED: Incomplete +COMPONENT_ADDED_TO_LOCATION_TOPIC: str +COMPONENT_REMOVED_FROM_LOCATION_TOPIC: str +ORIGIN_LOCATION_ID: str +UNMANAGED_LOCATION_ID: str +REVIEW_LOCATION_ID: str +CONNECT_LOCATION_ID: str +SERVER_LOCATION_ID: str +CHUNK_SIZE: Incomplete +JOB_SYNC_USERS_LDAP: Incomplete diff --git a/test/unit/test_stubs.py b/test/unit/test_stubs.py new file mode 100644 index 00000000..18f93039 --- /dev/null +++ b/test/unit/test_stubs.py @@ -0,0 +1,597 @@ +"""Verify completeness and correctness of .pyi stub files against source. + +This test suite verifies that inline stub files (.pyi) located alongside +source files (.py) in the ftrack_api package maintain API compatibility. +Inline stubs are used per PEP 561 for type distribution. +""" + +import ast +import pathlib +import textwrap + +import pytest + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +SOURCE_ROOT = PROJECT_ROOT / "source" / "ftrack_api" +# Inline stubs are in the same directory as source files +STUBS_ROOT = SOURCE_ROOT + +# Dunder methods conventionally omitted from stubs. +EXCLUDED_DUNDERS = frozenset({"__repr__", "__str__"}) + +# ── AST helpers ────────────────────────────────────────────────────────────── + + +def _extract_params(func_node): + """Return a list of parameter names from a function/method AST node.""" + args = func_node.args + params = [a.arg for a in args.posonlyargs] + params.extend(a.arg for a in args.args) + if args.vararg: + params.append(f"*{args.vararg.arg}") + params.extend(a.arg for a in args.kwonlyargs) + if args.kwarg: + params.append(f"**{args.kwarg.arg}") + return params + + +def _decorator_names(node): + """Return set of decorator name strings for a function/class node.""" + names = set() + for dec in node.decorator_list: + if isinstance(dec, ast.Name): + names.add(dec.id) + elif isinstance(dec, ast.Attribute): + names.add(dec.attr) + return names + + +def _is_property(func_node): + return "property" in _decorator_names(func_node) + + +def _is_property_setter(func_node): + return "setter" in _decorator_names(func_node) + + +def _is_namedtuple_assignment(node): + """Check if an ast.Assign is a collections.namedtuple() call.""" + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call): + return False + func = node.value.func + if isinstance(func, ast.Attribute) and func.attr == "namedtuple": + return True + if isinstance(func, ast.Name) and func.id == "namedtuple": + return True + return False + + +def _extract_class_api(class_node): + """Extract methods, properties, and class-level attrs from a ClassDef.""" + methods = {} + properties = {} + attrs = [] + + for item in class_node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if _is_property(item): + has_setter = False + # Look ahead for setter in same class body + for sibling in class_node.body: + if ( + isinstance(sibling, (ast.FunctionDef, ast.AsyncFunctionDef)) + and sibling.name == item.name + and _is_property_setter(sibling) + ): + has_setter = True + break + properties[item.name] = {"has_setter": has_setter} + elif _is_property_setter(item): + # Already handled via the getter above + pass + else: + methods[item.name] = _extract_params(item) + elif isinstance(item, ast.Assign): + for target in item.targets: + if isinstance(target, ast.Name): + attrs.append(target.id) + elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + attrs.append(item.target.id) + + return {"methods": methods, "properties": properties, "attrs": attrs} + + +def _parse_module_api(filepath): + """Parse a .py/.pyi file and return its API surface. + + Returns dict with keys: classes, functions, module_attrs. + """ + tree = ast.parse(filepath.read_text(), filename=str(filepath)) + api = {"classes": {}, "functions": {}, "module_attrs": []} + + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ClassDef): + api["classes"][node.name] = _extract_class_api(node) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + api["functions"][node.name] = _extract_params(node) + elif _is_namedtuple_assignment(node): + # Treat namedtuple assignments as classes (stubs define them as + # NamedTuple classes). + for target in node.targets: + if isinstance(target, ast.Name): + api["classes"][target.id] = { + "methods": {}, + "properties": {}, + "attrs": [], + } + elif isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + api["module_attrs"].append(target.id) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + api["module_attrs"].append(node.target.id) + + return api + + +# ── Caching & discovery ───────────────────────────────────────────────────── + +_api_cache = {} + + +def _get_api(filepath): + """Return parsed API for *filepath*, caching the result.""" + filepath = filepath.resolve() + if filepath not in _api_cache: + if filepath.exists(): + _api_cache[filepath] = _parse_module_api(filepath) + else: + _api_cache[filepath] = { + "classes": {}, + "functions": {}, + "module_attrs": [], + } + return _api_cache[filepath] + + +def _discover_source_modules(): + """Return sorted list of relative paths for all source .py files.""" + return sorted(p.relative_to(SOURCE_ROOT) for p in SOURCE_ROOT.rglob("*.py")) + + +def _stub_path_for(source_rel): + """Return the expected stub path for a source module relative path.""" + return STUBS_ROOT / source_rel.with_suffix(".pyi") + + +def _is_public(name): + """True for names that should appear in stubs (public + most dunders).""" + if name.startswith("__") and name.endswith("__"): + return name not in EXCLUDED_DUNDERS + return not name.startswith("_") + + +# ── Discovery for parametrization ─────────────────────────────────────────── + +_source_modules = _discover_source_modules() + + +def _discover_classes(): + items = [] + for mod in _source_modules: + source_api = _get_api(SOURCE_ROOT / mod) + for class_name in source_api["classes"]: + items.append((mod, class_name)) + return items + + +def _discover_public_methods(): + items = [] + for mod in _source_modules: + source_api = _get_api(SOURCE_ROOT / mod) + for class_name, class_info in source_api["classes"].items(): + for method_name in class_info["methods"]: + if _is_public(method_name): + items.append((mod, class_name, method_name)) + for prop_name in class_info["properties"]: + if _is_public(prop_name): + items.append((mod, class_name, prop_name)) + return items + + +def _discover_public_functions(): + items = [] + for mod in _source_modules: + source_api = _get_api(SOURCE_ROOT / mod) + for func_name in source_api["functions"]: + if _is_public(func_name): + items.append((mod, func_name)) + return items + + +# ── Tests ──────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "module_rel_path", + _source_modules, + ids=[str(p) for p in _source_modules], +) +def test_stub_file_exists(module_rel_path): + """Every source module must have a corresponding .pyi stub.""" + stub = _stub_path_for(module_rel_path) + assert stub.exists(), f"Missing stub file for {module_rel_path}" + + +_classes = _discover_classes() + + +@pytest.mark.parametrize( + "module_path,class_name", + _classes, + ids=[f"{m}::{c}" for m, c in _classes], +) +def test_class_exists_in_stub(module_path, class_name): + """Every class in source must exist in the stub.""" + stub = _stub_path_for(module_path) + if not stub.exists(): + pytest.skip("stub file missing") + stub_api = _get_api(stub) + assert ( + class_name in stub_api["classes"] + ), f"Class {class_name!r} missing from stub {stub.relative_to(PROJECT_ROOT)}" + + +_methods = _discover_public_methods() + + +@pytest.mark.parametrize( + "module_path,class_name,method_name", + _methods, + ids=[f"{m}::{c}::{meth}" for m, c, meth in _methods], +) +def test_method_exists_in_stub(module_path, class_name, method_name): + """Every public method/property in a source class must exist in the stub.""" + stub = _stub_path_for(module_path) + if not stub.exists(): + pytest.skip("stub file missing") + stub_api = _get_api(stub) + if class_name not in stub_api["classes"]: + pytest.skip("class missing from stub") + + stub_class = stub_api["classes"][class_name] + in_methods = method_name in stub_class["methods"] + in_properties = method_name in stub_class["properties"] + in_attrs = method_name in stub_class["attrs"] + + assert ( + in_methods or in_properties or in_attrs + ), f"Method/property {class_name}.{method_name!r} missing from stub" + + +@pytest.mark.parametrize( + "module_path,class_name,method_name", + _methods, + ids=[f"{m}::{c}::{meth}" for m, c, meth in _methods], +) +def test_method_params_match(module_path, class_name, method_name): + """Parameter names must match between source and stub for each method.""" + stub = _stub_path_for(module_path) + if not stub.exists(): + pytest.skip("stub file missing") + stub_api = _get_api(stub) + source_api = _get_api(SOURCE_ROOT / module_path) + + if class_name not in stub_api["classes"]: + pytest.skip("class missing from stub") + + source_class = source_api["classes"][class_name] + stub_class = stub_api["classes"][class_name] + + # Property getters always have just (self) — skip param comparison. + if method_name in source_class["properties"]: + # But verify the setter signature if present in source. + source_prop = source_class["properties"][method_name] + if source_prop["has_setter"]: + stub_prop = stub_class.get("properties", {}).get(method_name) + if stub_prop is not None: + assert stub_prop["has_setter"], ( + f"Property {class_name}.{method_name} has setter in source " + f"but not in stub" + ) + return + + # Regular method — compare parameter names. + if method_name not in stub_class["methods"]: + # Already flagged by test_method_exists_in_stub; may be an attr in stub. + pytest.skip("method not present as a callable in stub") + + source_params = source_class["methods"][method_name] + stub_params = stub_class["methods"][method_name] + + assert source_params == stub_params, ( + f"{class_name}.{method_name} params differ:\n" + f" source: {source_params}\n" + f" stub: {stub_params}" + ) + + +_functions = _discover_public_functions() + + +@pytest.mark.parametrize( + "module_path,func_name", + _functions, + ids=[f"{m}::{f}" for m, f in _functions], +) +def test_function_exists_in_stub(module_path, func_name): + """Every public module-level function in source must exist in the stub.""" + stub = _stub_path_for(module_path) + if not stub.exists(): + pytest.skip("stub file missing") + stub_api = _get_api(stub) + assert func_name in stub_api["functions"], ( + f"Function {func_name!r} missing from stub " f"{stub.relative_to(PROJECT_ROOT)}" + ) + + +@pytest.mark.parametrize( + "module_path,func_name", + _functions, + ids=[f"{m}::{f}" for m, f in _functions], +) +def test_function_params_match(module_path, func_name): + """Parameter names must match between source and stub for functions.""" + stub = _stub_path_for(module_path) + if not stub.exists(): + pytest.skip("stub file missing") + stub_api = _get_api(stub) + source_api = _get_api(SOURCE_ROOT / module_path) + + if func_name not in stub_api["functions"]: + pytest.skip("function missing from stub") + + source_params = source_api["functions"][func_name] + stub_params = stub_api["functions"][func_name] + + assert source_params == stub_params, ( + f"Function {func_name} params differ:\n" + f" source: {source_params}\n" + f" stub: {stub_params}" + ) + + +# ── Self-tests for the harness helpers ─────────────────────────────────────── + + +def _write_temp_py(tmp_path, code, name="module.py"): + """Write *code* to a temp file and return its Path.""" + p = tmp_path / name + p.write_text(textwrap.dedent(code)) + return p + + +def test_parse_detects_classes(tmp_path): + """_parse_module_api should find all classes by name.""" + source = _write_temp_py( + tmp_path, + """\ + class Foo: + pass + + class _Bar: + pass + """, + ) + api = _parse_module_api(source) + assert "Foo" in api["classes"] + assert "_Bar" in api["classes"] + assert len(api["classes"]) == 2 + + +def test_parse_detects_methods(tmp_path): + """Public methods and dunders extracted; private methods excluded from public API.""" + source = _write_temp_py( + tmp_path, + """\ + class Foo: + def __init__(self, x): + pass + def public(self, a, b): + pass + def _private(self): + pass + def __len__(self): + return 0 + """, + ) + api = _parse_module_api(source) + methods = api["classes"]["Foo"]["methods"] + # All methods are extracted (filtering is done by _is_public, not the parser). + assert "__init__" in methods + assert "public" in methods + assert "_private" in methods + assert "__len__" in methods + # _is_public filters correctly. + assert _is_public("__init__") + assert _is_public("public") + assert not _is_public("_private") + assert _is_public("__len__") + + +def test_parse_detects_properties(tmp_path): + """@property and @x.setter detected with has_setter flag.""" + source = _write_temp_py( + tmp_path, + """\ + class Foo: + @property + def read_only(self): + return 1 + + @property + def read_write(self): + return 2 + + @read_write.setter + def read_write(self, value): + pass + """, + ) + api = _parse_module_api(source) + props = api["classes"]["Foo"]["properties"] + assert "read_only" in props + assert props["read_only"]["has_setter"] is False + assert "read_write" in props + assert props["read_write"]["has_setter"] is True + # Properties should not appear in methods. + assert "read_only" not in api["classes"]["Foo"]["methods"] + assert "read_write" not in api["classes"]["Foo"]["methods"] + + +def test_parse_detects_namedtuple(tmp_path): + """collections.namedtuple() assignments parsed as classes.""" + source = _write_temp_py( + tmp_path, + """\ + import collections + + Point = collections.namedtuple("Point", ["x", "y"]) + CONSTANT = 42 + """, + ) + api = _parse_module_api(source) + assert "Point" in api["classes"] + # CONSTANT should be a module attr, not a class. + assert "Point" not in api["module_attrs"] + assert "CONSTANT" in api["module_attrs"] + + +def test_parse_detects_functions(tmp_path): + """Module-level functions extracted with full param signatures.""" + source = _write_temp_py( + tmp_path, + """\ + def simple(a, b): + pass + + def with_varargs(a, *args, key=None, **kwargs): + pass + """, + ) + api = _parse_module_api(source) + assert api["functions"]["simple"] == ["a", "b"] + assert api["functions"]["with_varargs"] == ["a", "*args", "key", "**kwargs"] + + +def test_parse_detects_module_attrs(tmp_path): + """Module-level assignments and annotated assignments detected.""" + source = _write_temp_py( + tmp_path, + """\ + CONSTANT = 42 + logger: object = None + """, + ) + api = _parse_module_api(source) + assert "CONSTANT" in api["module_attrs"] + assert "logger" in api["module_attrs"] + + +def test_is_public_filtering(): + """_is_public accepts public + most dunders, rejects private + excluded dunders.""" + assert _is_public("foo") is True + assert _is_public("Bar") is True + assert _is_public("__init__") is True + assert _is_public("__getitem__") is True + # Private. + assert _is_public("_private") is False + assert _is_public("_PrivateClass") is False + # Excluded dunders. + assert _is_public("__repr__") is False + assert _is_public("__str__") is False + + +def test_missing_class_detected(tmp_path): + """A class present in source but absent in stub is detectable.""" + source = _write_temp_py( + tmp_path, + """\ + class Foo: + pass + + class Bar: + pass + """, + name="source.py", + ) + stub = _write_temp_py( + tmp_path, + """\ + class Foo: ... + """, + name="stub.pyi", + ) + source_api = _parse_module_api(source) + stub_api = _parse_module_api(stub) + assert "Foo" in stub_api["classes"] + assert "Bar" not in stub_api["classes"] + # Confirm source has both. + assert "Foo" in source_api["classes"] + assert "Bar" in source_api["classes"] + + +def test_missing_method_detected(tmp_path): + """A method present in source class but absent in stub class is detectable.""" + source = _write_temp_py( + tmp_path, + """\ + class Foo: + def bar(self): + pass + def baz(self, x): + pass + """, + name="source.py", + ) + stub = _write_temp_py( + tmp_path, + """\ + class Foo: + def bar(self): ... + """, + name="stub.pyi", + ) + source_api = _parse_module_api(source) + stub_api = _parse_module_api(stub) + stub_methods = stub_api["classes"]["Foo"]["methods"] + source_methods = source_api["classes"]["Foo"]["methods"] + assert "bar" in stub_methods + assert "baz" not in stub_methods + assert "baz" in source_methods + + +def test_param_mismatch_detected(tmp_path): + """Differing parameter lists between source and stub are detectable.""" + source = _write_temp_py( + tmp_path, + """\ + class Foo: + def bar(self, a, b): + pass + """, + name="source.py", + ) + stub = _write_temp_py( + tmp_path, + """\ + class Foo: + def bar(self, a): ... + """, + name="stub.pyi", + ) + source_params = _parse_module_api(source)["classes"]["Foo"]["methods"]["bar"] + stub_params = _parse_module_api(stub)["classes"]["Foo"]["methods"]["bar"] + assert source_params == ["self", "a", "b"] + assert stub_params == ["self", "a"] + assert source_params != stub_params