diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d2bead83..10098503e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ On Linux or macOS: ``` .../debugpy$ python3 -m tox ``` -This will perform a full run with the default settings. A full run will run tests on Python 2.7 and 3.5-3.8, and requires all of those to be installed. If some versions are missing, or it is desired to skip them for a particular run, tox can be directed to only run tests on specific versions with `-e`. In addition, the `--developer` option can be used to skip the packaging step, running tests directly against the source code in `src/debugpy`. This should only be used when iterating on the code, and a proper run should be performed before submitting a PR. On Windows: +This will perform a full run with the default settings. A full run will run tests on Python 2.7 and 3.5-3.8, and requires all of those to be installed. If some versions are missing, or it is desired to skip them for a particular run, tox can be directed to only run tests on specific versions with `-e`. In addition, the `--develop` option can be used to skip the packaging step, running tests directly against the source code in `src/debugpy`. This should only be used when iterating on the code, and a proper run should be performed before submitting a PR. On Windows: ``` ...\debugpy> py -m tox -e py27,py37 --develop ``` @@ -76,7 +76,7 @@ On Linux or macOS: ### Running tests without tox -While tox is the recommended way to run the test suite, pytest can also be invoked directly from the root of the repository. This requires packages in tests/test_requirements.txt to be installed first. +While tox is the recommended way to run the test suite, pytest can also be invoked directly from the root (src/debugpy) of the repository. This requires packages in tests/requirements.txt to be installed first. ## Using modified debugpy in Visual Studio Code To test integration between debugpy and Visual Studio Code, the latter can be directed to use a custom version of debugpy in lieu of the one bundled with the Python extension. This is done by specifying `"debugAdapterPath"` in `launch.json` - it must point at the root directory of the *package*, which is `src/debugpy` inside the repository: diff --git a/pyproject.toml b/pyproject.toml index a6906c0d0..b2011ad76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ ignore = ["src/debugpy/_vendored/pydevd", "src/debugpy/_version.py"] executionEnvironments = [ { root = "src" }, { root = "." } ] +typeCheckingMode = "standard" +enableTypeIgnoreComments = false [tool.ruff] # Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default. diff --git a/src/debugpy/adapter/__main__.py b/src/debugpy/adapter/__main__.py index e18ecd560..06d601fcd 100644 --- a/src/debugpy/adapter/__main__.py +++ b/src/debugpy/adapter/__main__.py @@ -8,6 +8,7 @@ import locale import os import sys +from typing import Any # WARNING: debugpy and submodules must not be imported on top level in this module, # and should be imported locally inside main() instead. @@ -53,7 +54,7 @@ def main(args): if args.for_server is None: adapter.access_token = codecs.encode(os.urandom(32), "hex").decode("ascii") - endpoints = {} + endpoints: dict[str, Any] = {} try: client_host, client_port = clients.serve(args.host, args.port) except Exception as exc: diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index ee1d15145..69a3fe77f 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -7,13 +7,13 @@ import atexit import os import sys +from typing import Any, Callable, Union, cast import debugpy from debugpy import adapter, common, launcher from debugpy.common import json, log, messaging, sockets from debugpy.adapter import clients, components, launchers, servers, sessions - class Client(components.Component): """Handles the client side of a debug session.""" @@ -67,7 +67,7 @@ def __init__(self, sock): fully handled. """ - self.start_request = None + self.start_request: Union[messaging.Request, None] = None """The "launch" or "attach" request as received from the client. """ @@ -124,11 +124,12 @@ def propagate_after_start(self, event): self.client.channel.propagate(event) def _propagate_deferred_events(self): - log.debug("Propagating deferred events to {0}...", self.client) - for event in self._deferred_events: - log.debug("Propagating deferred {0}", event.describe()) - self.client.channel.propagate(event) - log.info("All deferred events propagated to {0}.", self.client) + if self._deferred_events is not None: + log.debug("Propagating deferred events to {0}...", self.client) + for event in self._deferred_events: + log.debug("Propagating deferred {0}", event.describe()) + self.client.channel.propagate(event) + log.info("All deferred events propagated to {0}.", self.client) self._deferred_events = None # Generic event handler. There are no specific handlers for client events, because @@ -202,9 +203,9 @@ def initialize_request(self, request): # # See https://github.com/microsoft/vscode/issues/4902#issuecomment-368583522 # for the sequence of request and events necessary to orchestrate the start. - def _start_message_handler(f): + def _start_message_handler(f: Callable[..., Any])-> Callable[..., object | None]: # pyright: ignore[reportGeneralTypeIssues, reportSelfClsParameterName] @components.Component.message_handler - def handle(self, request): + def handle(self, request: messaging.Message): assert request.is_request("launch", "attach") if self._initialize_request is None: raise request.isnt_valid("Session is not initialized yet") @@ -215,15 +216,16 @@ def handle(self, request): if self.session.no_debug: servers.dont_wait_for_first_connection() + request_options: list[Any] = cast("list[Any]", request("debugOptions", json.array(str))) self.session.debug_options = debug_options = set( - request("debugOptions", json.array(str)) + request_options ) f(self, request) - if request.response is not None: + if isinstance(request, messaging.Request) and request.response is not None: return - if self.server: + if self.server and isinstance(request, messaging.Request): self.server.initialize(self._initialize_request) self._initialize_request = None @@ -267,7 +269,7 @@ def handle(self, request): except messaging.MessageHandlingError as exc: exc.propagate(request) - if self.session.no_debug: + if self.session.no_debug and isinstance(request, messaging.Request): self.start_request = request self.has_started = True request.respond({}) @@ -335,6 +337,7 @@ def property_or_debug_option(prop_name, flag_name): launcher_python = python[0] program = module = code = () + args = [] if "program" in request: program = request("program", str) args = [program] @@ -391,7 +394,7 @@ def property_or_debug_option(prop_name, flag_name): if cwd == (): # If it's not specified, but we're launching a file rather than a module, # and the specified path has a directory in it, use that. - cwd = None if program == () else (os.path.dirname(program) or None) + cwd = None if program == () else (os.path.dirname(str(program)) or None) sudo = bool(property_or_debug_option("sudo", "Sudo")) if sudo and sys.platform == "win32": @@ -484,7 +487,7 @@ def attach_request(self, request): else: if not servers.is_serving(): servers.serve() - host, port = servers.listener.getsockname() + host, port = servers.listener.getsockname() if servers.listener is not None else ("", 0) # There are four distinct possibilities here. # @@ -576,9 +579,9 @@ def on_output(category, output): request.cant_handle("{0} is already being debugged.", conn) @message_handler - def configurationDone_request(self, request): + def configurationDone_request(self, request: messaging.Request): if self.start_request is None or self.has_started: - request.cant_handle( + raise request.cant_handle( '"configurationDone" is only allowed during handling of a "launch" ' 'or an "attach" request' ) @@ -623,7 +626,8 @@ def evaluate_request(self, request): def handle_response(response): request.respond(response.body) - propagated_request.on_response(handle_response) + if propagated_request is not None: + propagated_request.on_response(handle_response) return messaging.NO_RESPONSE @@ -649,7 +653,7 @@ def debugpySystemInfo_request(self, request): result = {"debugpy": {"version": debugpy.__version__}} if self.server: try: - pydevd_info = self.server.channel.request("pydevdSystemInfo") + pydevd_info: messaging.MessageDict = self.server.channel.request("pydevdSystemInfo") except Exception: # If the server has already disconnected, or couldn't handle it, # report what we've got. @@ -754,7 +758,7 @@ def notify_of_subprocess(self, conn): if "host" not in body["connect"]: body["connect"]["host"] = host if host is not None else "127.0.0.1" if "port" not in body["connect"]: - if port is None: + if port is None and listener is not None: _, port = listener.getsockname() body["connect"]["port"] = port diff --git a/src/debugpy/adapter/components.py b/src/debugpy/adapter/components.py index 1a6534071..73e06c207 100644 --- a/src/debugpy/adapter/components.py +++ b/src/debugpy/adapter/components.py @@ -3,7 +3,12 @@ # for license information. import functools +from typing import TYPE_CHECKING, Type, TypeVar, Union, cast +if TYPE_CHECKING: + # Dont import this during runtime. There's an order + # of imports issue that causes the debugger to hang. + from debugpy.adapter.sessions import Session from debugpy.common import json, log, messaging, util @@ -31,7 +36,7 @@ class Component(util.Observable): to wait_for() a change caused by another component. """ - def __init__(self, session, stream=None, channel=None): + def __init__(self, session: "Session", stream: "Union[messaging.JsonIOStream, None]"=None, channel: "Union[messaging.JsonMessageChannel, None]"=None): assert (stream is None) ^ (channel is None) try: @@ -44,13 +49,14 @@ def __init__(self, session, stream=None, channel=None): self.session = session - if channel is None: + if channel is None and stream is not None: stream.name = str(self) channel = messaging.JsonMessageChannel(stream, self) channel.start() - else: + elif channel is not None: channel.name = channel.stream.name = str(self) channel.handlers = self + assert channel is not None self.channel = channel self.is_connected = True @@ -108,8 +114,9 @@ def disconnect(self): self.is_connected = False self.session.finalize("{0} has disconnected".format(self)) +T = TypeVar('T') -def missing(session, type): +def missing(session, type: Type[T]) -> T: class Missing(object): """A dummy component that raises ComponentNotAvailable whenever some attribute is accessed on it. @@ -124,7 +131,7 @@ def report(): except Exception as exc: log.reraise_exception("{0} in {1}", exc, session) - return Missing() + return cast(type, Missing()) class Capabilities(dict): diff --git a/src/debugpy/adapter/launchers.py b/src/debugpy/adapter/launchers.py index 38a990d76..1658e1a8d 100644 --- a/src/debugpy/adapter/launchers.py +++ b/src/debugpy/adapter/launchers.py @@ -3,6 +3,7 @@ # for license information. import os +import socket import subprocess import sys @@ -18,7 +19,7 @@ class Launcher(components.Component): message_handler = components.Component.message_handler - def __init__(self, session, stream): + def __init__(self, session: sessions.Session, stream): with session: assert not session.launcher super().__init__(session, stream) @@ -88,12 +89,13 @@ def spawn_debuggee( env = {} arguments = dict(start_request.arguments) - if not session.no_debug: + if not session.no_debug and servers.listener is not None: _, arguments["port"] = servers.listener.getsockname() arguments["adapterAccessToken"] = adapter.access_token - def on_launcher_connected(sock): - listener.close() + def on_launcher_connected(sock: socket.socket): + if listener is not None: + listener.close() stream = messaging.JsonIOStream.from_socket(sock) Launcher(session, stream) diff --git a/src/debugpy/adapter/servers.py b/src/debugpy/adapter/servers.py index 025823616..b581d4e35 100644 --- a/src/debugpy/adapter/servers.py +++ b/src/debugpy/adapter/servers.py @@ -5,10 +5,12 @@ from __future__ import annotations import os +import socket import subprocess import sys import threading import time +from typing import Callable, Union, cast import debugpy from debugpy import adapter @@ -20,7 +22,7 @@ access_token = None """Access token used to authenticate with the servers.""" -listener = None +listener: Union[socket.socket, None] = None """Listener socket that accepts server connections.""" _lock = threading.RLock() @@ -60,7 +62,7 @@ class Connection(object): channel: messaging.JsonMessageChannel - def __init__(self, sock): + def __init__(self, sock: socket.socket): from debugpy.adapter import sessions self.disconnected = False @@ -78,9 +80,10 @@ def __init__(self, sock): try: self.authenticate() info = self.channel.request("pydevdSystemInfo") - process_info = info("process", json.object()) - self.pid = process_info("pid", int) - self.ppid = process_info("ppid", int, optional=True) + if not isinstance(info, Exception): + process_info: Callable[..., int] = cast(Callable[..., int], info("process", json.object())) + self.pid = process_info("pid", int) + self.ppid = process_info("ppid", int, optional=True) if self.ppid == (): self.ppid = None self.channel.name = stream.name = str(self) @@ -171,7 +174,7 @@ def authenticate(self): auth = self.channel.request( "pydevdAuthorize", {"debugServerAccessToken": access_token} ) - if auth["clientAccessToken"] != adapter.access_token: + if not isinstance(auth, Exception) and auth["clientAccessToken"] != adapter.access_token: self.channel.close() raise RuntimeError('Mismatched "clientAccessToken"; server not authorized.') @@ -250,7 +253,7 @@ class Capabilities(components.Capabilities): "supportedChecksumAlgorithms": [], } - def __init__(self, session, connection): + def __init__(self, session: sessions.Session, connection): assert connection.server is None with session: assert not session.server @@ -283,12 +286,13 @@ def initialize(self, request): assert request.is_request("initialize") self.connection.authenticate() request = self.channel.propagate(request) - request.wait_for_response() - self.capabilities = self.Capabilities(self, request.response) + if request is not None: + request.wait_for_response() + self.capabilities = self.Capabilities(self, request.response) # Generic request handler, used if there's no specific handler below. @message_handler - def request(self, request): + def request(self, request: messaging.Message): # Do not delegate requests from the server by default. There is a security # boundary between the server and the adapter, and we cannot trust arbitrary # requests sent over that boundary, since they may contain arbitrary code @@ -418,21 +422,21 @@ def connections(): return list(_connections) -def wait_for_connection(session, predicate, timeout=None): +def wait_for_connection(session, predicate, timeout: Union[float, None]=None): """Waits until there is a server matching the specified predicate connected to this adapter, and returns the corresponding Connection. If there is more than one server connection already available, returns the oldest one. """ - def wait_for_timeout(): - time.sleep(timeout) - wait_for_timeout.timed_out = True + if timeout is not None: + time.sleep(timeout) + wait_for_timeout.timed_out = True # pyright: ignore[reportFunctionMemberAccess] with _lock: _connections_changed.set() - wait_for_timeout.timed_out = timeout == 0 + wait_for_timeout.timed_out = timeout == 0 # pyright: ignore[reportFunctionMemberAccess] if timeout: thread = threading.Thread( target=wait_for_timeout, name="servers.wait_for_connection() timeout" @@ -447,7 +451,7 @@ def wait_for_timeout(): _connections_changed.clear() conns = (conn for conn in _connections if predicate(conn)) conn = next(conns, None) - if conn is not None or wait_for_timeout.timed_out: + if conn is not None or wait_for_timeout.timed_out: # pyright: ignore[reportFunctionMemberAccess] return conn _connections_changed.wait() @@ -475,7 +479,7 @@ def dont_wait_for_first_connection(): def inject(pid, debugpy_args, on_output): - host, port = listener.getsockname() + host, port = listener.getsockname() if listener is not None else ("", 0) cmdline = [ sys.executable, diff --git a/src/debugpy/adapter/sessions.py b/src/debugpy/adapter/sessions.py index ca87483f8..5b3ea748d 100644 --- a/src/debugpy/adapter/sessions.py +++ b/src/debugpy/adapter/sessions.py @@ -7,6 +7,7 @@ import signal import threading import time +from typing import Union from debugpy import common from debugpy.common import log, util @@ -26,6 +27,7 @@ class Session(util.Observable): """ _counter = itertools.count(1) + pid: Union[int, None] = None def __init__(self): from debugpy.adapter import clients @@ -94,7 +96,7 @@ def notify_changed(self): _sessions.remove(self) _sessions_changed.set() - def wait_for(self, predicate, timeout=None): + def wait_for(self, predicate, timeout: Union[float, None]=None): """Waits until predicate() becomes true. The predicate is invoked with the session locked. If satisfied, the method @@ -111,13 +113,14 @@ def wait_for(self, predicate, timeout=None): seconds regardless of whether the predicate was satisfied. The method returns False if it timed out, and True otherwise. """ - def wait_for_timeout(): - time.sleep(timeout) - wait_for_timeout.timed_out = True + if timeout is not None: + time.sleep(timeout) + wait_for_timeout.timed_out = True # pyright: ignore[reportFunctionMemberAccess] self.notify_changed() - wait_for_timeout.timed_out = False + wait_for_timeout.timed_out = False # pyright: ignore[reportFunctionMemberAccess] + if timeout is not None: thread = threading.Thread( target=wait_for_timeout, name="Session.wait_for() timeout" @@ -127,7 +130,7 @@ def wait_for_timeout(): with self: while not predicate(): - if wait_for_timeout.timed_out: + if wait_for_timeout.timed_out: # pyright: ignore[reportFunctionMemberAccess] return False self._changed_condition.wait() return True @@ -180,7 +183,7 @@ def _finalize(self, why, terminate_debuggee): # can ask the launcher to kill it, do so instead of disconnecting # from the server to prevent debuggee from running any more code. self.launcher.terminate_debuggee() - else: + elif self.server.channel is not None: # Otherwise, let the server handle it the best it can. try: self.server.channel.request( @@ -218,7 +221,8 @@ def _finalize(self, why, terminate_debuggee): self.wait_for(lambda: not self.launcher.is_connected) try: - self.launcher.channel.close() + if self.launcher.channel is not None: + self.launcher.channel.close() except Exception: log.swallow_exception() @@ -230,7 +234,8 @@ def _finalize(self, why, terminate_debuggee): if self.client.restart_requested: body["restart"] = True try: - self.client.channel.send_event("terminated", body) + if self.client.channel is not None: + self.client.channel.send_event("terminated", body) except Exception: pass diff --git a/src/debugpy/common/json.py b/src/debugpy/common/json.py index 6f3e2b214..3c6f8819f 100644 --- a/src/debugpy/common/json.py +++ b/src/debugpy/common/json.py @@ -7,8 +7,8 @@ import builtins import json -import numbers import operator +from typing import Any, Callable, Literal, Tuple, Union JsonDecoder = json.JSONDecoder @@ -21,14 +21,14 @@ class JsonEncoder(json.JSONEncoder): result is serialized instead of the object itself. """ - def default(self, value): + def default(self, o): try: - get_state = value.__getstate__ + get_state = o.__getstate__ except AttributeError: pass else: return get_state() - return super().default(value) + return super().default(o) class JsonObject(object): @@ -93,10 +93,10 @@ def __format__(self, format_spec): # some substitutions - e.g. replacing () with some default value. -def _converter(value, classinfo): +def _converter(value: str, classinfo) -> Union[int, float, None]: """Convert value (str) to number, otherwise return None if is not possible""" for one_info in classinfo: - if issubclass(one_info, numbers.Number): + if issubclass(one_info, int) or issubclass(one_info, float): try: return one_info(value) except ValueError: @@ -171,7 +171,7 @@ def validate(value): return validate -def array(validate_item=False, vectorize=False, size=None): +def array(validate_item: Union[Callable[..., Any], Literal[False]]=False, vectorize=False, size=None): """Returns a validator for a JSON array. If the property is missing, it is treated as if it were []. Otherwise, it must @@ -213,11 +213,11 @@ def array(validate_item=False, vectorize=False, size=None): ) elif isinstance(size, tuple): assert 1 <= len(size) <= 2 - size = tuple(operator.index(n) for n in size) - min_len, max_len = (size + (None,))[0:2] + sizes = tuple(operator.index(n) for n in size) + min_len, max_len = (sizes + (None,))[0:2] validate_size = lambda value: ( "must have at least {0} elements".format(min_len) - if len(value) < min_len + if min_len is None or len(value) < min_len else "must have at most {0} elements".format(max_len) if max_len is not None and len(value) < max_len else True @@ -250,7 +250,7 @@ def validate(value): return validate -def object(validate_value=False): +def object(validate_value: Union[Callable[..., Any], Tuple, Literal[False]]=False): """Returns a validator for a JSON object. If the property is missing, it is treated as if it were {}. Otherwise, it must diff --git a/src/debugpy/common/log.py b/src/debugpy/common/log.py index 099e93c71..d52c92951 100644 --- a/src/debugpy/common/log.py +++ b/src/debugpy/common/log.py @@ -12,6 +12,12 @@ import sys import threading import traceback +from typing import TYPE_CHECKING, Any, NoReturn, Protocol, Union + +if TYPE_CHECKING: + # Careful not force this import in production code, as it's not available in all + # code that we run. + from typing_extensions import TypeIs import debugpy from debugpy.common import json, timestamp, util @@ -122,7 +128,7 @@ def newline(level="info"): stderr.write(level, "\n") -def write(level, text, _to_files=all): +def write(level, text: str, _to_files=all): assert level in LEVELS t = timestamp.current() @@ -143,7 +149,7 @@ def write(level, text, _to_files=all): return text -def write_format(level, format_string, *args, **kwargs): +def write_format(level, format_string: str, *args, **kwargs) -> Union[str, None]: # Don't spend cycles doing expensive formatting if we don't have to. Errors are # always formatted, so that error() can return the text even if it's not logged. if level != "error" and level not in _levels: @@ -215,7 +221,7 @@ def swallow_exception(format_string="", *args, **kwargs): _exception(format_string, *args, **kwargs) -def reraise_exception(format_string="", *args, **kwargs): +def reraise_exception(format_string="", *args, **kwargs) -> NoReturn: """Like swallow_exception(), but re-raises the current exception after logging it.""" assert "exc_info" not in kwargs @@ -278,6 +284,14 @@ def prefixed(format_string, *args, **kwargs): finally: _tls.prefix = old_prefix +class HasName(Protocol): + name: str + +def has_name(obj: Any) -> "TypeIs[HasName]": + try: + return hasattr(obj, "name") + except NameError: + return False def get_environment_description(header): import sysconfig @@ -359,7 +373,11 @@ def report_paths(get_paths, label=None): report("Installed packages:\n") try: for pkg in importlib_metadata.distributions(): - report(" {0}=={1}\n", pkg.name, pkg.version) + if has_name(pkg): + name = pkg.name + report(" {0}=={1}\n", name, pkg.version) + else: + report(" {0}\n", pkg) except Exception: # pragma: no cover swallow_exception( "Error while enumerating installed packages.", level="info" @@ -395,7 +413,8 @@ def _repr(value): # pragma: no cover def _vars(*names): # pragma: no cover - locals = inspect.currentframe().f_back.f_locals + frame = inspect.currentframe() + locals = frame.f_back.f_locals if frame is not None and frame.f_back is not None else {} if names: locals = {name: locals[name] for name in names if name in locals} warning("$VARS {0!r}", locals) diff --git a/src/debugpy/common/messaging.py b/src/debugpy/common/messaging.py index b133f71b6..a04282323 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -14,11 +14,17 @@ import collections import contextlib import functools +import io import itertools import os import socket import sys import threading +from typing import TYPE_CHECKING, BinaryIO, Callable, Union, cast, Any +if TYPE_CHECKING: + # Careful not force this import in production code, as it's not available in all + # code that we run. + from typing_extensions import TypeIs from debugpy.common import json, log, util from debugpy.common.util import hide_thread_from_debugger @@ -86,7 +92,7 @@ def from_process(cls, process, name="stdio"): return cls(process.stdout, process.stdin, name) @classmethod - def from_socket(cls, sock, name=None): + def from_socket(cls: type[JsonIOStream], sock: socket.socket, name: Union[str, None]=None): """Creates a new instance that sends and receives messages over a socket.""" sock.settimeout(None) # make socket blocking if name is None: @@ -96,7 +102,7 @@ def from_socket(cls, sock, name=None): # sockets is very slow! Although the implementation of readline() itself is # native code, it calls read(1) in a loop - and that then ultimately calls # SocketIO.readinto(), which is implemented in Python. - socket_io = sock.makefile("rwb", 0) + socket_io: socket.SocketIO = sock.makefile("rwb", 0) # SocketIO.close() doesn't close the underlying socket. def cleanup(): @@ -108,7 +114,13 @@ def cleanup(): return cls(socket_io, socket_io, name, cleanup) - def __init__(self, reader, writer, name=None, cleanup=lambda: None): + def __init__( + self, + reader: Union[io.RawIOBase, BinaryIO], + writer: Union[io.RawIOBase, BinaryIO], + name: Union[str, None] = None, + cleanup=lambda: None, + ): """Creates a new JsonIOStream. reader must be a BytesIO-like object, from which incoming messages will be @@ -158,11 +170,13 @@ def close(self): except Exception: # pragma: no cover log.reraise_exception("Error while closing {0} message stream", self.name) - def _log_message(self, dir, data, logger=log.debug): + def _log_message( + self, dir, data, logger: Callable[..., Union[str, None]] = log.debug + ): return logger("{0} {1} {2}", self.name, dir, data) - def _read_line(self, reader): - line = b"" + def _read_line(self, reader: Union[io.RawIOBase, BinaryIO]) -> bytes: + line: bytes = b"" while True: try: line += reader.readline() @@ -202,6 +216,7 @@ def log_message_and_reraise_exception(format_string="", *args, **kwargs): raw_chunks = [] headers = {} + line: Union[bytes, None] = None while True: try: @@ -222,9 +237,12 @@ def log_message_and_reraise_exception(format_string="", *args, **kwargs): if line == b"": break - key, _, value = line.partition(b":") + key, _, value = ( + line.partition(b":") if line is not None else (b"", b"", b"") + ) headers[key] = value + length = 0 try: length = int(headers[b"Content-Length"]) if not (0 <= length <= self.MAX_BODY_SIZE): @@ -256,10 +274,11 @@ def log_message_and_reraise_exception(format_string="", *args, **kwargs): except Exception: # pragma: no cover log_message_and_reraise_exception() - try: - body = decoder.decode(body) - except Exception: # pragma: no cover - log_message_and_reraise_exception() + if isinstance(body, str): + try: + body = decoder.decode(body) + except Exception: # pragma: no cover + log_message_and_reraise_exception() # If parsed successfully, log as JSON for readability. self._log_message("-->", body) @@ -283,6 +302,7 @@ def write_json(self, value, encoder=None): # information as we already have at the point of the failure. For example, # if it fails after it is serialized to JSON, log that JSON. + body: Union[str, bytes] = "" try: body = encoder.encode(value) except Exception: # pragma: no cover @@ -295,7 +315,8 @@ def write_json(self, value, encoder=None): try: while data_written < len(data): written = writer.write(data[data_written:]) - data_written += written + if written is not None: + data_written += written writer.flush() except Exception as exc: # pragma: no cover self._log_message("<--", value, logger=log.swallow_exception) @@ -325,7 +346,7 @@ class MessageDict(collections.OrderedDict): such guarantee for outgoing messages. """ - def __init__(self, message, items=None): + def __init__(self, message: Union[Message, None], items: Union[dict, None]=None): assert message is None or isinstance(message, Message) if items is None: @@ -383,19 +404,19 @@ def __call__(self, key, validate, optional=False): try: value = validate(value) except (TypeError, ValueError) as exc: - message = Message if self.message is None else self.message + message = Message.empty() if self.message is None else self.message err = str(exc) if not err.startswith("["): err = " " + err raise message.isnt_valid("{0}{1}", json.repr(key), err) return value - def _invalid_if_no_key(func): + def _invalid_if_no_key(func: Callable[..., Any]): # pyright: ignore[reportSelfClsParameterName] def wrap(self, key, *args, **kwargs): try: return func(self, key, *args, **kwargs) except KeyError: - message = Message if self.message is None else self.message + message = Message.empty() if self.message is None else self.message raise message.isnt_valid("missing property {0!r}", key) return wrap @@ -407,6 +428,13 @@ def wrap(self, key, *args, **kwargs): del _invalid_if_no_key +class AssociableMessageDict(MessageDict): + def associate_with(self, message: Message): + self.message = message + +def is_associable(obj) -> "TypeIs[AssociableMessageDict]": + return isinstance(obj, MessageDict) and hasattr(obj, "associate_with") + def _payload(value): """JSON validator for message payload. @@ -421,12 +449,7 @@ def _payload(value): # Missing payload. Construct a dummy MessageDict, and make it look like it was # deserialized. See JsonMessageChannel._parse_incoming_message for why it needs # to have associate_with(). - - def associate_with(message): - value.message = message - - value = MessageDict(None) - value.associate_with = associate_with + value = AssociableMessageDict(None) return value @@ -451,7 +474,7 @@ def __init__(self, channel, seq, json=None): """ def __str__(self): - return json.repr(self.json) if self.json is not None else repr(self) + return str(json.repr(self.json)) if self.json is not None else repr(self) def describe(self): """A brief description of the message that is enough to identify it. @@ -463,14 +486,17 @@ def describe(self): raise NotImplementedError @property - def payload(self) -> MessageDict: + def payload(self) -> MessageDict | Exception: """Payload of the message - self.body or self.arguments, depending on the message type. """ raise NotImplementedError - def __call__(self, *args, **kwargs): + def __call__(self, *args, **kwargs) -> MessageDict | Any | int | float: """Same as self.payload(...).""" + assert not isinstance(self.payload, Exception) + if args.count == 0 and kwargs == {}: + return self.payload return self.payload(*args, **kwargs) def __contains__(self, key): @@ -523,7 +549,10 @@ def isnt_valid(self, *args, **kwargs): def cant_handle(self, *args, **kwargs): """Same as self.error(MessageHandlingError, ...).""" return self.error(MessageHandlingError, *args, **kwargs) - + + @classmethod + def empty(cls) -> Message: + return Message(None, None) class Event(Message): """Represents an incoming event. @@ -550,12 +579,12 @@ class Event(Message): the appropriate exception type that applies_to() the Event object. """ - def __init__(self, channel, seq, event, body, json=None): + def __init__(self, channel, seq, event, body: MessageDict, json=None): super().__init__(channel, seq, json) self.event = event - if isinstance(body, MessageDict) and hasattr(body, "associate_with"): + if is_associable(body): body.associate_with(self) self.body = body @@ -644,16 +673,16 @@ class Request(Message): the appropriate exception type that applies_to() the Request object. """ - def __init__(self, channel, seq, command, arguments, json=None): + def __init__(self, channel, seq, command, arguments: MessageDict, json=None): super().__init__(channel, seq, json) self.command = command - if isinstance(arguments, MessageDict) and hasattr(arguments, "associate_with"): + if is_associable(arguments): arguments.associate_with(self) self.arguments = arguments - self.response = None + self.response: Union[Response, None] = None """Response to this request. For incoming requests, it is set as soon as the request handler returns. @@ -753,16 +782,18 @@ class OutgoingRequest(Request): response to be received, and register a response handler. """ - _parse = _handle = None - def __init__(self, channel, seq, command, arguments): super().__init__(channel, seq, command, arguments) self._response_handlers = [] + # Erase the parse and handle methods, as they are not needed for outgoing. + setattr(self, "_parse", None) + setattr(self, "_handle", None) + def describe(self): return f"{self.seq} request {json.repr(self.command)} to {self.channel}" - def wait_for_response(self, raise_if_failed=True): + def wait_for_response(self, raise_if_failed=True)-> MessageDict: """Waits until a response is received for this request, records the Response object for it in self.response, and returns response.body. @@ -777,8 +808,10 @@ def wait_for_response(self, raise_if_failed=True): while self.response is None: self.channel._handlers_enqueued.wait() - if raise_if_failed and not self.response.success: + if raise_if_failed and not self.response.success and isinstance( self.response.body, BaseException): raise self.response.body + + assert not isinstance(self.response.body, Exception) return self.response.body def on_response(self, response_handler): @@ -864,13 +897,13 @@ class Response(Message): the appropriate exception type that applies_to() the Response object. """ - def __init__(self, channel, seq, request, body, json=None): + def __init__(self, channel, seq, request, body: MessageDict | Exception, json=None): super().__init__(channel, seq, json) self.request = request """The request to which this is the response.""" - if isinstance(body, MessageDict) and hasattr(body, "associate_with"): + if is_associable(body): body.associate_with(self) self.body = body """Body of the response if the request was successful, or an instance @@ -904,8 +937,10 @@ def result(self): """ if self.success: return self.body - else: + elif isinstance(self.body, Exception): raise self.body + else: + raise Exception(self.body) @staticmethod def _parse(channel, message_dict, body=None): @@ -1263,7 +1298,10 @@ def send_event(self, event, body=None): def request(self, *args, **kwargs): """Same as send_request(...).wait_for_response()""" - return self.send_request(*args, **kwargs).wait_for_response() + # This should always raise an exception on failure + result = self.send_request(*args, **kwargs).wait_for_response() + assert not isinstance(result, BaseException) + return result def propagate(self, message): """Sends a new message with the same type and payload. @@ -1282,7 +1320,7 @@ def delegate(self, message): """ try: result = self.propagate(message) - if result.is_request(): + if result is not None and result.is_request(): result = result.wait_for_response() return result except MessageHandlingError as exc: @@ -1336,10 +1374,10 @@ def _parse_incoming_message(self): # for all JSON objects, and track them so that they can be later wired up to # the Message they belong to, once it is instantiated. def object_hook(d): - d = MessageDict(None, d) + d = AssociableMessageDict(None, d) if "seq" in d: self._prettify(d) - d.associate_with = associate_with + setattr(d, "associate_with", associate_with) message_dicts.append(d) return d @@ -1363,7 +1401,7 @@ def associate_with(message): message_dict = self.stream.read_json(decoder) assert isinstance(message_dict, MessageDict) # make sure stream used decoder - msg_type = message_dict("type", json.enum("event", "request", "response")) + msg_type: str = cast(str, message_dict("type", json.enum("event", "request", "response"))) parser = self._message_parsers[msg_type] try: parser(self, message_dict) @@ -1421,7 +1459,7 @@ def _run_handlers(self): while True: with self: closed = self._closed - if closed: + if closed and self._parser_thread is not None: # Wait for the parser thread to wrap up and enqueue any remaining # handlers, if it is still running. self._parser_thread.join() diff --git a/src/debugpy/common/singleton.py b/src/debugpy/common/singleton.py index d515a4abf..f3f9abf85 100644 --- a/src/debugpy/common/singleton.py +++ b/src/debugpy/common/singleton.py @@ -86,12 +86,16 @@ def __init__(self, *args, **kwargs): def __enter__(self): """Lock this singleton to prevent concurrent access.""" - type(self)._lock.acquire() + lock = type(self)._lock + assert lock is not None + lock.acquire() return self def __exit__(self, exc_type, exc_value, exc_tb): """Unlock this singleton to allow concurrent access.""" - type(self)._lock.release() + lock = type(self)._lock + assert lock is not None + lock.release() def share(self): """Share this singleton, if it was originally created with shared=False.""" @@ -137,9 +141,9 @@ def __init__(self, *args, **kwargs): # with @threadsafe_method. Such methods should perform the necessary locking to # ensure thread safety for the callers. - @staticmethod def assert_locked(self): lock = type(self)._lock + assert lock is not None assert lock.acquire(blocking=False), ( "ThreadSafeSingleton accessed without locking. Either use with-statement, " "or if it is a method or property, mark it as @threadsafe_method or with " diff --git a/src/debugpy/common/sockets.py b/src/debugpy/common/sockets.py index ffcef80f6..202bbbe7f 100644 --- a/src/debugpy/common/sockets.py +++ b/src/debugpy/common/sockets.py @@ -5,6 +5,7 @@ import socket import sys import threading +from typing import Any, Callable, Union from debugpy.common import log from debugpy.common.util import hide_thread_from_debugger @@ -18,7 +19,7 @@ def create_server(host, port=0, backlog=socket.SOMAXCONN, timeout=None): host = "127.0.0.1" if port is None: port = 0 - + server: Union[socket.socket, None] = None try: server = _new_sock() if port != 0: @@ -37,7 +38,8 @@ def create_server(host, port=0, backlog=socket.SOMAXCONN, timeout=None): server.settimeout(timeout) server.listen(backlog) except Exception: # pragma: no cover - server.close() + if server is not None: + server.close() raise return server @@ -87,7 +89,7 @@ def close_socket(sock): sock.close() -def serve(name, handler, host, port=0, backlog=socket.SOMAXCONN, timeout=None): +def serve(name: str, handler: Callable[[socket.socket], Any], host: str, port: int=0, backlog=socket.SOMAXCONN, timeout: Union[int, None]=None): """Accepts TCP connections on the specified host and port, and invokes the provided handler function for every new connection. @@ -97,7 +99,7 @@ def serve(name, handler, host, port=0, backlog=socket.SOMAXCONN, timeout=None): assert backlog > 0 try: - listener = create_server(host, port, backlog, timeout) + listener: socket.socket = create_server(host, port, backlog, timeout) except Exception: # pragma: no cover log.reraise_exception( "Error listening for incoming {0} connections on {1}:{2}:", name, host, port diff --git a/src/debugpy/common/util.py b/src/debugpy/common/util.py index 54850a07b..329fad95f 100644 --- a/src/debugpy/common/util.py +++ b/src/debugpy/common/util.py @@ -19,7 +19,7 @@ def evaluate(code, path=__file__, mode="eval"): class Observable(object): """An object with change notifications.""" - observers = () # used when attributes are set before __init__ is invoked + observers = [] # used when attributes are set before __init__ is invoked def __init__(self): self.observers = [] @@ -162,3 +162,4 @@ def hide_thread_from_debugger(thread): if hide_debugpy_internals(): thread.pydev_do_not_trace = True thread.is_pydev_daemon_thread = True + diff --git a/src/debugpy/launcher/debuggee.py b/src/debugpy/launcher/debuggee.py index 2d8528815..e5dc72062 100644 --- a/src/debugpy/launcher/debuggee.py +++ b/src/debugpy/launcher/debuggee.py @@ -10,6 +10,7 @@ import subprocess import sys import threading +from typing import Any from debugpy import launcher from debugpy.common import log, messaging @@ -34,7 +35,7 @@ def describe(): - return f"Debuggee[PID={process.pid}]" + return f"Debuggee[PID={process.pid if process is not None else 0}]" def spawn(process_name, cmdline, env, redirect_output): @@ -47,6 +48,8 @@ def spawn(process_name, cmdline, env, redirect_output): ) close_fds = set() + stdout_r = 0 + stderr_r = 0 try: if redirect_output: # subprocess.PIPE behavior can vary substantially depending on Python version @@ -54,7 +57,7 @@ def spawn(process_name, cmdline, env, redirect_output): stdout_r, stdout_w = os.pipe() stderr_r, stderr_w = os.pipe() close_fds |= {stdout_r, stdout_w, stderr_r, stderr_w} - kwargs = dict(stdout=stdout_w, stderr=stderr_w) + kwargs: dict[str, Any] = dict(stdout=stdout_w, stderr=stderr_w) else: kwargs = {} @@ -194,7 +197,7 @@ def kill(): def wait_for_exit(): try: - code = process.wait() + code = process.wait() if process is not None else 0 if sys.platform != "win32" and code < 0: # On POSIX, if the process was terminated by a signal, Popen will use # a negative returncode to indicate that - but the actual exit code of @@ -241,7 +244,7 @@ def _wait_for_user_input(): log.debug("msvcrt available - waiting for user input via getch()") sys.stdout.write("Press any key to continue . . . ") sys.stdout.flush() - msvcrt.getch() + msvcrt.getch() # pyright: ignore[reportPossiblyUnboundVariable, reportAttributeAccessIssue] else: log.debug("msvcrt not available - waiting for user input via read()") sys.stdout.write("Press Enter to continue . . . ") diff --git a/src/debugpy/launcher/output.py b/src/debugpy/launcher/output.py index 70cd5218c..818b436e9 100644 --- a/src/debugpy/launcher/output.py +++ b/src/debugpy/launcher/output.py @@ -18,7 +18,7 @@ class CaptureOutput(object): instances = {} """Keys are output categories, values are CaptureOutput instances.""" - def __init__(self, whose, category, fd, stream): + def __init__(self, whose, category, fd: int, stream): assert category not in self.instances self.instances[category] = self log.info("Capturing {0} of {1}.", category, whose) @@ -95,7 +95,7 @@ def _process_chunk(self, s, final=False): while i < size: written = self._stream.write(s[i:]) self._stream.flush() - if written == 0: + if written == 0 and self._fd is not None: # This means that the output stream was closed from the other end. # Do the same to the debuggee, so that it knows as well. os.close(self._fd) diff --git a/src/debugpy/launcher/winapi.py b/src/debugpy/launcher/winapi.py index a93dbc70a..4b470f5f8 100644 --- a/src/debugpy/launcher/winapi.py +++ b/src/debugpy/launcher/winapi.py @@ -64,14 +64,14 @@ def _errcheck(is_error_result=(lambda result: not result)): def impl(result, func, args): if is_error_result(result): log.debug("{0} returned {1}", func.__name__, result) - raise ctypes.WinError() + raise ctypes.WinError() # pyright: ignore[reportAttributeAccessIssue] else: return result return impl -kernel32 = ctypes.windll.kernel32 +kernel32 = ctypes.windll.kernel32 # pyright: ignore[reportAttributeAccessIssue] kernel32.AssignProcessToJobObject.errcheck = _errcheck() kernel32.AssignProcessToJobObject.restype = BOOL diff --git a/src/debugpy/server/api.py b/src/debugpy/server/api.py index 8fa8767a1..6be27cf81 100644 --- a/src/debugpy/server/api.py +++ b/src/debugpy/server/api.py @@ -4,6 +4,7 @@ import codecs import os +from typing import Any import pydevd import socket import sys @@ -37,37 +38,36 @@ _adapter_process = None -def _settrace(*args, **kwargs): - log.debug("pydevd.settrace(*{0!r}, **{1!r})", args, kwargs) - # The stdin in notification is not acted upon in debugpy, so, disable it. - kwargs.setdefault("notify_stdin", False) - try: - return pydevd.settrace(*args, **kwargs) - except Exception: - raise - else: - _settrace.called = True - - -_settrace.called = False +class _settrace(): + called = False + def __new__(cls, *args, **kwargs): + log.debug("pydevd.settrace(*{0!r}, **{1!r})", args, kwargs) + # The stdin in notification is not acted upon in debugpy, so, disable it. + kwargs.setdefault("notify_stdin", False) + try: + return pydevd.settrace(*args, **kwargs) + except Exception: + raise + finally: + cls.called = True def ensure_logging(): """Starts logging to log.log_dir, if it hasn't already been done.""" - if ensure_logging.ensured: + if ensure_logging.ensured: # pyright: ignore[reportFunctionMemberAccess] return - ensure_logging.ensured = True + ensure_logging.ensured = True # pyright: ignore[reportFunctionMemberAccess] log.to_file(prefix="debugpy.server") log.describe_environment("Initial environment:") if log.log_dir is not None: pydevd.log_to(log.log_dir + "/debugpy.pydevd.log") -ensure_logging.ensured = False +ensure_logging.ensured = False # pyright: ignore[reportFunctionMemberAccess] def log_to(path): - if ensure_logging.ensured: + if getattr(ensure_logging, "ensured"): raise RuntimeError("logging has already begun") log.debug("log_to{0!r}", (path,)) @@ -78,7 +78,7 @@ def log_to(path): def configure(properties=None, **kwargs): - if _settrace.called: + if getattr(_settrace, "called"): raise RuntimeError("debug adapter is already running") ensure_logging() @@ -239,7 +239,10 @@ def listen(address, settrace_kwargs, in_process_debug_adapter=False): sock.settimeout(None) sock_io = sock.makefile("rb", 0) try: - endpoints = json.loads(sock_io.read().decode("utf-8")) + bytes = sock_io.read() + if bytes is None: + raise EOFError("EOF while reading adapter endpoints") + endpoints = json.loads(bytes.decode("utf-8")) finally: sock_io.close() finally: @@ -297,7 +300,7 @@ def connect(address, settrace_kwargs, access_token=None): _settrace(host=host, port=port, client_access_token=access_token, **settrace_kwargs) -class wait_for_client: +class wait_for_client_cls: def __call__(self): ensure_logging() log.debug("wait_for_client()") @@ -311,12 +314,10 @@ def __call__(self): pydevd._wait_for_attach(cancel=cancel_event) @staticmethod - def cancel(): + def cancel() -> None: raise RuntimeError("wait_for_client() must be called first") - -wait_for_client = wait_for_client() - +wait_for_client = wait_for_client_cls() def is_client_connected(): return pydevd._is_attached() @@ -334,6 +335,7 @@ def breakpoint(): stop_at_frame = sys._getframe().f_back while ( stop_at_frame is not None + and pydb is not None and pydb.get_file_type(stop_at_frame) == pydb.PYDEV_FILE ): stop_at_frame = stop_at_frame.f_back @@ -358,7 +360,7 @@ def trace_this_thread(should_trace): ensure_logging() log.debug("trace_this_thread({0!r})", should_trace) - pydb = get_global_debugger() + pydb: Any = get_global_debugger() if should_trace: pydb.enable_tracing() else: diff --git a/src/debugpy/server/attach_pid_injected.py b/src/debugpy/server/attach_pid_injected.py index a8df6e1e2..866d99160 100644 --- a/src/debugpy/server/attach_pid_injected.py +++ b/src/debugpy/server/attach_pid_injected.py @@ -11,7 +11,7 @@ _debugpy_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -def attach(setup): +def attach(setup) -> None: log = None try: import sys diff --git a/src/debugpy/server/cli.py b/src/debugpy/server/cli.py index a9c442f9d..1656bb01f 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -7,10 +7,7 @@ import re import sys from importlib.util import find_spec -from typing import Any -from typing import Union -from typing import Tuple -from typing import Dict +from typing import Any, Tuple, Union, Dict # debugpy.__main__ should have preloaded pydevd properly before importing this module. # Otherwise, some stdlib modules above might have had imported threading before pydevd diff --git a/tests/logs.py b/tests/logs.py index 7d3089cad..5d1c63c8a 100644 --- a/tests/logs.py +++ b/tests/logs.py @@ -4,12 +4,26 @@ import io import os -import pytest_timeout +import shutil import sys from debugpy.common import json, log +def write_title(title, stream=None, sep="~"): + """Write a section title. + If *stream* is None sys.stderr will be used, *sep* is used to + draw the line. + """ + if stream is None: + stream = sys.stderr + width, height = shutil.get_terminal_size() + fill = int((width - len(title) - 2) / 2) + line = " ".join([sep * fill, title, sep * fill]) + if len(line) < width: + line += sep * (width - len(line)) + stream.write("\n" + line + "\n") + def dump(): if log.log_dir is None: return @@ -27,5 +41,5 @@ def dump(): pass else: path = os.path.relpath(path, log.log_dir) - pytest_timeout.write_title(path) + write_title(path) print(s, file=sys.stderr) diff --git a/tests/pytest_hooks.py b/tests/pytest_hooks.py index f37eecb84..7ef3851f1 100644 --- a/tests/pytest_hooks.py +++ b/tests/pytest_hooks.py @@ -7,7 +7,7 @@ import pytest_timeout import sys -from debugpy.common import log +from debugpy.common import log # pyright: ignore[reportAttributeAccessIssue] import tests from tests import logs @@ -56,9 +56,8 @@ def pytest_runtest_makereport(item, call): def pytest_make_parametrize_id(config, val): return getattr(val, "pytest_id", None) - # If a test times out and pytest tries to print the stacks of where it was hanging, # we want to print the pydevd log as well. This is not a normal pytest hook - we # just detour pytest_timeout.dump_stacks directly. _dump_stacks = pytest_timeout.dump_stacks -pytest_timeout.dump_stacks = lambda: (_dump_stacks(), logs.dump()) +pytest_timeout.dump_stacks = lambda terminal: (_dump_stacks(terminal), logs.dump()) diff --git a/tests/requirements.txt b/tests/requirements.txt index fa0a4ea53..7d48234e2 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -10,6 +10,7 @@ pytest-retry importlib_metadata psutil +untangle ## Used in Python code that is run/debugged by the tests: @@ -18,3 +19,4 @@ flask gevent numpy requests +typing_extensions \ No newline at end of file