From 3d505874aa08159e5b242cefc67327fe1b99a1a5 Mon Sep 17 00:00:00 2001 From: grdddj Date: Tue, 1 Mar 2022 13:55:58 +0100 Subject: [PATCH] feat(ci): create pyright tool and include it in CI [no changelog] --- core/Makefile | 6 +- core/pyrightconfig.json | 1 + core/src/apps/base.py | 2 +- core/src/apps/bitcoin/sign_tx/bitcoin.py | 2 +- core/src/apps/bitcoin/sign_tx/helpers.py | 42 +- core/src/apps/cardano/address.py | 2 +- core/src/apps/common/paths.py | 2 +- core/src/apps/common/request_pin.py | 3 +- core/src/apps/common/safety_checks.py | 2 +- core/src/apps/debug/__init__.py | 4 +- core/src/apps/stellar/sign_tx.py | 2 +- core/src/storage/device.py | 4 +- core/src/trezor/log.py | 4 +- core/src/trezor/loop.py | 12 +- core/src/trezor/sdcard.py | 4 +- core/src/trezor/ui/__init__.py | 10 +- core/src/trezor/ui/components/tt/swipe.py | 4 +- core/src/trezor/ui/layouts/t1.py | 4 +- core/src/trezor/ui/layouts/tt_v2/__init__.py | 4 +- core/src/trezor/ui/popup.py | 2 +- core/src/trezor/utils.py | 2 +- core/src/trezor/wire/codec_v1.py | 2 +- python/Makefile | 6 +- python/pyrightconfig.json | 1 + python/src/trezorlib/cli/__init__.py | 4 +- python/src/trezorlib/cli/btc.py | 2 +- python/src/trezorlib/cli/ethereum.py | 2 +- python/src/trezorlib/cli/trezorctl.py | 2 +- python/src/trezorlib/debuglink.py | 2 +- python/src/trezorlib/protobuf.py | 4 +- python/src/trezorlib/qt/pinmatrix.py | 2 +- tools/pyright_tool.py | 653 +++++++++++++++++++ 32 files changed, 727 insertions(+), 71 deletions(-) create mode 100755 tools/pyright_tool.py diff --git a/core/Makefile b/core/Makefile index acba8cd8a..7da35a111 100644 --- a/core/Makefile +++ b/core/Makefile @@ -113,8 +113,10 @@ mypy: ## deprecated; use "make typecheck" @echo "mypy is deprecated; use 'make typecheck'" make typecheck -typecheck: - pyright +typecheck: pyright + +pyright: + python ./../tools/pyright_tool.py --dir core clippy: cd embed/rust ; cargo clippy diff --git a/core/pyrightconfig.json b/core/pyrightconfig.json index 535740c2b..f47c4a92b 100644 --- a/core/pyrightconfig.json +++ b/core/pyrightconfig.json @@ -10,5 +10,6 @@ "stubPath": "mocks/generated", "typeCheckingMode": "basic", "pythonVersion": "3.10", + "enableTypeIgnoreComments": false, "reportMissingModuleSource": false } diff --git a/core/src/apps/base.py b/core/src/apps/base.py index 724839a48..d3b0f7d34 100644 --- a/core/src/apps/base.py +++ b/core/src/apps/base.py @@ -176,7 +176,7 @@ async def handle_DoPreauthorized( if handler is None: return wire.unexpected_message() - return await handler(ctx, req, authorization.get()) # type: ignore + return await handler(ctx, req, authorization.get()) # type: ignore [Expected 2 positional arguments] async def handle_CancelAuthorization( diff --git a/core/src/apps/bitcoin/sign_tx/bitcoin.py b/core/src/apps/bitcoin/sign_tx/bitcoin.py index 1a24cbf12..97121eada 100644 --- a/core/src/apps/bitcoin/sign_tx/bitcoin.py +++ b/core/src/apps/bitcoin/sign_tx/bitcoin.py @@ -776,7 +776,7 @@ class Bitcoin: # The nHashType is the 8 least significant bits of the sighash type. # Some coins set the 24 most significant bits of the sighash type to # the fork ID value. - return self.get_hash_type(txi) & 0xFF # type: ignore + return self.get_hash_type(txi) & 0xFF # type: ignore [int-into-enum] def write_tx_input_derived( self, diff --git a/core/src/apps/bitcoin/sign_tx/helpers.py b/core/src/apps/bitcoin/sign_tx/helpers.py index 290ad5383..0b17301fa 100644 --- a/core/src/apps/bitcoin/sign_tx/helpers.py +++ b/core/src/apps/bitcoin/sign_tx/helpers.py @@ -199,55 +199,55 @@ class UiConfirmNonDefaultLocktime(UiConfirm): ) -def confirm_output(output: TxOutput, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[None]: # type: ignore +def confirm_output(output: TxOutput, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[None]: # type: ignore [awaitable-is-generator] return (yield UiConfirmOutput(output, coin, amount_unit)) -def confirm_decred_sstx_submission(output: TxOutput, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[None]: # type: ignore +def confirm_decred_sstx_submission(output: TxOutput, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[None]: # type: ignore [awaitable-is-generator] return (yield UiConfirmDecredSSTXSubmission(output, coin, amount_unit)) -def confirm_payment_request(payment_req: TxAckPaymentRequest, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore +def confirm_payment_request(payment_req: TxAckPaymentRequest, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] return (yield UiConfirmPaymentRequest(payment_req, coin, amount_unit)) -def confirm_replacement(description: str, txid: bytes) -> Awaitable[Any]: # type: ignore +def confirm_replacement(description: str, txid: bytes) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] return (yield UiConfirmReplacement(description, txid)) -def confirm_modify_output(txo: TxOutput, orig_txo: TxOutput, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore +def confirm_modify_output(txo: TxOutput, orig_txo: TxOutput, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] return (yield UiConfirmModifyOutput(txo, orig_txo, coin, amount_unit)) -def confirm_modify_fee(user_fee_change: int, total_fee_new: int, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore +def confirm_modify_fee(user_fee_change: int, total_fee_new: int, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] return (yield UiConfirmModifyFee(user_fee_change, total_fee_new, coin, amount_unit)) -def confirm_total(spending: int, fee: int, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[None]: # type: ignore +def confirm_total(spending: int, fee: int, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[None]: # type: ignore [awaitable-is-generator] return (yield UiConfirmTotal(spending, fee, coin, amount_unit)) -def confirm_joint_total(spending: int, total: int, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore +def confirm_joint_total(spending: int, total: int, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] return (yield UiConfirmJointTotal(spending, total, coin, amount_unit)) -def confirm_feeoverthreshold(fee: int, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore +def confirm_feeoverthreshold(fee: int, coin: CoinInfo, amount_unit: AmountUnit) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] return (yield UiConfirmFeeOverThreshold(fee, coin, amount_unit)) -def confirm_change_count_over_threshold(change_count: int) -> Awaitable[Any]: # type: ignore +def confirm_change_count_over_threshold(change_count: int) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] return (yield UiConfirmChangeCountOverThreshold(change_count)) -def confirm_foreign_address(address_n: list) -> Awaitable[Any]: # type: ignore +def confirm_foreign_address(address_n: list) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] return (yield UiConfirmForeignAddress(address_n)) -def confirm_nondefault_locktime(lock_time: int, lock_time_disabled: bool) -> Awaitable[Any]: # type: ignore +def confirm_nondefault_locktime(lock_time: int, lock_time_disabled: bool) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] return (yield UiConfirmNonDefaultLocktime(lock_time, lock_time_disabled)) -def request_tx_meta(tx_req: TxRequest, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[PrevTx]: # type: ignore +def request_tx_meta(tx_req: TxRequest, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[PrevTx]: # type: ignore [awaitable-is-generator] assert tx_req.details is not None tx_req.request_type = RequestType.TXMETA tx_req.details.tx_hash = tx_hash @@ -258,7 +258,7 @@ def request_tx_meta(tx_req: TxRequest, coin: CoinInfo, tx_hash: bytes | None = N def request_tx_extra_data( tx_req: TxRequest, offset: int, size: int, tx_hash: bytes | None = None -) -> Awaitable[bytearray]: # type: ignore +) -> Awaitable[bytearray]: # type: ignore [awaitable-is-generator] assert tx_req.details is not None tx_req.request_type = RequestType.TXEXTRADATA tx_req.details.extra_data_offset = offset @@ -269,7 +269,7 @@ def request_tx_extra_data( return ack.tx.extra_data_chunk -def request_tx_input(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[TxInput]: # type: ignore +def request_tx_input(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[TxInput]: # type: ignore [awaitable-is-generator] assert tx_req.details is not None if tx_hash: tx_req.request_type = RequestType.TXORIGINPUT @@ -282,7 +282,7 @@ def request_tx_input(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes | return sanitize_tx_input(ack.tx.input, coin) -def request_tx_prev_input(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[PrevInput]: # type: ignore +def request_tx_prev_input(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[PrevInput]: # type: ignore [awaitable-is-generator] assert tx_req.details is not None tx_req.request_type = RequestType.TXINPUT tx_req.details.request_index = i @@ -292,7 +292,7 @@ def request_tx_prev_input(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: by return sanitize_tx_prev_input(ack.tx.input, coin) -def request_tx_output(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[TxOutput]: # type: ignore +def request_tx_output(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[TxOutput]: # type: ignore [awaitable-is-generator] assert tx_req.details is not None if tx_hash: tx_req.request_type = RequestType.TXORIGOUTPUT @@ -305,7 +305,7 @@ def request_tx_output(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes return sanitize_tx_output(ack.tx.output, coin) -def request_tx_prev_output(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[PrevOutput]: # type: ignore +def request_tx_prev_output(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: bytes | None = None) -> Awaitable[PrevOutput]: # type: ignore [awaitable-is-generator] assert tx_req.details is not None tx_req.request_type = RequestType.TXOUTPUT tx_req.details.request_index = i @@ -316,7 +316,7 @@ def request_tx_prev_output(tx_req: TxRequest, i: int, coin: CoinInfo, tx_hash: b return ack.tx.output -def request_payment_req(tx_req: TxRequest, i: int) -> Awaitable[TxAckPaymentRequest]: # type: ignore +def request_payment_req(tx_req: TxRequest, i: int) -> Awaitable[TxAckPaymentRequest]: # type: ignore [awaitable-is-generator] assert tx_req.details is not None tx_req.request_type = RequestType.TXPAYMENTREQ tx_req.details.request_index = i @@ -325,7 +325,7 @@ def request_payment_req(tx_req: TxRequest, i: int) -> Awaitable[TxAckPaymentRequ return sanitize_payment_req(ack) -def request_tx_finish(tx_req: TxRequest) -> Awaitable[None]: # type: ignore +def request_tx_finish(tx_req: TxRequest) -> Awaitable[None]: # type: ignore [awaitable-is-generator] tx_req.request_type = RequestType.TXFINISHED yield None, tx_req _clear_tx_request(tx_req) @@ -344,7 +344,7 @@ def _clear_tx_request(tx_req: TxRequest) -> None: tx_req.serialized.signature_index = None # typechecker thinks serialized_tx is `bytes`, which is immutable # we know that it is `bytearray` in reality - tx_req.serialized.serialized_tx[:] = bytes() # type: ignore + tx_req.serialized.serialized_tx[:] = bytes() # type: ignore ["__setitem__" method not defined on type "bytes"] # Data sanitizers diff --git a/core/src/apps/cardano/address.py b/core/src/apps/cardano/address.py index 67c9f15ab..0765e1d40 100644 --- a/core/src/apps/cardano/address.py +++ b/core/src/apps/cardano/address.py @@ -282,7 +282,7 @@ def get_address_bytes_unsafe(address: str) -> bytes: def _get_address_type(address: bytes) -> CardanoAddressType: - return address[0] >> 4 # type: ignore + return address[0] >> 4 # type: ignore [int-into-enum] def _validate_shelley_address( diff --git a/core/src/apps/common/paths.py b/core/src/apps/common/paths.py index 2a263d3c1..64218f6b1 100644 --- a/core/src/apps/common/paths.py +++ b/core/src/apps/common/paths.py @@ -265,7 +265,7 @@ class PathSchema: # Which in practice it is, the only non-Collection is Interval. # But we're not going to introduce an additional type requirement # for the sake of __repr__ that doesn't exist in production anyway - collection: Collection[int] = component # type: ignore + collection: Collection[int] = component # type: ignore [Expression of type "Container[int]" cannot be assigned to declared type "Collection[int]"] component_str = ",".join(str(unharden(i)) for i in collection) if len(collection) > 1: component_str = "[" + component_str + "]" diff --git a/core/src/apps/common/request_pin.py b/core/src/apps/common/request_pin.py index 84f3b70eb..a4bd233aa 100644 --- a/core/src/apps/common/request_pin.py +++ b/core/src/apps/common/request_pin.py @@ -106,8 +106,7 @@ async def verify_user_pin( raise RuntimeError while retry: - # request_pin_on_device possibly unbound - pin = await request_pin_on_device( # type: ignore + pin = await request_pin_on_device( # type: ignore ["request_pin_on_device" is possibly unbound] ctx, "Wrong PIN, enter again", config.get_pin_rem(), allow_cancel ) if config.unlock(pin, salt): diff --git a/core/src/apps/common/safety_checks.py b/core/src/apps/common/safety_checks.py index be8ecef08..bff8d54ca 100644 --- a/core/src/apps/common/safety_checks.py +++ b/core/src/apps/common/safety_checks.py @@ -11,7 +11,7 @@ def read_setting() -> SafetyCheckLevel: """ temporary_safety_check_level = storage.cache.get(APP_COMMON_SAFETY_CHECKS_TEMPORARY) if temporary_safety_check_level: - return int.from_bytes(temporary_safety_check_level, "big") # type: ignore + return int.from_bytes(temporary_safety_check_level, "big") # type: ignore [int-into-enum] else: stored = storage.device.safety_check_level() if stored == SAFETY_CHECK_LEVEL_STRICT: diff --git a/core/src/apps/debug/__init__.py b/core/src/apps/debug/__init__.py index 2dcfaf2da..5b7694465 100644 --- a/core/src/apps/debug/__init__.py +++ b/core/src/apps/debug/__init__.py @@ -214,8 +214,8 @@ if __debug__: return Success() def boot() -> None: - workflow_handlers.register(MessageType.DebugLinkDecision, dispatch_DebugLinkDecision) # type: ignore - workflow_handlers.register(MessageType.DebugLinkGetState, dispatch_DebugLinkGetState) # type: ignore + workflow_handlers.register(MessageType.DebugLinkDecision, dispatch_DebugLinkDecision) # type: ignore [Argument of type "(ctx: Context, msg: DebugLinkDecision) -> Coroutine[Any, Any, None]" cannot be assigned to parameter "handler" of type "Handler[Msg@register]" in function "register"] + workflow_handlers.register(MessageType.DebugLinkGetState, dispatch_DebugLinkGetState) # type: ignore [Argument of type "(ctx: Context, msg: DebugLinkGetState) -> Coroutine[Any, Any, DebugLinkState | None]" cannot be assigned to parameter "handler" of type "Handler[Msg@register]" in function "register"] workflow_handlers.register( MessageType.DebugLinkReseedRandom, dispatch_DebugLinkReseedRandom ) diff --git a/core/src/apps/stellar/sign_tx.py b/core/src/apps/stellar/sign_tx.py index 19d934fb2..f4225428d 100644 --- a/core/src/apps/stellar/sign_tx.py +++ b/core/src/apps/stellar/sign_tx.py @@ -86,7 +86,7 @@ async def _operations(ctx: Context, w: Writer, num_operations: int) -> None: writers.write_uint32(w, num_operations) for _ in range(num_operations): op = await ctx.call_any(StellarTxOpRequest(), *consts.op_wire_types) - await process_operation(ctx, w, op) # type: ignore + await process_operation(ctx, w, op) # type: ignore [Argument of type "MessageType" cannot be assigned to parameter "op" of type "StellarMessageType" in function "process_operation"] async def _memo(ctx: Context, w: Writer, msg: StellarSignTx) -> None: diff --git a/core/src/storage/device.py b/core/src/storage/device.py index c50aa1766..eaf489511 100644 --- a/core/src/storage/device.py +++ b/core/src/storage/device.py @@ -134,7 +134,7 @@ def get_backup_type() -> BackupType: ): # Invalid backup type raise RuntimeError - return backup_type # type: ignore + return backup_type # type: ignore [int-into-enum] def is_passphrase_enabled() -> bool: @@ -310,7 +310,7 @@ def safety_check_level() -> StorageSafetyCheckLevel: if level not in (SAFETY_CHECK_LEVEL_STRICT, SAFETY_CHECK_LEVEL_PROMPT): return _DEFAULT_SAFETY_CHECK_LEVEL else: - return level # type: ignore + return level # type: ignore [int-into-enum] # do not use this function directly, see apps.common.safety_checks instead diff --git a/core/src/trezor/log.py b/core/src/trezor/log.py index 39f906c2a..d024efe0d 100644 --- a/core/src/trezor/log.py +++ b/core/src/trezor/log.py @@ -65,11 +65,11 @@ def exception(name: str, exc: BaseException) -> None: name, DEBUG, "ui.Result: %s", - exc.value, # type: ignore[attr-defined] # noqa: F821 + exc.value, # type: ignore[Cannot access member "value" for type "BaseException"] ) elif exc.__class__.__name__ == "Cancelled": _log(name, DEBUG, "ui.Cancelled") else: _log(name, ERROR, "exception:") # since mypy 0.770 we cannot override sys, so print_exception is unknown - sys.print_exception(exc) # type: ignore + sys.print_exception(exc) # type: ignore ["print_exception" is not a known member of module] diff --git a/core/src/trezor/loop.py b/core/src/trezor/loop.py index e59b28c9e..8590fe5d5 100644 --- a/core/src/trezor/loop.py +++ b/core/src/trezor/loop.py @@ -147,7 +147,7 @@ def run() -> None: # timeout occurred, run the first scheduled task if _queue: _queue.pop(task_entry) - _step(task_entry[1], task_entry[2]) # type: ignore + _step(task_entry[1], task_entry[2]) # type: ignore [Argument of type "int" cannot be assigned to parameter "task" of type "Task" in function "_step"] # error: Argument 1 to "_step" has incompatible type "int"; expected "Coroutine[Any, Any, Any]" # rationale: We use untyped lists here, because that is what the C API supports. @@ -323,7 +323,7 @@ class race(Syscall): # child is a layout -- type-wise, it is an Awaitable, but # implementation-wise it is an Iterable and we know that its __iter__ # will return a Generator. - child_task = child.__iter__() # type: ignore + child_task = child.__iter__() # type: ignore [Cannot access member "__iter__" for type "Awaitable[Unknown]";;Cannot access member "__iter__" for type "Coroutine[Unknown, Unknown, Unknown]"] schedule(child_task, None, None, finalizer) scheduled.append(child_task) @@ -347,7 +347,7 @@ class race(Syscall): self.exit(task) schedule(self.callback, result) - def __iter__(self) -> Task: # type: ignore + def __iter__(self) -> Task: # type: ignore [awaitable-is-generator] try: return (yield self) except: # noqa: E722 @@ -411,7 +411,7 @@ class chan: self.putters: list[tuple[Task | None, Any]] = [] self.takers: list[Task] = [] - def put(self, value: Any) -> Awaitable[None]: # type: ignore + def put(self, value: Any) -> Awaitable[None]: # type: ignore [awaitable-is-generator] put = chan.Put(self, value) try: return (yield put) @@ -421,7 +421,7 @@ class chan: self.putters.remove(entry) raise - def take(self) -> Awaitable[Any]: # type: ignore + def take(self) -> Awaitable[Any]: # type: ignore [awaitable-is-generator] take = chan.Take(self) try: return (yield take) @@ -521,7 +521,7 @@ class spawn(Syscall): if self.finalizer_callback is not None: self.finalizer_callback(self) - def __iter__(self) -> Task: # type: ignore + def __iter__(self) -> Task: # type: ignore [awaitable-is-generator] if self.finished: # exit immediately if we already have a return value if isinstance(self.return_value, BaseException): diff --git a/core/src/trezor/sdcard.py b/core/src/trezor/sdcard.py index f079076ea..e6582bbee 100644 --- a/core/src/trezor/sdcard.py +++ b/core/src/trezor/sdcard.py @@ -2,8 +2,8 @@ try: from trezorio import fatfs, sdcard HAVE_SDCARD = True - is_present = sdcard.is_present # type: ignore - capacity = sdcard.capacity # type: ignore + is_present = sdcard.is_present # type: ignore [obscured-by-same-name] + capacity = sdcard.capacity # type: ignore [obscured-by-same-name] except Exception: HAVE_SDCARD = False diff --git a/core/src/trezor/ui/__init__.py b/core/src/trezor/ui/__init__.py index a23afd001..99a8f3ce2 100644 --- a/core/src/trezor/ui/__init__.py +++ b/core/src/trezor/ui/__init__.py @@ -45,7 +45,7 @@ if __debug__: display.refresh() else: - refresh = display.refresh # type: ignore + refresh = display.refresh # type: ignore [obscured-by-same-name] # in both debug and production, emulator needs to draw the screen explicitly @@ -115,7 +115,7 @@ async def click() -> Pos: ev, *pos = await touch if ev == io.TOUCH_END: break - return pos # type: ignore + return pos # type: ignore [Expression of type "list[Unknown]" cannot be assigned to return type "Pos"] def backlight_fade(val: int, delay: int = 14000, step: int = 15) -> None: @@ -360,7 +360,7 @@ class Layout(Component): if TYPE_CHECKING: def __await__(self) -> Generator: - return self.__iter__() # type: ignore + return self.__iter__() # type: ignore [Expression of type "Coroutine[Any, Any, Any]" cannot be assigned to return type "Generator[Unknown, Unknown, Unknown]"] else: __await__ = __iter__ @@ -427,7 +427,7 @@ class Layout(Component): refresh() backlight_fade(self.BACKLIGHT_LEVEL) - def handle_rendering(self) -> loop.Task: # type: ignore + def handle_rendering(self) -> loop.Task: # type: ignore [awaitable-is-generator] """Task that is rendering the layout in a busy loop.""" self._before_render() sleep = self.RENDER_SLEEP @@ -440,6 +440,6 @@ class Layout(Component): self.dispatch(RENDER, 0, 0) -def wait_until_layout_is_running() -> Awaitable[None]: # type: ignore +def wait_until_layout_is_running() -> Awaitable[None]: # type: ignore [awaitable-is-generator] while not layout_chan.takers: yield diff --git a/core/src/trezor/ui/components/tt/swipe.py b/core/src/trezor/ui/components/tt/swipe.py index 009d9293d..c9797b398 100644 --- a/core/src/trezor/ui/components/tt/swipe.py +++ b/core/src/trezor/ui/components/tt/swipe.py @@ -105,9 +105,9 @@ class Swipe(ui.Component): raise ui.Result(swipe) def __await__(self) -> Generator: - return self.__iter__() # type: ignore + return self.__iter__() # type: ignore [Expression of type "Task" cannot be assigned to return type "Generator[Unknown, Unknown, Unknown]"] - def __iter__(self) -> loop.Task: # type: ignore + def __iter__(self) -> loop.Task: # type: ignore [awaitable-is-generator] try: touch = loop.wait(io.TOUCH) while True: diff --git a/core/src/trezor/ui/layouts/t1.py b/core/src/trezor/ui/layouts/t1.py index 0ae7d4fc4..a4e0f60ef 100644 --- a/core/src/trezor/ui/layouts/t1.py +++ b/core/src/trezor/ui/layouts/t1.py @@ -26,7 +26,7 @@ class _RustLayout(ui.Layout): def create_tasks(self) -> tuple[loop.Task, ...]: return self.handle_input_and_rendering(), self.handle_timers() - def handle_input_and_rendering(self) -> loop.Task: # type: ignore + def handle_input_and_rendering(self) -> loop.Task: # type: ignore [awaitable-is-generator] button = loop.wait(io.BUTTON) ui.display.clear() self.layout.paint() @@ -41,7 +41,7 @@ class _RustLayout(ui.Layout): if msg is not None: raise ui.Result(msg) - def handle_timers(self) -> loop.Task: # type: ignore + def handle_timers(self) -> loop.Task: # type: ignore [awaitable-is-generator] while True: # Using `yield` instead of `await` to avoid allocations. token = yield self.timer diff --git a/core/src/trezor/ui/layouts/tt_v2/__init__.py b/core/src/trezor/ui/layouts/tt_v2/__init__.py index 05d7e7562..6bf1562e6 100644 --- a/core/src/trezor/ui/layouts/tt_v2/__init__.py +++ b/core/src/trezor/ui/layouts/tt_v2/__init__.py @@ -27,7 +27,7 @@ class _RustLayout(ui.Layout): def create_tasks(self) -> tuple[loop.Task, ...]: return self.handle_input_and_rendering(), self.handle_timers() - def handle_input_and_rendering(self) -> loop.Task: # type: ignore + def handle_input_and_rendering(self) -> loop.Task: # type: ignore [awaitable-is-generator] touch = loop.wait(io.TOUCH) ui.display.clear() self.layout.paint() @@ -44,7 +44,7 @@ class _RustLayout(ui.Layout): if msg is not None: raise ui.Result(msg) - def handle_timers(self) -> loop.Task: # type: ignore + def handle_timers(self) -> loop.Task: # type: ignore [awaitable-is-generator] while True: # Using `yield` instead of `await` to avoid allocations. token = yield self.timer diff --git a/core/src/trezor/ui/popup.py b/core/src/trezor/ui/popup.py index 7a7fd7223..db698ff32 100644 --- a/core/src/trezor/ui/popup.py +++ b/core/src/trezor/ui/popup.py @@ -16,6 +16,6 @@ class Popup(ui.Layout): def create_tasks(self) -> tuple[loop.Task, ...]: return self.handle_input(), self.handle_rendering(), self.handle_timeout() - def handle_timeout(self) -> loop.Task: # type: ignore + def handle_timeout(self) -> loop.Task: # type: ignore [awaitable-is-generator] yield loop.sleep(self.time_ms) raise ui.Result(None) diff --git a/core/src/trezor/utils.py b/core/src/trezor/utils.py index acec2a636..0650e2f66 100644 --- a/core/src/trezor/utils.py +++ b/core/src/trezor/utils.py @@ -153,7 +153,7 @@ if TYPE_CHECKING: class HashContextInitable(HashContext, Protocol): def __init__( # pylint: disable=super-init-not-called - self, __data: bytes = None + self, __data: bytes | None = None ) -> None: ... diff --git a/core/src/trezor/wire/codec_v1.py b/core/src/trezor/wire/codec_v1.py index 1e7352ac7..459ebb7ee 100644 --- a/core/src/trezor/wire/codec_v1.py +++ b/core/src/trezor/wire/codec_v1.py @@ -24,7 +24,7 @@ INVALID_TYPE = const(-1) # use it at the same time, thus we check this at runtime in debug builds. if __debug__: - class BufferLock: # type: ignore + class BufferLock: # type: ignore [Class declaration "BufferLock" is obscured by a declaration of the same name] def __init__(self) -> None: self.in_use = False diff --git a/python/Makefile b/python/Makefile index 62e1585cd..ae84add05 100644 --- a/python/Makefile +++ b/python/Makefile @@ -49,13 +49,13 @@ style: isort --apply --recursive $(STYLE_TARGETS) --skip-glob "$(EXCLUDE_TARGETS)/*" autoflake -i --remove-all-unused-imports -r $(STYLE_TARGETS) --exclude "$(EXCLUDE_TARGETS)" flake8 - pyright + make pyright style_check: black --check $(STYLE_TARGETS) isort --check-only --recursive $(STYLE_TARGETS) --skip-glob "$(EXCLUDE_TARGETS)/*" flake8 - pyright + make pyright style_quick_check: black --check $(STYLE_TARGETS) @@ -67,4 +67,4 @@ test: pytest tests pyright: - pyright -p pyrightconfig.json + python ./../tools/pyright_tool.py --dir python diff --git a/python/pyrightconfig.json b/python/pyrightconfig.json index 4a6906666..3d732721e 100644 --- a/python/pyrightconfig.json +++ b/python/pyrightconfig.json @@ -6,6 +6,7 @@ ], "pythonVersion": "3.6", "typeCheckingMode": "basic", + "enableTypeIgnoreComments": false, "reportMissingImports": false, "reportUntypedFunctionDecorator": true, "reportUntypedClassDecorator": true, diff --git a/python/src/trezorlib/cli/__init__.py b/python/src/trezorlib/cli/__init__.py index 5f41a8673..875cd8097 100644 --- a/python/src/trezorlib/cli/__init__.py +++ b/python/src/trezorlib/cli/__init__.py @@ -81,7 +81,7 @@ class TrezorConnection: # It is alright to return just the class object instead of instance, # as the ScriptUI class object itself is the implementation of TrezorClientUI # (ScriptUI is just a set of staticmethods) - return ScriptUI # type: ignore [Expression of type "Type[ScriptUI]" cannot be assigned to return type "TrezorClientUI"] + return ScriptUI else: return ClickUI(passphrase_on_host=self.passphrase_on_host) @@ -149,4 +149,4 @@ def with_client(func: "Callable[Concatenate[TrezorClient, P], R]") -> "Callable[ # the return type of @click.pass_obj is improperly specified and pyright doesn't # understand that it converts f(obj, *args, **kwargs) to f(*args, **kwargs) - return trezorctl_command_with_client # type: ignore + return trezorctl_command_with_client # type: ignore [cannot be assigned to return type] diff --git a/python/src/trezorlib/cli/btc.py b/python/src/trezorlib/cli/btc.py index 4166b6e2e..a099d6e53 100644 --- a/python/src/trezorlib/cli/btc.py +++ b/python/src/trezorlib/cli/btc.py @@ -78,7 +78,7 @@ def xpub_deserialize(xpubstr: str) -> Tuple[str, messages.HDNodeType]: fingerprint=data.fingerprint, child_num=data.child_num, chain_code=data.chain_code, - public_key=public_key, # type: ignore ["Unknown | None" cannot be assigned to parameter "public_key"] + public_key=public_key, # type: ignore [Argument of type "Unknown | None" cannot be assigned to parameter "public_key" of type "bytes"] private_key=private_key, ) diff --git a/python/src/trezorlib/cli/ethereum.py b/python/src/trezorlib/cli/ethereum.py index a435c553c..665379148 100644 --- a/python/src/trezorlib/cli/ethereum.py +++ b/python/src/trezorlib/cli/ethereum.py @@ -139,7 +139,7 @@ def _erc20_contract(token_address: str, to_address: str, amount: int) -> str: "outputs": [{"name": "", "type": "bool"}], } ] - contract = _get_web3().eth.contract(address=token_address, abi=min_abi) # type: ignore ["str" cannot be assigned to type "Address | ChecksumAddress | ENS"] + contract = _get_web3().eth.contract(address=token_address, abi=min_abi) return contract.encodeABI("transfer", [to_address, amount]) diff --git a/python/src/trezorlib/cli/trezorctl.py b/python/src/trezorlib/cli/trezorctl.py index 0394f1e6a..b4d43f242 100755 --- a/python/src/trezorlib/cli/trezorctl.py +++ b/python/src/trezorlib/cli/trezorctl.py @@ -125,7 +125,7 @@ class TrezorctlGroup(click.Group): command, subcommand = cmd_name.split("-", maxsplit=1) # get_command can return None and the following line will fail. # We don't care, we ignore the exception anyway. - return super().get_command(ctx, command).get_command(ctx, subcommand) # type: ignore ["get_command" is not a known member of "None"] + return super().get_command(ctx, command).get_command(ctx, subcommand) # type: ignore ["get_command" is not a known member of "None";;Cannot access member "get_command" for type "Command"] except Exception: pass diff --git a/python/src/trezorlib/debuglink.py b/python/src/trezorlib/debuglink.py index d54b08ba5..f9fd54cf8 100644 --- a/python/src/trezorlib/debuglink.py +++ b/python/src/trezorlib/debuglink.py @@ -280,7 +280,7 @@ class DebugLink: class NullDebugLink(DebugLink): def __init__(self) -> None: # Ignoring type error as self.transport will not be touched while using NullDebugLink - super().__init__(None) # type: ignore ["None" cannot be assigned to parameter of type "Transport"] + super().__init__(None) # type: ignore [Argument of type "None" cannot be assigned to parameter "transport"] def open(self) -> None: pass diff --git a/python/src/trezorlib/protobuf.py b/python/src/trezorlib/protobuf.py index 2ef2ee5e3..6eca24e12 100644 --- a/python/src/trezorlib/protobuf.py +++ b/python/src/trezorlib/protobuf.py @@ -181,9 +181,9 @@ class Field: class _MessageTypeMeta(type): def __init__(cls, name: str, bases: tuple, d: dict) -> None: - super().__init__(name, bases, d) # type: ignore [Expected 1 positional] + super().__init__(name, bases, d) # type: ignore [Expected 1 positional argument] if name != "MessageType": - cls.__init__ = MessageType.__init__ # type: ignore [Cannot assign member "__init__" for type "_MessageTypeMeta"] + cls.__init__ = MessageType.__init__ # type: ignore ["__init__" is obscured by a declaration of the same name;;Cannot assign member "__init__" for type "_MessageTypeMeta"] class MessageType(metaclass=_MessageTypeMeta): diff --git a/python/src/trezorlib/qt/pinmatrix.py b/python/src/trezorlib/qt/pinmatrix.py index 98571f8c6..389e376d6 100644 --- a/python/src/trezorlib/qt/pinmatrix.py +++ b/python/src/trezorlib/qt/pinmatrix.py @@ -158,7 +158,7 @@ if __name__ == "__main__": if QT_VERSION_STR >= "5": ok.clicked.connect(clicked) elif QT_VERSION_STR >= "4": - QObject.connect(ok, SIGNAL("clicked()"), clicked) # type: ignore [SIGNAL is not unbound] + QObject.connect(ok, SIGNAL("clicked()"), clicked) # type: ignore ["QObject" is possibly unbound;;"SIGNAL" is possibly unbound] else: raise RuntimeError("Unsupported Qt version") diff --git a/tools/pyright_tool.py b/tools/pyright_tool.py new file mode 100755 index 000000000..177439528 --- /dev/null +++ b/tools/pyright_tool.py @@ -0,0 +1,653 @@ +#!/usr/bin/env python3 +""" +Wrapper around pyright type checking to allow for easy ignore of specific error messages. +Thanks to it the `# type: ignore` does not affect the whole line, +so other problems at the same line cannot be masked by it. + +Features: +- ignores specific pyright errors based on substring or regex +- reports empty `# type: ignore`s (without ignore reason in `[]`) +- reports unused `# type: ignore`s (for example after pyright is updated) +- allows for ignoring some errors in the whole file - see `FILE_SPECIFIC_IGNORES` variable +- allows for error aliases - see `ALIASES` variable + +Usage: +- there are multiple options how to ignore/silence a pyright error: + 1 - "# type: ignore []" + - put it as a comment to the line we want to ignore + - "# type: ignore [;;;;...]" if there are more than one errors on that line + - also regex patterns are valid substrings + 2 - "# pyright: off" / "# pyright: on" + - all errors in block of code between these marks will be ignored + 3 - FILE_SPECIFIC_IGNORES + - ignore specific rules (defined by pyright) or error substrings in the whole file + 4 - ALIASES + - create an alias for a common error and use is with option 1 - "# type: ignore []" + +Running the script: +- see all script argument by calling `python pyright_tool.py --help` +- only directories with existing `pyrightconfig.json` can be tested - see `--dir` flag + +Simplified program flow (as it happens in PyrightTool.run()): +- extract and validate pyright config data from pyrightconfig.json +- collect all the pyright errors by actually running the pyright itself +- extract type-ignore information for all the files pyright was analyzing +- loop through all the pyright errors and try to match them against all the type-ignore rules +- if there are some unmatched errors, report them and exit with nonzero value +- also report unused ignores and other inconsistencies +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Final, TypedDict + + +class RangeDetail(TypedDict): + line: int + character: int + + +class Range(TypedDict): + start: RangeDetail + end: RangeDetail + + +class Error(TypedDict): + file: str + severity: str + message: str + range: Range + rule: str + + +Errors = list[Error] + + +class Summary(TypedDict): + filesAnalyzed: int + errorCount: int + warningCount: int + informationCount: int + timeInSec: float + + +class PyrightResults(TypedDict): + version: str + time: str + generalDiagnostics: Errors + summary: Summary + + +@dataclass +class IgnoreStatement: + substring: str + already_used: bool = False + + +@dataclass +class LineIgnore: + line_no: int + ignore_statements: list[IgnoreStatement] + + +LineIgnores = list[LineIgnore] +FileIgnores = dict[str, LineIgnores] + + +@dataclass +class FileSpecificIgnore: + rule: str = "" + substring: str = "" + already_used: bool = False + + def __post_init__(self) -> None: + if self.rule and self.substring: + raise ValueError("Only one of rule|substring should be set") + + +FileSpecificIgnores = dict[str, list[FileSpecificIgnore]] + + +@dataclass +class PyrightOffIgnore: + start_line: int + end_line: int + already_used: bool = False + + +PyrightOffIgnores = list[PyrightOffIgnore] +FilePyrightOffIgnores = dict[str, PyrightOffIgnores] + +parser = argparse.ArgumentParser() +parser.add_argument( + "--dev", action="store_true", help="Creating the error file and not deleting it" +) +parser.add_argument( + "--test", + action="store_true", + help="Reusing existing error file and not deleting it", +) +parser.add_argument("--log", action="store_true", help="Log details") +parser.add_argument( + "--dir", + help="Directory which to test, relative to the repository root. When empty, taking the directory of this file.", + default="", +) +args = parser.parse_args() + +if args.dev: + should_generate_error_file = True + should_delete_error_file = False + print("Running in dev mode, creating the file and not deleting it") +elif args.test: + should_generate_error_file = False + should_delete_error_file = False + print("Running in test mode, will reuse existing error file") +else: + should_generate_error_file = True + should_delete_error_file = True + +SHOULD_GENERATE_ERROR_FILE = should_generate_error_file +SHOULD_DELETE_ERROR_FILE = should_delete_error_file +SHOULD_LOG = args.log + +if args.dir: + # Need to change the os directory to find all the files correctly + # Repository root + the wanted directory. + HERE = Path(__file__).resolve().parent.parent / args.dir + if not HERE.is_dir(): + raise RuntimeError(f"Could not find directory {args.dir} under {HERE}") + os.chdir(HERE) +else: + # Directory of this file + HERE = Path(__file__).resolve().parent + +# TODO: move into a JSON or other config file +# Files need to have a relative location to the directory being tested +# Example (when checking `python` directory): +# "tools/helloworld.py": [ +# FileSpecificIgnore(rule="reportMissingParameterType"), +# FileSpecificIgnore(substring="cannot be assigned to parameter"), +# ], +FILE_SPECIFIC_IGNORES: FileSpecificIgnores = {} + + +# Allowing for more readable ignore of common problems, with an easy-to-understand alias +ALIASES: dict[str, str] = { + "awaitable-is-generator": 'Return type of generator function must be "Generator" or "Iterable"', + "obscured-by-same-name": "is obscured by a declaration of the same name", + "int-into-enum": 'Expression of type "int.*" cannot be assigned to return type ".*"', +} + + +class PyrightTool: + ON_PATTERN: Final = "# pyright: on" + OFF_PATTERN: Final = "# pyright: off" + IGNORE_PATTERN: Final = "# type: ignore" + IGNORE_DELIMITER: Final = ";;" + + original_pyright_results: PyrightResults + all_files_to_check: set[str] + all_pyright_ignores: FileIgnores + pyright_off_ignores: FilePyrightOffIgnores + real_errors: Errors + unused_ignores: list[str] + inconsistencies: list[str] = [] + + def __init__( + self, + pyright_config_file: str | Path, + *, + file_specific_ignores: FileSpecificIgnores | None = None, + aliases: dict[str, str] | None = None, + error_file: str | Path = "temp_error_file.json", + should_generate_error_file: bool = True, + should_delete_error_file: bool = True, + should_log: bool = False, + ) -> None: + self.pyright_config_file = pyright_config_file + self.file_specific_ignores = file_specific_ignores or {} + self.aliases = aliases or {} + self.error_file = error_file + self.should_generate_error_file = should_generate_error_file + self.should_delete_error_file = should_delete_error_file + self.should_log = should_log + + self.count_of_ignored_errors = 0 + + self.check_input_correctness() + + def check_input_correctness(self) -> None: + """Verify the input data structures are correct.""" + # Checking for correct file_specific_ignores structure + for file, ignores in self.file_specific_ignores.items(): + for ignore in ignores: + if not isinstance(ignore, FileSpecificIgnore): + raise RuntimeError( + "All items of file_specific_ignores must be FileSpecificIgnore classes. " + f"Got {ignore} - type {type(ignore)}" + ) + # Also putting substrings at the beginning of ignore-lists, so they are matched before rules + # (Not to leave them potentially unused when error would be matched by a rule instead) + self.file_specific_ignores[file].sort( + key=lambda x: x.substring, reverse=True + ) + + # Checking for correct aliases (dict[str, str] type) + for alias, full_substring in self.aliases.items(): + if not isinstance(alias, str) or not isinstance(full_substring, str): + raise RuntimeError( + "All alias keys and values must be strings. " + f"Got {alias} (type {type(alias)}), {full_substring} (type {type(full_substring)}" + ) + + def run(self) -> None: + """Main function, putting together all logic and evaluating result.""" + self.pyright_config_data = self.get_and_validate_pyright_config_data() + + self.original_pyright_results = self.get_original_pyright_results() + + self.all_files_to_check = self.get_all_files_to_check() + self.all_pyright_ignores = self.get_all_pyright_ignores() + self.pyright_off_ignores = self.get_pyright_off_ignores() + + self.real_errors = self.get_all_real_errors() + self.unused_ignores = self.get_unused_ignores() + + self.evaluate_final_result() + + def evaluate_final_result(self) -> None: + """Reporting results to the user/CI (printing stuff, deciding exit value).""" + print( + f"\nIgnored {self.count_of_ignored_errors} custom-defined errors " + f"from {len(self.all_pyright_ignores)} files." + ) + + if self.unused_ignores: + print("\nWARNING: there are unused ignores!") + for unused_ignore in self.unused_ignores: + print(unused_ignore) + + if self.inconsistencies: + print("\nWARNING: there are inconsistencies!") + for inconsistency in self.inconsistencies: + print(inconsistency) + + if not self.real_errors: + print("\nSUCCESS: Everything is fine!") + if self.unused_ignores or self.inconsistencies: + print("But we have unused ignores or inconsistencies!") + sys.exit(2) + else: + sys.exit(0) + else: + print("\nERROR: We have issues!\n") + for error in self.real_errors: + print(self.get_human_readable_error_string(error)) + print(f"Found {len(self.real_errors)} issues above") + if self.unused_ignores or self.inconsistencies: + print("And we have unused ignores or inconsistencies!") + sys.exit(1) + + def get_and_validate_pyright_config_data(self) -> dict[str, Any]: + """Verify that pyrightconfig exists and has correct data.""" + if not os.path.isfile(self.pyright_config_file): + raise RuntimeError( + f"Pyright config file under {self.pyright_config_file} does not exist! " + "Tool relies on its existence, please create it." + ) + + try: + config_data = json.loads(open(self.pyright_config_file, "r").read()) + except json.decoder.JSONDecodeError as err: + raise RuntimeError( + f"Pyright config under {self.pyright_config_file} does not contain valid JSON! Err: {err}" + ) from None + + # enableTypeIgnoreComments MUST be set to False, otherwise the "type: ignore"s + # will affect the original pyright result - and we need it to grab all the errors + # so we can handle them on our own + if ( + "enableTypeIgnoreComments" not in config_data + or config_data["enableTypeIgnoreComments"] + ): + raise RuntimeError( + f"Please set '\"enableTypeIgnoreComments\": true' in {self.pyright_config_file}. " + "Otherwise the tool will not work as expected." + ) + + return config_data + + def get_original_pyright_results(self) -> PyrightResults: + """Extract all information from pyright. + + `pyright --outputjson` will return all the results in + nice JSON format with `generalDiagnostics` array storing + all the errors - schema described in PyrightResults + """ + if self.should_generate_error_file: + cmd = f"pyright -p {self.pyright_config_file} --outputjson > {self.error_file}" + exit_code = subprocess.call(cmd, shell=True) + # Checking if there was no non-type-checking error when running the above command + # Exit code 0 = all fine, no type-checking issues in pyright + # Exit code 1 = pyright has found some type-checking issues (expected) + # All other exit codes mean something non-type-related got wrong (or pyright was not found) + # https://github.com/microsoft/pyright/blob/main/docs/command-line.md#pyright-exit-codes + if exit_code not in (0, 1): + raise RuntimeError( + f"Running '{cmd}' produced a non-expected exit code (see output above)." + ) + + if not os.path.isfile(self.error_file): + raise RuntimeError( + f"Pyright error file under {self.error_file} was not generated by running '{cmd}'." + ) + + try: + pyright_results: PyrightResults = json.loads( + open(self.error_file, "r").read() + ) + except FileNotFoundError: + raise RuntimeError( + f"Error file under {self.error_file} does not exist!" + ) from None + except json.decoder.JSONDecodeError as err: + raise RuntimeError( + f"Error file under {self.error_file} does not contain valid JSON! Err: {err}" + ) from None + + if self.should_delete_error_file: + os.remove(self.error_file) + + return pyright_results + + def get_all_real_errors(self) -> Errors: + """Analyze all pyright errors and discard all that should be ignored. + + Ignores can be different: + - as per "# type: ignore []" comment + - as per "file_specific_ignores" + - as per "# pyright: off" mark + """ + real_errors: Errors = [] + for error in self.original_pyright_results["generalDiagnostics"]: + # Special handling of cycle import issues, which have different format + if "range" not in error: + error["range"] = {"start": {"line": 0}} + error["rule"] = "cycleImport" + real_errors.append(error) + continue + + file_path = error["file"] + error_message = error["message"] + line_no = error["range"]["start"]["line"] + + # Checking for "# type: ignore []" comment + if self.should_ignore_per_inline_substring( + file_path, error_message, line_no + ): + self.count_of_ignored_errors += 1 + self.log_ignore(error, "error substring matched") + continue + + # Checking in file_specific_ignores + if self.should_ignore_file_specific_error(file_path, error): + self.count_of_ignored_errors += 1 + self.log_ignore(error, "file specific error") + continue + + # Checking for "# pyright: off" mark + if self.is_line_in_pyright_off_block(file_path, line_no): + self.count_of_ignored_errors += 1 + self.log_ignore(error, "pyright disabled for this line") + continue + + real_errors.append(error) + + return real_errors + + def get_all_files_to_check(self) -> set[str]: + """Get all files to be analyzed by pyright, based on its config.""" + all_files: set[str] = set() + + if "include" in self.pyright_config_data: + for dir_or_file in self.pyright_config_data["include"]: + for file in self.get_all_py_files_recursively(dir_or_file): + all_files.add(file) + else: + # "include" is missing, we should analyze all files in current dir + for file in self.get_all_py_files_recursively("."): + all_files.add(file) + + if "exclude" in self.pyright_config_data: + for dir_or_file in self.pyright_config_data["exclude"]: + for file in self.get_all_py_files_recursively(dir_or_file): + if file in all_files: + all_files.remove(file) + + return all_files + + @staticmethod + def get_all_py_files_recursively(dir_or_file: str) -> set[str]: + """Return all python files in certain directory (or the file itself).""" + if os.path.isfile(dir_or_file): + return set(str(HERE / dir_or_file)) + + all_files: set[str] = set() + for root, _, files in os.walk(dir_or_file): + for file in files: + if file.endswith(".py"): + all_files.add(str(HERE / os.path.join(root, file))) + + return all_files + + def get_all_pyright_ignores(self) -> FileIgnores: + """Get ignore information from all the files to be analyzed.""" + file_ignores: FileIgnores = {} + for file in self.all_files_to_check: + ignores = self.get_inline_type_ignores_from_file(file) + if ignores: + file_ignores[file] = ignores + + return file_ignores + + def get_pyright_off_ignores(self) -> FilePyrightOffIgnores: + """Get ignore information based on `# pyright: on/off` marks.""" + pyright_off_ignores: FilePyrightOffIgnores = {} + for file in self.all_files_to_check: + ignores = self.find_pyright_off_from_file(file) + if ignores: + pyright_off_ignores[file] = ignores + + return pyright_off_ignores + + def get_unused_ignores(self) -> list[str]: + """Evaluate if there are no ignores not matched by pyright errors.""" + unused_ignores: list[str] = [] + + # type: ignore + for file, file_ignores in self.all_pyright_ignores.items(): + for line_ignore in file_ignores: + for ignore_statement in line_ignore.ignore_statements: + if not ignore_statement.already_used: + unused_ignores.append( + f"File {file}:{line_ignore.line_no + 1} has unused ignore. " + f"Substring: {ignore_statement.substring}" + ) + + # Pyright: off + for file, file_ignores in self.pyright_off_ignores.items(): + for off_ignore in file_ignores: + if not off_ignore.already_used: + unused_ignores.append( + f"File {file} has unused # pyright: off ignore between lines " + f"{off_ignore.start_line + 1} and {off_ignore.end_line + 1}." + ) + + # File-specific + for file, file_ignores in self.file_specific_ignores.items(): + for ignore_object in file_ignores: + if not ignore_object.already_used: + if ignore_object.substring: + unused_ignores.append( + f"File {file} has unused specific ignore substring. " + f"Substring: {ignore_object.substring}" + ) + elif ignore_object.rule: + unused_ignores.append( + f"File {file} has unused specific ignore rule. " + f"Rule: {ignore_object.rule}" + ) + + return unused_ignores + + def should_ignore_per_inline_substring( + self, file: str, error_message: str, line_no: int + ) -> bool: + """Check if line should be ignored based on inline substring/regex.""" + if file not in self.all_pyright_ignores: + return False + + for ignore_index, ignore in enumerate(self.all_pyright_ignores[file]): + if line_no == ignore.line_no: + for substring_index, ignore_statement in enumerate( + ignore.ignore_statements + ): + # Supporting both text substrings and regex patterns + if ignore_statement.substring in error_message or re.search( + ignore_statement.substring, error_message + ): + # Marking this ignore to be used (so we can identify unused ignores) + self.all_pyright_ignores[file][ignore_index].ignore_statements[ + substring_index + ].already_used = True + return True + + return False + + def should_ignore_file_specific_error(self, file: str, error: Error) -> bool: + """Check if line should be ignored based on file-specific ignores.""" + if file not in self.file_specific_ignores: + return False + + for ignore_object in self.file_specific_ignores[file]: + if ignore_object.rule: + if error["rule"] == ignore_object.rule: + ignore_object.already_used = True + return True + elif ignore_object.substring: + # Supporting both text substrings and regex patterns + if ignore_object.substring in error["message"] or re.search( + ignore_object.substring, error["message"] + ): + ignore_object.already_used = True + return True + + return False + + def is_line_in_pyright_off_block(self, file: str, line_no: int) -> bool: + """Check if line should be ignored based on `# pyright: off` mark.""" + if file not in self.pyright_off_ignores: + return False + + for off_ignore in self.pyright_off_ignores[file]: + if off_ignore.start_line < line_no < off_ignore.end_line: + off_ignore.already_used = True + return True + + return False + + def find_pyright_off_from_file(self, file: str) -> PyrightOffIgnores: + """Get sections in file to be ignored based on `# pyright: off`.""" + pyright_off_ignores: PyrightOffIgnores = [] + with open(file, "r") as f: + pyright_off = False + start_line = 0 + index = 0 + for index, line in enumerate(f): + if self.OFF_PATTERN in line and not pyright_off: + start_line = index + pyright_off = True + elif self.ON_PATTERN in line and pyright_off: + pyright_off_ignores.append(PyrightOffIgnore(start_line, index)) + pyright_off = False + + if pyright_off: + pyright_off_ignores.append(PyrightOffIgnore(start_line, index)) + + return pyright_off_ignores + + def get_inline_type_ignores_from_file(self, file: str) -> LineIgnores: + """Get all type ignore lines and statements from a certain file.""" + ignores: LineIgnores = [] + with open(file, "r") as f: + for index, line in enumerate(f): + if self.IGNORE_PATTERN in line: + ignore_statements = self.get_ignore_statements(line) + if not ignore_statements: + self.inconsistencies.append( + f"There is an empty `{self.IGNORE_PATTERN}` in {file}:{index+1}" + ) + else: + ignores.append(LineIgnore(index, ignore_statements)) + + return ignores + + def get_ignore_statements(self, line: str) -> list[IgnoreStatement]: + """Extract error substrings to be ignored from a certain line.""" + # Extracting content of [error_substring(s)] after the ignore comment + ignore_part = line.split(self.IGNORE_PATTERN, maxsplit=2)[1] + ignore_content = re.search(r"\[(.*)\]", ignore_part) + + # We should not be using empty `# type: ignore` without content in [] + # Notifying the parent function that we should do something about it + if not ignore_content: + return [] + + # There might be more than one substring + statement_substrings = ignore_content.group(1).split(self.IGNORE_DELIMITER) + + # When finding aliases, replacing them with a real substring + statement_substrings = [self.aliases.get(ss, ss) for ss in statement_substrings] + + return [IgnoreStatement(substr) for substr in statement_substrings] + + def log_ignore(self, error: Error, reason: str) -> None: + """Print the action of ignoring certain error into the console.""" + if self.should_log: + err = self.get_human_readable_error_string(error) + print(f"\nError ignored. Reason: {reason}.\nErr: {err}") + + @staticmethod + def get_human_readable_error_string(error: Error) -> str: + """Transform error object to a string readable by human.""" + file = error["file"] + message = error["message"] + rule = error["rule"] + line = error["range"]["start"]["line"] + + # Need to add +1 to the line, as it is zero-based index + return f"{file}:{line + 1}: - error: {message} ({rule})\n" + + +if __name__ == "__main__": + tool = PyrightTool( + pyright_config_file=HERE / "pyrightconfig.json", + file_specific_ignores={ + str(HERE / k): v for k, v in FILE_SPECIFIC_IGNORES.items() + }, + aliases=ALIASES, + error_file="errors_for_pyright_temp.json", + should_generate_error_file=SHOULD_GENERATE_ERROR_FILE, + should_delete_error_file=SHOULD_DELETE_ERROR_FILE, + should_log=SHOULD_LOG, + ) + tool.run()