Merge pull request #2153 from nathanwilliams-ct/nathan/enum-static-type-checking
Static Type Checking: Enums
diff --git a/doc/source/hacking/using_the_testsuite.rst b/doc/source/hacking/using_the_testsuite.rst
index 418bdb7..9302b01 100644
--- a/doc/source/hacking/using_the_testsuite.rst
+++ b/doc/source/hacking/using_the_testsuite.rst
@@ -190,6 +190,13 @@
.. _contributing_formatting_code:
+Running Static Type Checkers
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Static Type Checking is performed separately from testing. In order to run the static type checking step which
+consists of running the ``mypy`` tool, run the following::
+
+ tox -e mypy
+
Formatting code
~~~~~~~~~~~~~~~
Similar to linting, code formatting is also done via a ``tox`` environment. To
@@ -197,6 +204,8 @@
tox -e format
+In CI `tox -e format-check` is used to ensure formatting has been run.
+
Observing coverage
~~~~~~~~~~~~~~~~~~
Once you have run the tests using `tox` (or `detox`), some coverage reports will
diff --git a/src/buildstream/_elementproxy.py b/src/buildstream/_elementproxy.py
index 3425b6c..dc7f030 100644
--- a/src/buildstream/_elementproxy.py
+++ b/src/buildstream/_elementproxy.py
@@ -95,7 +95,7 @@
sandbox: "Sandbox",
*,
path: Optional[str] = None,
- action: str = OverlapAction.WARNING,
+ action: OverlapAction = OverlapAction.WARNING,
include: Optional[List[str]] = None,
exclude: Optional[List[str]] = None,
orphans: bool = True
@@ -120,7 +120,7 @@
selection: Optional[Sequence["Element"]] = None,
*,
path: Optional[str] = None,
- action: str = OverlapAction.WARNING,
+ action: OverlapAction = OverlapAction.WARNING,
include: Optional[List[str]] = None,
exclude: Optional[List[str]] = None,
orphans: bool = True
@@ -168,7 +168,7 @@
sandbox: "Sandbox",
*,
path: Optional[str] = None,
- action: str = OverlapAction.WARNING,
+ action: OverlapAction = OverlapAction.WARNING,
include: Optional[List[str]] = None,
exclude: Optional[List[str]] = None,
orphans: bool = True,
diff --git a/src/buildstream/_overlapcollector.py b/src/buildstream/_overlapcollector.py
index 79f62b1..d1f5dc0 100644
--- a/src/buildstream/_overlapcollector.py
+++ b/src/buildstream/_overlapcollector.py
@@ -66,7 +66,7 @@
# location (str): The Sandbox relative location this session was created for
#
@contextmanager
- def session(self, action: str, location: Optional[str]):
+ def session(self, action: OverlapAction, location: Optional[str]):
assert self._session is None, "Stage session already started"
if location is None:
@@ -108,13 +108,13 @@
# location (str): The Sandbox relative location this session was created for
#
class OverlapCollectorSession:
- def __init__(self, element: "Element", action: str, location: str):
+ def __init__(self, element: "Element", action: OverlapAction, location: str):
# The Element we are staging for, on which we'll issue warnings
self._element = element # type: Element
# The OverlapAction for this session
- self._action = action # type: str
+ self._action = action # type: OverlapAction
# The Sandbox relative directory this session was created for
self._location = location # type: str
diff --git a/src/buildstream/_pipeline.py b/src/buildstream/_pipeline.py
index 8a8a9e9..da54de2 100644
--- a/src/buildstream/_pipeline.py
+++ b/src/buildstream/_pipeline.py
@@ -44,7 +44,7 @@
# Yields:
# Elements in the scope of the specified target elements
#
-def dependencies(targets: List[Element], scope: int, *, recurse: bool = True) -> Iterator[Element]:
+def dependencies(targets: List[Element], scope: _Scope, *, recurse: bool = True) -> Iterator[Element]:
# Keep track of 'visited' in this scope, so that all targets
# share the same context.
visited = (BitMap(), BitMap())
@@ -73,7 +73,12 @@
# A list of Elements appropriate for the specified selection mode
#
def get_selection(
- context: Context, targets: List[Element], mode: str, *, silent: bool = True, depth_sort: bool = False
+ context: Context,
+ targets: List[Element],
+ mode: _PipelineSelection,
+ *,
+ silent: bool = True,
+ depth_sort: bool = False
) -> List[Element]:
def redirect_and_log() -> List[Element]:
# Redirect and log if permitted
diff --git a/src/buildstream/_stream.py b/src/buildstream/_stream.py
index 913bc3f..9197107 100644
--- a/src/buildstream/_stream.py
+++ b/src/buildstream/_stream.py
@@ -153,7 +153,7 @@
self,
targets: Iterable[str],
*,
- selection: str = _PipelineSelection.NONE,
+ selection: _PipelineSelection = _PipelineSelection.NONE,
except_targets: Iterable[str] = (),
load_artifacts: bool = False,
connect_artifact_cache: bool = False,
@@ -259,7 +259,7 @@
def shell(
self,
target: str,
- scope: int,
+ scope: _Scope,
prompt: Callable[[Element], str],
*,
unique_id: Optional[str] = None,
@@ -382,7 +382,7 @@
self,
targets: Iterable[str],
*,
- selection: str = _PipelineSelection.NONE,
+ selection: _PipelineSelection = _PipelineSelection.NONE,
ignore_junction_targets: bool = False,
artifact_remotes: Iterable[RemoteSpec] = (),
source_remotes: Iterable[RemoteSpec] = (),
@@ -456,7 +456,7 @@
self,
targets: Iterable[str],
*,
- selection: str = _PipelineSelection.NONE,
+ selection: _PipelineSelection = _PipelineSelection.NONE,
except_targets: Iterable[str] = (),
source_remotes: Iterable[RemoteSpec] = (),
ignore_project_source_remotes: bool = False,
@@ -579,7 +579,7 @@
self,
targets: Iterable[str],
*,
- selection: str = _PipelineSelection.NONE,
+ selection: _PipelineSelection = _PipelineSelection.NONE,
ignore_junction_targets: bool = False,
artifact_remotes: Iterable[RemoteSpec] = (),
ignore_project_artifact_remotes: bool = False,
@@ -633,7 +633,7 @@
self,
targets: Iterable[str],
*,
- selection: str = _PipelineSelection.NONE,
+ selection: _PipelineSelection = _PipelineSelection.NONE,
ignore_junction_targets: bool = False,
artifact_remotes: Iterable[RemoteSpec] = (),
ignore_project_artifact_remotes: bool = False,
@@ -688,7 +688,7 @@
*,
location: Optional[str] = None,
force: bool = False,
- selection: str = _PipelineSelection.RUN,
+ selection: _PipelineSelection = _PipelineSelection.RUN,
integrate: bool = True,
hardlinks: bool = False,
compression: str = "",
@@ -1668,7 +1668,7 @@
self,
targets: Iterable[str],
*,
- selection: str = _PipelineSelection.NONE,
+ selection: _PipelineSelection = _PipelineSelection.NONE,
except_targets: Iterable[str] = (),
ignore_junction_targets: bool = False,
dynamic_plan: bool = False,
diff --git a/src/buildstream/element.py b/src/buildstream/element.py
index 6ac1ff1..209b9e9 100644
--- a/src/buildstream/element.py
+++ b/src/buildstream/element.py
@@ -90,7 +90,7 @@
from .sandbox import _SandboxFlags, SandboxCommandError
from .sandbox._config import SandboxConfig
from .sandbox._sandboxremote import SandboxRemote
-from .types import _Scope, _CacheBuildTrees, _KeyStrength, OverlapAction, _DisplayKey
+from .types import _HostMount, _Scope, _CacheBuildTrees, _KeyStrength, OverlapAction, _DisplayKey
from ._artifact import Artifact
from ._elementproxy import ElementProxy
from ._elementsources import ElementSources
@@ -604,7 +604,7 @@
sandbox: "Sandbox",
*,
path: Optional[str] = None,
- action: str = OverlapAction.WARNING,
+ action: OverlapAction = OverlapAction.WARNING,
include: Optional[List[str]] = None,
exclude: Optional[List[str]] = None,
orphans: bool = True,
@@ -664,7 +664,7 @@
selection: Optional[Sequence["Element"]] = None,
*,
path: Optional[str] = None,
- action: str = OverlapAction.WARNING,
+ action: OverlapAction = OverlapAction.WARNING,
include: Optional[List[str]] = None,
exclude: Optional[List[str]] = None,
orphans: bool = True,
@@ -864,7 +864,7 @@
# Yields:
# (Element): The dependencies in `scope`, in deterministic staging order
#
- def _dependencies(self, scope, *, recurse=True, visited=None):
+ def _dependencies(self, scope: _Scope, *, recurse=True, visited=None):
# The format of visited is (BitMap(), BitMap()), with the first BitMap
# containing element that have been visited for the `_Scope.BUILD` case
@@ -971,7 +971,7 @@
sandbox: "Sandbox",
*,
path: Optional[str] = None,
- action: str = OverlapAction.WARNING,
+ action: OverlapAction = OverlapAction.WARNING,
include: Optional[List[str]] = None,
exclude: Optional[List[str]] = None,
orphans: bool = True,
@@ -2060,7 +2060,16 @@
# usebuildtree (bool): Use the buildtree as its source
#
# Returns: Exit code
- def _shell(self, scope=None, *, mounts=None, isolate=False, prompt=None, command=None, usebuildtree=False):
+ def _shell(
+ self,
+ scope: _Scope | None = None,
+ *,
+ mounts: List[_HostMount] | None = None,
+ isolate: bool = False,
+ prompt: str | None = None,
+ command: List[str] | None = None,
+ usebuildtree: bool = False,
+ ):
with self._prepare_sandbox(scope, shell=True, usebuildtree=usebuildtree) as sandbox:
environment = sandbox._get_configured_environment() or self.get_environment()
diff --git a/src/buildstream/types.py b/src/buildstream/types.py
index a21e1dc..bd69dd2 100644
--- a/src/buildstream/types.py
+++ b/src/buildstream/types.py
@@ -35,6 +35,9 @@
:class:`enum.Enum` attributes accesses can be really slow, and slow down the execution noticeably.
This reimplementation doesn't suffer the same problems, but also does not reimplement everything.
+
+ For mypy Enum static type checking support, all FastEnum should be stubbed as inheriting from enum.Enum.
+ Use `stubgen src/buildstream/types.py --include-docstrings` to generate the stubs, add `from enum import Enum` and replace all `(FastEnum)` with `(Enum)`
"""
name = None
@@ -61,7 +64,7 @@
try:
return cls._value_to_entry[value]
except KeyError:
- if type(value) is cls: # pylint: disable=unidiomatic-typecheck
+ if isinstance(value, cls): # pylint: disable=unidiomatic-typecheck
return value
raise ValueError("Unknown enum value: {}".format(value))
@@ -172,7 +175,6 @@
# Defines the scope of dependencies to include for a given element
# when iterating over the dependency graph in APIs like
# Element._dependencies().
-#
class _Scope(FastEnum):
# All elements which the given element depends on, following
@@ -384,7 +386,7 @@
alias_node: MappingNode = node.get_mapping("aliases")
for alias, uris in alias_node.items():
- assert type(uris) is SequenceNode # pylint: disable=unidiomatic-typecheck
+ assert isinstance(uris, SequenceNode) # pylint: disable=unidiomatic-typecheck
aliases[alias] = uris.as_str_list()
return cls(name, aliases)
diff --git a/src/buildstream/types.pyi b/src/buildstream/types.pyi
new file mode 100644
index 0000000..8a75b9f
--- /dev/null
+++ b/src/buildstream/types.pyi
@@ -0,0 +1,177 @@
+"""
+Foundation type stubs
+================
+
+See src/buildstream/types.py
+
+These stubs are used to replace FastEnum with Enum when doing static type checking.
+
+Buildstream implements a custom `FastEnum`[1].
+mypy only supports `enum.Enum` (and it's official variations) when it comes to doing static type checks on enums [2].
+We can't subclass Enum in FastEnum to make mypy happy, because Enum doesn't allow subclassing [3].
+So we end up with a bunch of `str`, `int` etc around the codebase,
+instead of the appropriate Enum classes like `OverlapAction` or `_Scope` which are usually recorded separately in the doc strings.
+This means we don't get proper static type checking on our Enums :( .
+With stub files [4][5] we can lie to mypy about what type the Enum classes are[6],
+so the type hints around the codebase are now correct be corrected.
+Now we can have proper static type checking on the custom Enums, Yay!
+
+So
+
+```
+class OverlapAction(FastEnum):
+ ERROR: str
+ WARNING: str
+ IGNORE: str
+```
+
+gets stubbed as
+
+```
+from enum import Enum
+class OverlapAction(Enum):
+ ERROR: str
+ WARNING: str
+ IGNORE: str
+```
+
+[1] src/buildstream/types.py#L32
+[2] https://mypy.readthedocs.io/en/stable/literal_types.html#enums
+[3] https://docs.python.org/3/howto/enum.html#restricted-enum-subclassing
+[4] https://typing.python.org/en/latest/guides/writing_stubs.html
+[5] https://mypy.readthedocs.io/en/stable/stubgen.html
+[6] https://github.com/python/mypy/issues/3217
+
+"""
+
+from ._types import MetaFastEnum as MetaFastEnum
+from .node import MappingNode as MappingNode, SequenceNode as SequenceNode
+from _typeshed import Incomplete
+from typing import Any
+from enum import Enum
+
+class FastEnum(metaclass=MetaFastEnum):
+ """
+ A reimplementation of a subset of the `Enum` functionality, which is far quicker than `Enum`.
+
+ :class:`enum.Enum` attributes accesses can be really slow, and slow down the execution noticeably.
+ This reimplementation doesn't suffer the same problems, but also does not reimplement everything.
+
+ For mypy Enum static type checking support, all FastEnum should be stubbed as inheriting from enum.Enum.
+ Use `stubgen src/buildstream/types.py --include-docstrings` to generate the stubs, add `from enum import Enum` and replace all `(FastEnum)` with `(Enum)`
+ """
+
+ name: Incomplete
+ value: Incomplete
+ @classmethod
+ def values(cls):
+ """Get all the possible values for the enum.
+
+ Returns:
+ list: the list of all possible values for the enum
+ """
+
+ def __new__(cls, value): ...
+ def __eq__(self, other): ...
+ def __ne__(self, other): ...
+ def __hash__(self): ...
+ def __reduce__(self): ...
+
+class CoreWarnings:
+ """CoreWarnings()
+
+ Some common warnings which are raised by core functionalities within BuildStream are found in this class.
+ """
+
+ OVERLAPS: str
+ UNSTAGED_FILES: str
+ REF_NOT_IN_TRACK: str
+ UNALIASED_URL: str
+ UNAVAILABLE_SOURCE_INFO: str
+
+class OverlapAction(Enum):
+ """OverlapAction()
+
+ Defines what action to take when files staged into the sandbox overlap.
+
+ .. note::
+
+ This only dictates what happens when functions such as
+ :func:`Element.stage_artifact() <buildstream.element.Element.stage_artifact>` and
+ :func:`Element.stage_dependency_artifacts() <buildstream.element.Element.stage_dependency_artifacts>`
+ are called multiple times in an Element's :func:`Element.stage() <buildstream.element.Element.stage>`
+ implementation, and the files staged from one function call result in overlapping files staged
+ from previous invocations.
+
+ If multiple staged elements overlap eachother within a single call to
+ :func:`Element.stage_dependency_artifacts() <buildstream.element.Element.stage_dependency_artifacts>`,
+ then the :ref:`overlap whitelist <public_overlap_whitelist>` will be ovserved, and warnings will
+ be issued for overlapping files, which will be fatal warnings if
+ :attr:`CoreWarnings.OVERLAPS <buildstream.types.CoreWarnings.OVERLAPS>` is specified
+ as a :ref:`fatal warning <configurable_warnings>`.
+ """
+
+ ERROR: str
+ WARNING: str
+ IGNORE: str
+
+class _Scope(Enum):
+ ALL: int
+ BUILD: int
+ RUN: int
+ NONE: int
+
+class _KeyStrength(Enum):
+ STRONG: int
+ WEAK: int
+
+class _DisplayKey:
+ full: str
+ brief: str
+ strict: bool
+ def __init__(self, full: str, brief: str, strict: bool) -> None: ...
+
+class _SchedulerErrorAction(Enum):
+ CONTINUE: str
+ QUIT: str
+ TERMINATE: str
+
+class _CacheBuildTrees(Enum):
+ ALWAYS: str
+ AUTO: str
+ NEVER: str
+
+class _SourceUriPolicy(Enum):
+ ALL: str
+ ALIASES: str
+ MIRRORS: str
+ USER: str
+
+class _PipelineSelection(Enum):
+ NONE: str
+ REDIRECT: str
+ ALL: str
+ BUILD: str
+ RUN: str
+
+class _ProjectInformation:
+ project: Incomplete
+ provenance: Incomplete
+ duplicates: Incomplete
+ internal: Incomplete
+ def __init__(self, project, provenance_node, duplicates, internal) -> None: ...
+
+class _HostMount:
+ path: str
+ host_path: str
+ optional: bool
+ def __init__(self, path: str, host_path: str | None = None, optional: bool = False) -> None: ...
+
+class _SourceMirror:
+ name: str
+ aliases: dict[str, list[str]]
+ def __init__(self, name: str, aliases: dict[str, list[str]]) -> None: ...
+ @classmethod
+ def new_from_node(cls, node: MappingNode) -> _SourceMirror: ...
+
+SourceRef = None | int | str | list[Any] | dict[str, Any]