diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5306a34..129ebe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,9 +81,6 @@ jobs: type-check: name: Type check runs-on: ubuntu-latest - # Advisory while the diagnostics already in the tree are worked through: the - # job reports them, it does not fail the pull request. - continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -93,9 +90,12 @@ jobs: - name: Install the package and the type checker # ty resolves third-party imports out of the environment the package is - # installed in, so the extras have to be here too. + # installed in, so the extras have to be here too. docs is among them + # because ascenderkit/cli/sphinx.py imports docutils and autoprogram at + # module level. formatting is not: jq ships as a compiled extension with + # no stubs, so installing it resolves nothing. run: | - pip install -e ".[websockets,crypto]" + pip install -e ".[websockets,crypto,docs]" pip install ty==0.0.74 - name: Run the type checker diff --git a/ascenderkit/api/client.py b/ascenderkit/api/client.py index 8d20d97..3c62399 100644 --- a/ascenderkit/api/client.py +++ b/ascenderkit/api/client.py @@ -1,4 +1,5 @@ import logging +from typing import Any import requests from requests.auth import AuthBase @@ -18,9 +19,11 @@ class Token_Auth(AuthBase): def __init__(self, token): self.token = token - def __call__(self, request): - request.headers['Authorization'] = f'Bearer {self.token}' - return request + # Named `r` because AuthBase.__call__ names it that, and a caller holding + # the base type is entitled to pass it by that keyword. + def __call__(self, r): + r.headers['Authorization'] = f'Bearer {self.token}' + return r def log_elapsed(r, *args, **kwargs): # requests hook to display API elapsed time @@ -87,7 +90,10 @@ def request(self, relative_endpoint, method='get', json=None, data=None, query_p use_endpoint = use_endpoint[1:] url = '/'.join([self.server, use_endpoint]) - kwargs = dict(verify=self.verify, params=query_parameters, json=json, data=data, hooks=dict(response=log_elapsed)) + # Annotated because the values are heterogeneous: inference narrows the + # dict to the union of the literals below, and the headers added further + # down are not in it. + kwargs: dict[str, Any] = dict(verify=self.verify, params=query_parameters, json=json, data=data, hooks=dict(response=log_elapsed)) if headers is not None: kwargs['headers'] = headers diff --git a/ascenderkit/api/mixins/has_create.py b/ascenderkit/api/mixins/has_create.py index 414f039..d7172e3 100644 --- a/ascenderkit/api/mixins/has_create.py +++ b/ascenderkit/api/mixins/has_create.py @@ -195,6 +195,8 @@ def _filter_ds_from_payload(obj, *a, **kw): json.dumps = filter_ds_from_payload(json.dumps) try: + # Not a dependency: it is patched only when something else in the + # environment has brought it in. See [[tool.ty.overrides]] in pyproject.toml. import simplejson # noqa simplejson.dumps = filter_ds_from_payload(simplejson.dumps) diff --git a/ascenderkit/api/pages/notifications.py b/ascenderkit/api/pages/notifications.py index 359c802..83fb63f 100644 --- a/ascenderkit/api/pages/notifications.py +++ b/ascenderkit/api/pages/notifications.py @@ -26,12 +26,12 @@ def wait_until_status(self, status, interval=5, timeout=30, **kwargs): adjusted_timeout = timeout - seconds_since_date_string(self.created) return super().wait_until_status(status, interval, adjusted_timeout, **kwargs) - def wait_until_completed(self, interval=5, timeout=240): + def wait_until_completed(self, interval=5, timeout=240, **kwargs): """Notifications need a longer timeout, since the backend often has to wait for the request (sending the notification) to timeout itself """ adjusted_timeout = timeout - seconds_since_date_string(self.created) - return super().wait_until_completed(interval, adjusted_timeout) + return super().wait_until_completed(interval, adjusted_timeout, **kwargs) page.register_page(resources.notification, Notification) diff --git a/ascenderkit/api/pages/page.py b/ascenderkit/api/pages/page.py index 38eca35..3aa32d8 100644 --- a/ascenderkit/api/pages/page.py +++ b/ascenderkit/api/pages/page.py @@ -344,6 +344,10 @@ class PageList: # Page.__init__, next and previous come from the response body through # Page.__getattr__, and get is Page's own. Annotations rather than # assignments, so nothing is created at runtime. + # + # self.__class__ is a Page subclass for the same reason. The two methods + # below hold it in a local first, because the mixin does not inherit Page + # and the only constructor visible behind the class itself is object's. json: dict connection: Any r: Any @@ -379,12 +383,14 @@ def results(self): def go_to_next(self): if self.next: - next_page = self.__class__(self.connection, endpoint=self.next) + page_class: Any = self.__class__ + next_page = page_class(self.connection, endpoint=self.next) return next_page.get() def go_to_previous(self): if self.previous: - prev_page = self.__class__(self.connection, endpoint=self.previous) + page_class: Any = self.__class__ + prev_page = page_class(self.connection, endpoint=self.previous) return prev_page.get() def create(self, *a, **kw): diff --git a/ascenderkit/cli/custom.py b/ascenderkit/cli/custom.py index 75c3224..99b9f77 100644 --- a/ascenderkit/cli/custom.py +++ b/ascenderkit/cli/custom.py @@ -257,10 +257,10 @@ class AdHocCommandRelaunch(HasRelaunch, CustomAction): class WorkflowJobRelaunch(HasRelaunch, CustomAction): resource = 'workflow_jobs' - def add_arguments(self, parser, resource_options_parser): + def add_arguments(self, parser, resource_options_parser, with_pk=True): # The endpoint takes an empty serializer, so OPTIONS advertises no # fields and nothing below would be generated from it. - super().add_arguments(parser, resource_options_parser) + super().add_arguments(parser, resource_options_parser, with_pk=with_pk) parser.choices[self.action].add_argument( '--nodes', choices=['all', 'failed'], diff --git a/ascenderkit/cli/format.py b/ascenderkit/cli/format.py index 1c98c6a..e5c08c8 100644 --- a/ascenderkit/cli/format.py +++ b/ascenderkit/cli/format.py @@ -148,6 +148,8 @@ def format_response(response, fmt='json', filter='.', changed=False): def format_jq(output, fmt): try: + # Ships as a compiled extension with no stubs, so it does not resolve to + # a type checker even when installed. See pyproject.toml. import jq except ImportError: if fmt == '.': diff --git a/ascenderkit/cli/resource.py b/ascenderkit/cli/resource.py index 3038bc2..bc95e04 100644 --- a/ascenderkit/cli/resource.py +++ b/ascenderkit/cli/resource.py @@ -54,9 +54,10 @@ class CustomCommand(metaclass=CustomRegistryMeta): help_text = '' - @property - def name(self): - raise NotImplementedError() + # Set as a plain class attribute by every subclass, so it is declared here + # rather than raised from a property that nothing ever reaches: the registry + # reads it off the class, where a property is the property itself. + name: str def handle(self, client, parser): """To be implemented by subclasses. diff --git a/ascenderkit/cli/sphinx.py b/ascenderkit/cli/sphinx.py index 78ee507..0cdd19e 100644 --- a/ascenderkit/cli/sphinx.py +++ b/ascenderkit/cli/sphinx.py @@ -5,6 +5,12 @@ from .client import CLI from .resource import is_control_resource, CustomCommand +from .utils import HelpfulArgumentParser + +# Bound by __getattr__ below on the first lookup, and by nothing else. An +# annotation rather than an assignment, so the name stays out of the module +# namespace and PEP 562 still routes that first lookup here. +parser: HelpfulArgumentParser class CustomAutoprogramDirective(AutoprogramDirective): diff --git a/ascenderkit/cli/utils.py b/ascenderkit/cli/utils.py index b97d435..454257b 100644 --- a/ascenderkit/cli/utils.py +++ b/ascenderkit/cli/utils.py @@ -24,6 +24,12 @@ class CustomRegistryMeta(type): + # Every registered class carries one, either as a plain attribute or as a + # property on the metaclass that derives it. Declared here because the + # registry below reaches those classes through __subclasses__(), which + # describes them as this class rather than as themselves. + name: str + @property def registry(cls): return dict((command.name, command) for command in cls.__subclasses__()) diff --git a/ascenderkit/exceptions.py b/ascenderkit/exceptions.py index 2f7a8fb..c33419b 100644 --- a/ascenderkit/exceptions.py +++ b/ascenderkit/exceptions.py @@ -1,4 +1,12 @@ +from typing import Any + + class Common(Exception): + # The decoded response body, which the platform renders as a dict for most + # errors, and as a string for the ones it does not serialize. Callers read + # it both ways, so it is not narrowed to either. + msg: Any + def __init__(self, status_string='', message=''): if isinstance(status_string, Exception): self.status_string = '' diff --git a/ascenderkit/scripts/basic_session.py b/ascenderkit/scripts/basic_session.py index 0158fa2..8125de1 100755 --- a/ascenderkit/scripts/basic_session.py +++ b/ascenderkit/scripts/basic_session.py @@ -107,6 +107,8 @@ def load_interactive(): return main() try: + # Not a dependency: the session falls back to code.interact without it. + # See [[tool.ty.overrides]] in pyproject.toml. from IPython import start_ipython basic_session_path = os.path.abspath(__file__) diff --git a/pyproject.toml b/pyproject.toml index 0e6005b..a09fa58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,6 +105,24 @@ select = [ "W291", "W605", ] +[[tool.ty.overrides]] +# Three optional imports the package does not declare: simplejson is patched +# only when something else in the environment has brought it in, IPython decides +# which interactive session ascender-shell drops into, and jq backs `-f jq`. +# Whether each one resolves depends on the environment the checker runs in, so +# the rule is turned off for the files holding them. A `ty: ignore` on the line +# would not do: it becomes an unused suppression, and a warning, the moment one +# of the three is installed. jq is the permanent case of the two, since it ships +# as a compiled extension with no stubs and so resolves either way. +include = [ + "ascenderkit/api/mixins/has_create.py", + "ascenderkit/cli/format.py", + "ascenderkit/scripts/basic_session.py", +] + +[tool.ty.overrides.rules] +unresolved-import = "ignore" + [tool.pytest.ini_options] # Moved here from a [pytest] section in tox.ini. pytest reads both, and this is # the file the rest of the tooling configuration already lives in. diff --git a/tox.ini b/tox.ini index fb63648..73d82f2 100644 --- a/tox.ini +++ b/tox.ini @@ -44,13 +44,14 @@ commands = sphinx-build -b html -W --keep-going ascenderkit/cli/docs/source {envtmpdir}/html [testenv:typecheck] -# Advisory: the type checker runs over ascenderkit and reports what it finds -# without failing the merge, so the diagnostics already in the tree can be -# worked through a module at a time. It resolves third-party imports out of the -# environment the package is installed in; set TYPECHECK_PYTHON to point it at -# one. Pinned like the other linters so results do not drift. +# The type checker runs over ascenderkit and has to come back clean. It resolves +# third-party imports out of the environment the package is installed in; set +# TYPECHECK_PYTHON to point it at one. That is also why docs is installed here, +# since ascenderkit/cli/sphinx.py imports docutils and autoprogram at module +# level. Pinned like the other linters so results do not drift. deps = {[testenv]deps} + .[docs] ty==0.0.74 passenv = TYPECHECK_PYTHON