From 3d7808eedd411df237e867b3d10d868df75a0db6 Mon Sep 17 00:00:00 2001 From: rchiodo Date: Fri, 5 Jul 2024 18:15:45 +0000 Subject: [PATCH 01/20] Make typing even more restrictive and start fixing some more errors --- pyproject.toml | 1 + src/debugpy/common/log.py | 23 +++++++++++++++++++---- src/debugpy/server/attach_pid_injected.py | 2 +- src/debugpy/server/cli.py | 8 ++++---- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a6906c0d0..3addae674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ ignore = ["src/debugpy/_vendored/pydevd", "src/debugpy/_version.py"] executionEnvironments = [ { root = "src" }, { root = "." } ] +typeCheckingMode = "standard" [tool.ruff] # Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default. diff --git a/src/debugpy/common/log.py b/src/debugpy/common/log.py index 099e93c71..093606a47 100644 --- a/src/debugpy/common/log.py +++ b/src/debugpy/common/log.py @@ -12,6 +12,8 @@ import sys import threading import traceback +from typing import Any, NoReturn, Protocol, Union +from typing_extensions import TypeIs import debugpy from debugpy.common import json, timestamp, util @@ -143,7 +145,7 @@ def write(level, text, _to_files=all): return text -def write_format(level, format_string, *args, **kwargs): +def write_format(level, format_string, *args, **kwargs) -> Union[Any, 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 +217,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 +280,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 +369,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 +409,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/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 0fc5e2559..ae942702c 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -7,7 +7,7 @@ import re import sys from importlib.util import find_spec -from typing import Any +from typing import Any, Tuple, Union # debugpy.__main__ should have preloaded pydevd properly before importing this module. # Otherwise, some stdlib modules above might have had imported threading before pydevd @@ -43,11 +43,11 @@ class Options(object): mode = None - address: "tuple[str, int] | None" = None + address: Union[Tuple[str, int], None] = None log_to = None log_to_stderr = False - target: str | None = None - target_kind: str | None = None + target: Union[str, None] = None + target_kind: Union[str, None] = None wait_for_client = False adapter_access_token = None config: "dict[str, Any]" = {} From e528e2dbe3c38a66187dbb0a31a22e81cd84bf04 Mon Sep 17 00:00:00 2001 From: rchiodo Date: Fri, 5 Jul 2024 20:52:27 +0000 Subject: [PATCH 02/20] More typing --- src/debugpy/adapter/components.py | 8 ++- src/debugpy/adapter/launchers.py | 10 ++-- src/debugpy/adapter/servers.py | 22 +++++--- src/debugpy/adapter/sessions.py | 12 ++-- src/debugpy/common/log.py | 6 +- src/debugpy/common/messaging.py | 92 +++++++++++++++++++++---------- src/debugpy/common/sockets.py | 10 ++-- src/debugpy/common/util.py | 2 +- 8 files changed, 104 insertions(+), 58 deletions(-) diff --git a/src/debugpy/adapter/components.py b/src/debugpy/adapter/components.py index cc88d1416..038e88832 100644 --- a/src/debugpy/adapter/components.py +++ b/src/debugpy/adapter/components.py @@ -3,7 +3,9 @@ # for license information. import functools +from typing import Union +from debugpy.adapter.sessions import Session from debugpy.common import json, log, messaging, util @@ -31,7 +33,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=None): assert (stream is None) ^ (channel is None) try: @@ -44,11 +46,11 @@ 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 self.channel = channel 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..03ef6d463 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,8 +286,9 @@ 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 diff --git a/src/debugpy/adapter/sessions.py b/src/debugpy/adapter/sessions.py index ca87483f8..24b492b97 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 @@ -113,11 +115,11 @@ def wait_for(self, predicate, timeout=None): """ def wait_for_timeout(): - time.sleep(timeout) - wait_for_timeout.timed_out = True + if timeout is not None: + time.sleep(timeout) + setattr(wait_for_timeout, "timed_out", True) self.notify_changed() - wait_for_timeout.timed_out = False if timeout is not None: thread = threading.Thread( target=wait_for_timeout, name="Session.wait_for() timeout" @@ -127,7 +129,7 @@ def wait_for_timeout(): with self: while not predicate(): - if wait_for_timeout.timed_out: + if hasattr(wait_for_timeout, "timed_out"): return False self._changed_condition.wait() return True diff --git a/src/debugpy/common/log.py b/src/debugpy/common/log.py index 093606a47..f35db8b04 100644 --- a/src/debugpy/common/log.py +++ b/src/debugpy/common/log.py @@ -12,7 +12,7 @@ import sys import threading import traceback -from typing import Any, NoReturn, Protocol, Union +from typing import Any, Callable, NoReturn, Protocol, Union from typing_extensions import TypeIs import debugpy @@ -124,7 +124,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() @@ -145,7 +145,7 @@ def write(level, text, _to_files=all): return text -def write_format(level, format_string, *args, **kwargs) -> Union[Any, str, None]: +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: diff --git a/src/debugpy/common/messaging.py b/src/debugpy/common/messaging.py index b133f71b6..644c1ba28 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -14,14 +14,18 @@ import collections import contextlib import functools +import io import itertools import os import socket import sys import threading +from typing import Any, BinaryIO, Callable, Literal, Protocol, Union, cast +from typing_extensions import TypeIs from debugpy.common import json, log, util from debugpy.common.util import hide_thread_from_debugger +from winappdbg.compat import b class JsonIOError(IOError): @@ -86,7 +90,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 +100,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 +112,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 +168,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 +214,7 @@ def log_message_and_reraise_exception(format_string="", *args, **kwargs): raw_chunks = [] headers = {} + line: Union[bytes, None] = None while True: try: @@ -222,9 +235,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 +272,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 +300,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 +313,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) @@ -383,19 +402,20 @@ 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 + @staticmethod def _invalid_if_no_key(func): 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 +427,13 @@ def wrap(self, key, *args, **kwargs): del _invalid_if_no_key +class AssociatableMessageDict(MessageDict): + def associate_with(self, message: Message): + ... + +def is_associatable(obj) -> TypeIs[AssociatableMessageDict]: + return isinstance(obj, MessageDict) and hasattr(obj, "associate_with") + def _payload(value): """JSON validator for message payload. @@ -426,7 +453,7 @@ def associate_with(message): value.message = message value = MessageDict(None) - value.associate_with = associate_with + setattr(value, "associate_with", associate_with) return value @@ -451,7 +478,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 json.repr(self.json).__str__() if self.json is not None else repr(self) def describe(self): """A brief description of the message that is enough to identify it. @@ -523,7 +550,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. @@ -555,7 +585,7 @@ def __init__(self, channel, seq, event, body, json=None): self.event = event - if isinstance(body, MessageDict) and hasattr(body, "associate_with"): + if is_associatable(body): body.associate_with(self) self.body = body @@ -649,7 +679,7 @@ def __init__(self, channel, seq, command, arguments, json=None): self.command = command - if isinstance(arguments, MessageDict) and hasattr(arguments, "associate_with"): + if is_associatable(arguments): arguments.associate_with(self) self.arguments = arguments @@ -753,16 +783,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)-> AssociatableMessageDict | Exception: """Waits until a response is received for this request, records the Response object for it in self.response, and returns response.body. @@ -777,7 +809,7 @@ 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, Exception): raise self.response.body return self.response.body @@ -870,7 +902,7 @@ def __init__(self, channel, seq, request, body, json=None): self.request = request """The request to which this is the response.""" - if isinstance(body, MessageDict) and hasattr(body, "associate_with"): + if is_associatable(body): body.associate_with(self) self.body = body """Body of the response if the request was successful, or an instance @@ -904,8 +936,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): @@ -1282,7 +1316,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: @@ -1339,7 +1373,7 @@ def object_hook(d): d = MessageDict(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 +1397,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 +1455,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/sockets.py b/src/debugpy/common/sockets.py index ffcef80f6..7b26026ae 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 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], None], 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..e332f1f41 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 = [] From 622e3c69674203e78f4682d7d3707257c3cfcd0f Mon Sep 17 00:00:00 2001 From: rchiodo Date: Fri, 5 Jul 2024 22:59:42 +0000 Subject: [PATCH 03/20] Finish typing debupy python code --- src/debugpy/adapter/__main__.py | 3 +- src/debugpy/adapter/clients.py | 47 ++++++++++++++++++------------- src/debugpy/adapter/components.py | 10 ++++--- src/debugpy/adapter/servers.py | 18 ++++++------ src/debugpy/adapter/sessions.py | 10 ++++--- src/debugpy/common/json.py | 21 +++++++------- src/debugpy/common/messaging.py | 11 +++++--- src/debugpy/common/singleton.py | 10 +++++-- 8 files changed, 75 insertions(+), 55 deletions(-) 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..afd9257b5 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -6,7 +6,9 @@ import atexit import os +import socket import sys +from typing import Literal, Union import debugpy from debugpy import adapter, common, launcher @@ -41,7 +43,7 @@ class Expectations(components.Capabilities): "pathFormat": json.enum("path", optional=True), # we don't support "uri" } - def __init__(self, sock): + def __init__(self, sock: Union[Literal["stdio"], socket.socket]): if sock == "stdio": log.info("Connecting to client over stdio...", self) self.using_stdio = True @@ -67,7 +69,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 +126,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 +205,10 @@ 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. + @staticmethod def _start_message_handler(f): @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 +219,16 @@ def handle(self, request): if self.session.no_debug: servers.dont_wait_for_first_connection() + request_options = request("debugOptions", json.array(str)) self.session.debug_options = debug_options = set( - request("debugOptions", json.array(str)) + ) 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 +272,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 +340,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 +397,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 +490,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 +582,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 +629,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 +656,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.AssociatableMessageDict = 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 +761,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 @@ -770,7 +777,7 @@ def notify_of_subprocess(self, conn): def serve(host, port): global listener - listener = sockets.serve("Client", Client, host, port) + listener = sockets.serve("Client", Client, host, port) # type: ignore sessions.report_sockets() return listener.getsockname() diff --git a/src/debugpy/adapter/components.py b/src/debugpy/adapter/components.py index 038e88832..448713fab 100644 --- a/src/debugpy/adapter/components.py +++ b/src/debugpy/adapter/components.py @@ -3,7 +3,7 @@ # for license information. import functools -from typing import Union +from typing import Type, TypeVar, Union, cast from debugpy.adapter.sessions import Session from debugpy.common import json, log, messaging, util @@ -33,7 +33,7 @@ class Component(util.Observable): to wait_for() a change caused by another component. """ - def __init__(self, session: Session, stream: Union[messaging.JsonIOStream, None]=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: @@ -53,6 +53,7 @@ def __init__(self, session: Session, stream: Union[messaging.JsonIOStream, None] 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 @@ -110,8 +111,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. @@ -126,7 +128,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/servers.py b/src/debugpy/adapter/servers.py index 03ef6d463..996784b94 100644 --- a/src/debugpy/adapter/servers.py +++ b/src/debugpy/adapter/servers.py @@ -292,7 +292,7 @@ def initialize(self, request): # 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 @@ -397,7 +397,7 @@ def disconnect(self): def serve(host="127.0.0.1", port=0): global listener - listener = sockets.serve("Server", Connection, host, port) + listener = sockets.serve("Server", Connection, host, port) # type: ignore sessions.report_sockets() return listener.getsockname() @@ -422,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) + setattr(wait_for_timeout, "timed_out", True) with _lock: _connections_changed.set() - wait_for_timeout.timed_out = timeout == 0 + setattr(wait_for_timeout, "timed_out", timeout == 0) if timeout: thread = threading.Thread( target=wait_for_timeout, name="servers.wait_for_connection() timeout" @@ -451,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 getattr(wait_for_timeout, "timed_out") is True: return conn _connections_changed.wait() @@ -479,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 24b492b97..0eaeb7503 100644 --- a/src/debugpy/adapter/sessions.py +++ b/src/debugpy/adapter/sessions.py @@ -7,7 +7,7 @@ import signal import threading import time -from typing import Union +from typing import Union, cast from debugpy import common from debugpy.common import log, util @@ -182,7 +182,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( @@ -220,7 +220,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() @@ -232,7 +233,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..2de77acaf 100644 --- a/src/debugpy/common/json.py +++ b/src/debugpy/common/json.py @@ -9,6 +9,7 @@ import json import numbers import operator +from typing import Any, Callable, Literal, Tuple, Union JsonDecoder = json.JSONDecoder @@ -21,14 +22,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,12 +94,12 @@ def __format__(self, format_spec): # some substitutions - e.g. replacing () with some default value. -def _converter(value, classinfo): +def _converter(value: str, classinfo) -> Any: """Convert value (str) to number, otherwise return None if is not possible""" for one_info in classinfo: if issubclass(one_info, numbers.Number): try: - return one_info(value) + return one_info(value) # pyright: ignore except ValueError: pass @@ -171,7 +172,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 +214,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 +251,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/messaging.py b/src/debugpy/common/messaging.py index 644c1ba28..3429040ad 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -344,7 +344,7 @@ class MessageDict(collections.OrderedDict): such guarantee for outgoing messages. """ - def __init__(self, message, items=None): + def __init__(self, message, items: Union[dict, None]=None): assert message is None or isinstance(message, Message) if items is None: @@ -683,7 +683,7 @@ def __init__(self, channel, seq, command, arguments, json=None): 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. @@ -809,7 +809,7 @@ def wait_for_response(self, raise_if_failed=True)-> AssociatableMessageDict | Ex while self.response is None: self.channel._handlers_enqueued.wait() - if raise_if_failed and not self.response.success and isinstance( self.response.body, Exception): + if raise_if_failed and not self.response.success and isinstance( self.response.body, BaseException): raise self.response.body return self.response.body @@ -1297,7 +1297,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. diff --git a/src/debugpy/common/singleton.py b/src/debugpy/common/singleton.py index d515a4abf..f8da9ec3d 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.""" @@ -138,7 +142,7 @@ def __init__(self, *args, **kwargs): # ensure thread safety for the callers. @staticmethod - def assert_locked(self): + def assert_locked(self): # type: ignore lock = type(self)._lock assert lock.acquire(blocking=False), ( "ThreadSafeSingleton accessed without locking. Either use with-statement, " From 50e8d1d5698043007798dc36b128c2f2231196d8 Mon Sep 17 00:00:00 2001 From: rchiodo Date: Mon, 8 Jul 2024 17:05:45 +0000 Subject: [PATCH 04/20] Rest of the API typed --- src/debugpy/adapter/servers.py | 11 ++++---- src/debugpy/adapter/sessions.py | 9 ++---- src/debugpy/common/messaging.py | 13 +++------ src/debugpy/common/util.py | 15 ++++++++++ src/debugpy/launcher/debuggee.py | 13 +++++---- src/debugpy/launcher/output.py | 4 +-- src/debugpy/launcher/winapi.py | 4 +-- src/debugpy/server/api.py | 48 +++++++++++++++++--------------- 8 files changed, 63 insertions(+), 54 deletions(-) diff --git a/src/debugpy/adapter/servers.py b/src/debugpy/adapter/servers.py index 996784b94..77575f014 100644 --- a/src/debugpy/adapter/servers.py +++ b/src/debugpy/adapter/servers.py @@ -19,6 +19,8 @@ import traceback import io +from debugpy.common.util import WaitForTimeout + access_token = None """Access token used to authenticate with the servers.""" @@ -429,14 +431,11 @@ def wait_for_connection(session, predicate, timeout: Union[float, None]=None): If there is more than one server connection already available, returns the oldest one. """ - def wait_for_timeout(): - if timeout is not None: - time.sleep(timeout) - setattr(wait_for_timeout, "timed_out", True) + def after_wait(): with _lock: _connections_changed.set() + wait_for_timeout = WaitForTimeout(timeout, after_wait) - setattr(wait_for_timeout, "timed_out", timeout == 0) if timeout: thread = threading.Thread( target=wait_for_timeout, name="servers.wait_for_connection() timeout" @@ -451,7 +450,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 getattr(wait_for_timeout, "timed_out") is True: + if conn is not None or wait_for_timeout.timed_out: return conn _connections_changed.wait() diff --git a/src/debugpy/adapter/sessions.py b/src/debugpy/adapter/sessions.py index 0eaeb7503..1a3e75fb7 100644 --- a/src/debugpy/adapter/sessions.py +++ b/src/debugpy/adapter/sessions.py @@ -113,12 +113,7 @@ def wait_for(self, predicate, timeout: Union[float, None]=None): seconds regardless of whether the predicate was satisfied. The method returns False if it timed out, and True otherwise. """ - - def wait_for_timeout(): - if timeout is not None: - time.sleep(timeout) - setattr(wait_for_timeout, "timed_out", True) - self.notify_changed() + wait_for_timeout = util.WaitForTimeout(timeout, lambda: self.notify_changed()) if timeout is not None: thread = threading.Thread( @@ -129,7 +124,7 @@ def wait_for_timeout(): with self: while not predicate(): - if hasattr(wait_for_timeout, "timed_out"): + if wait_for_timeout.timed_out: return False self._changed_condition.wait() return True diff --git a/src/debugpy/common/messaging.py b/src/debugpy/common/messaging.py index 3429040ad..c7544c2fb 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -344,7 +344,7 @@ class MessageDict(collections.OrderedDict): such guarantee for outgoing messages. """ - def __init__(self, message, items: Union[dict, None]=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: @@ -429,7 +429,7 @@ def wrap(self, key, *args, **kwargs): class AssociatableMessageDict(MessageDict): def associate_with(self, message: Message): - ... + self.message = message def is_associatable(obj) -> TypeIs[AssociatableMessageDict]: return isinstance(obj, MessageDict) and hasattr(obj, "associate_with") @@ -448,12 +448,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) - setattr(value, "associate_with", associate_with) + value = AssociatableMessageDict(None) return value @@ -1373,7 +1368,7 @@ 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 = AssociatableMessageDict(None, d) if "seq" in d: self._prettify(d) setattr(d, "associate_with", associate_with) diff --git a/src/debugpy/common/util.py b/src/debugpy/common/util.py index e332f1f41..680c9ed6a 100644 --- a/src/debugpy/common/util.py +++ b/src/debugpy/common/util.py @@ -5,6 +5,9 @@ import inspect import os import sys +import threading +import time +from typing import Callable, Union def evaluate(code, path=__file__, mode="eval"): @@ -162,3 +165,15 @@ def hide_thread_from_debugger(thread): if hide_debugpy_internals(): thread.pydev_do_not_trace = True thread.is_pydev_daemon_thread = True + +class WaitForTimeout(): + def __init__(self, timeout: Union[float, None], func: Callable[[], None]): + self._func = func + self._timeout = timeout + self.timed_out = False + + def __call__(self): + if self._timeout is not None: + time.sleep(self._timeout) + self.timed_out = True + self._func() diff --git a/src/debugpy/launcher/debuggee.py b/src/debugpy/launcher/debuggee.py index 2d8528815..4b6ac87c1 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 = {} @@ -88,7 +91,7 @@ def preexec_fn(): try: global process - process = subprocess.Popen(cmdline, env=env, bufsize=0, **kwargs) + process = subprocess.Popen(cmdline, env=env, bufsize=0, **kwargs) # type: ignore except Exception as exc: raise messaging.MessageHandlingError( "Couldn't spawn debuggee: {0}\n\nCommand line:{1!r}".format( @@ -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() # type: ignore 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..e65d3ee90 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() # type: ignore else: return result return impl -kernel32 = ctypes.windll.kernel32 +kernel32 = ctypes.windll.kernel32 # type: ignore kernel32.AssignProcessToJobObject.errcheck = _errcheck() kernel32.AssignProcessToJobObject.restype = BOOL diff --git a/src/debugpy/server/api.py b/src/debugpy/server/api.py index 8fa8767a1..e4fe276e9 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: # type: ignore return - ensure_logging.ensured = True + ensure_logging.ensured = True # type: ignore 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 # type: ignore 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: @@ -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() - 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: From 063bb6a216b0499a51870791ef976f81546cb1af Mon Sep 17 00:00:00 2001 From: rchiodo Date: Mon, 8 Jul 2024 17:07:03 +0000 Subject: [PATCH 05/20] Put back wait_for_client variable --- src/debugpy/server/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/debugpy/server/api.py b/src/debugpy/server/api.py index e4fe276e9..f1698142b 100644 --- a/src/debugpy/server/api.py +++ b/src/debugpy/server/api.py @@ -300,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()") @@ -317,7 +317,7 @@ def __call__(self): def cancel() -> None: raise RuntimeError("wait_for_client() must be called first") - +wait_for_client = wait_for_client_cls() def is_client_connected(): return pydevd._is_attached() From 5dd7fe91c54236d3d60d0aa01af0d626259a2fd7 Mon Sep 17 00:00:00 2001 From: rchiodo Date: Mon, 8 Jul 2024 17:40:24 +0000 Subject: [PATCH 06/20] Update test requirements --- CONTRIBUTING.md | 2 +- src/debugpy/common/util.py | 2 +- tests/requirements.txt | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d2bead83..f3f4b09a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/src/debugpy/common/util.py b/src/debugpy/common/util.py index 680c9ed6a..219c85260 100644 --- a/src/debugpy/common/util.py +++ b/src/debugpy/common/util.py @@ -175,5 +175,5 @@ def __init__(self, timeout: Union[float, None], func: Callable[[], None]): def __call__(self): if self._timeout is not None: time.sleep(self._timeout) - self.timed_out = True + self.timed_out = True self._func() diff --git a/tests/requirements.txt b/tests/requirements.txt index 12784fe35..a55e80aac 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -9,6 +9,7 @@ pytest-timeout importlib_metadata psutil +untangle ## Used in Python code that is run/debugged by the tests: From 3ee1940f553267e0cf8070a0321cc5d93616378c Mon Sep 17 00:00:00 2001 From: rchiodo Date: Mon, 8 Jul 2024 18:03:34 +0000 Subject: [PATCH 07/20] Fix converter --- src/debugpy/common/json.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/debugpy/common/json.py b/src/debugpy/common/json.py index 2de77acaf..91a9a98d1 100644 --- a/src/debugpy/common/json.py +++ b/src/debugpy/common/json.py @@ -94,12 +94,12 @@ def __format__(self, format_spec): # some substitutions - e.g. replacing () with some default value. -def _converter(value: str, classinfo) -> Any: +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) # pyright: ignore + return one_info(value) except ValueError: pass From 931d83cfc8f44c96d4e4221b579000757e35db5e Mon Sep 17 00:00:00 2001 From: rchiodo Date: Wed, 24 Jul 2024 21:47:56 +0000 Subject: [PATCH 08/20] Fix linter --- src/debugpy/adapter/clients.py | 2 +- src/debugpy/adapter/sessions.py | 3 +-- src/debugpy/common/json.py | 1 - src/debugpy/common/log.py | 2 +- src/debugpy/common/messaging.py | 3 +-- src/debugpy/common/util.py | 1 - 6 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index afd9257b5..03a14db89 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -219,7 +219,7 @@ def handle(self, request: messaging.Message): if self.session.no_debug: servers.dont_wait_for_first_connection() - request_options = request("debugOptions", json.array(str)) + request("debugOptions", json.array(str)) self.session.debug_options = debug_options = set( ) diff --git a/src/debugpy/adapter/sessions.py b/src/debugpy/adapter/sessions.py index 1a3e75fb7..7b17b93ae 100644 --- a/src/debugpy/adapter/sessions.py +++ b/src/debugpy/adapter/sessions.py @@ -6,8 +6,7 @@ import os import signal import threading -import time -from typing import Union, cast +from typing import Union from debugpy import common from debugpy.common import log, util diff --git a/src/debugpy/common/json.py b/src/debugpy/common/json.py index 91a9a98d1..3c6f8819f 100644 --- a/src/debugpy/common/json.py +++ b/src/debugpy/common/json.py @@ -7,7 +7,6 @@ import builtins import json -import numbers import operator from typing import Any, Callable, Literal, Tuple, Union diff --git a/src/debugpy/common/log.py b/src/debugpy/common/log.py index f35db8b04..343cf50f2 100644 --- a/src/debugpy/common/log.py +++ b/src/debugpy/common/log.py @@ -12,7 +12,7 @@ import sys import threading import traceback -from typing import Any, Callable, NoReturn, Protocol, Union +from typing import Any, NoReturn, Protocol, Union from typing_extensions import TypeIs import debugpy diff --git a/src/debugpy/common/messaging.py b/src/debugpy/common/messaging.py index c7544c2fb..e53821cd1 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -20,12 +20,11 @@ import socket import sys import threading -from typing import Any, BinaryIO, Callable, Literal, Protocol, Union, cast +from typing import BinaryIO, Callable, Union, cast from typing_extensions import TypeIs from debugpy.common import json, log, util from debugpy.common.util import hide_thread_from_debugger -from winappdbg.compat import b class JsonIOError(IOError): diff --git a/src/debugpy/common/util.py b/src/debugpy/common/util.py index 219c85260..b87d7640d 100644 --- a/src/debugpy/common/util.py +++ b/src/debugpy/common/util.py @@ -5,7 +5,6 @@ import inspect import os import sys -import threading import time from typing import Callable, Union From 7a1350a740a2c87b70c95aed8ba0f3e4513a8ea7 Mon Sep 17 00:00:00 2001 From: rchiodo Date: Wed, 24 Jul 2024 22:01:56 +0000 Subject: [PATCH 09/20] Fix messaging error --- src/debugpy/common/messaging.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/debugpy/common/messaging.py b/src/debugpy/common/messaging.py index e53821cd1..fa9bfe7b4 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -20,7 +20,7 @@ import socket import sys import threading -from typing import BinaryIO, Callable, Union, cast +from typing import BinaryIO, Callable, Union, cast, Any from typing_extensions import TypeIs from debugpy.common import json, log, util @@ -408,8 +408,7 @@ def __call__(self, key, validate, optional=False): raise message.isnt_valid("{0}{1}", json.repr(key), err) return value - @staticmethod - def _invalid_if_no_key(func): + def _invalid_if_no_key(func: Callable[..., Any]): # type: ignore def wrap(self, key, *args, **kwargs): try: return func(self, key, *args, **kwargs) From 8ce6670e156b4262ede4122e3288c135074c605a Mon Sep 17 00:00:00 2001 From: rchiodo Date: Wed, 24 Jul 2024 22:40:47 +0000 Subject: [PATCH 10/20] Review feedback and fixup more types --- src/debugpy/adapter/clients.py | 8 ++++---- src/debugpy/adapter/servers.py | 2 +- src/debugpy/common/messaging.py | 35 ++++++++++++++++++-------------- src/debugpy/common/singleton.py | 4 ++-- src/debugpy/common/sockets.py | 4 ++-- src/debugpy/launcher/debuggee.py | 4 ++-- src/debugpy/launcher/winapi.py | 4 ++-- src/debugpy/server/api.py | 6 +++--- 8 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index 03a14db89..84084dd10 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -219,9 +219,9 @@ def handle(self, request: messaging.Message): if self.session.no_debug: servers.dont_wait_for_first_connection() - request("debugOptions", json.array(str)) + request_options = request("debugOptions", json.array(str)) self.session.debug_options = debug_options = set( - + request_options ) f(self, request) @@ -656,7 +656,7 @@ def debugpySystemInfo_request(self, request): result = {"debugpy": {"version": debugpy.__version__}} if self.server: try: - pydevd_info: messaging.AssociatableMessageDict = 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. @@ -777,7 +777,7 @@ def notify_of_subprocess(self, conn): def serve(host, port): global listener - listener = sockets.serve("Client", Client, host, port) # type: ignore + listener = sockets.serve("Client", Client, host, port) sessions.report_sockets() return listener.getsockname() diff --git a/src/debugpy/adapter/servers.py b/src/debugpy/adapter/servers.py index 77575f014..219a85a66 100644 --- a/src/debugpy/adapter/servers.py +++ b/src/debugpy/adapter/servers.py @@ -399,7 +399,7 @@ def disconnect(self): def serve(host="127.0.0.1", port=0): global listener - listener = sockets.serve("Server", Connection, host, port) # type: ignore + listener = sockets.serve("Server", Connection, host, port) sessions.report_sockets() return listener.getsockname() diff --git a/src/debugpy/common/messaging.py b/src/debugpy/common/messaging.py index fa9bfe7b4..39e46ddfd 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -408,7 +408,7 @@ def __call__(self, key, validate, optional=False): raise message.isnt_valid("{0}{1}", json.repr(key), err) return value - def _invalid_if_no_key(func: Callable[..., Any]): # type: ignore + def _invalid_if_no_key(func: Callable[..., Any]): # pyright: ignore[reportSelfClsParameterName] def wrap(self, key, *args, **kwargs): try: return func(self, key, *args, **kwargs) @@ -425,11 +425,11 @@ def wrap(self, key, *args, **kwargs): del _invalid_if_no_key -class AssociatableMessageDict(MessageDict): +class AssociableMessageDict(MessageDict): def associate_with(self, message: Message): self.message = message -def is_associatable(obj) -> TypeIs[AssociatableMessageDict]: +def is_associable(obj) -> TypeIs[AssociableMessageDict]: return isinstance(obj, MessageDict) and hasattr(obj, "associate_with") def _payload(value): @@ -446,7 +446,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(). - value = AssociatableMessageDict(None) + value = AssociableMessageDict(None) return value @@ -471,7 +471,7 @@ def __init__(self, channel, seq, json=None): """ def __str__(self): - return json.repr(self.json).__str__() 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. @@ -483,14 +483,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): @@ -573,12 +576,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 is_associatable(body): + if is_associable(body): body.associate_with(self) self.body = body @@ -667,12 +670,12 @@ 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 is_associatable(arguments): + if is_associable(arguments): arguments.associate_with(self) self.arguments = arguments @@ -787,7 +790,7 @@ def __init__(self, channel, seq, command, arguments): def describe(self): return f"{self.seq} request {json.repr(self.command)} to {self.channel}" - def wait_for_response(self, raise_if_failed=True)-> AssociatableMessageDict | Exception: + 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. @@ -804,6 +807,8 @@ def wait_for_response(self, raise_if_failed=True)-> AssociatableMessageDict | Ex 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): @@ -889,13 +894,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 is_associatable(body): + if is_associable(body): body.associate_with(self) self.body = body """Body of the response if the request was successful, or an instance @@ -1366,7 +1371,7 @@ 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 = AssociatableMessageDict(None, d) + d = AssociableMessageDict(None, d) if "seq" in d: self._prettify(d) setattr(d, "associate_with", associate_with) diff --git a/src/debugpy/common/singleton.py b/src/debugpy/common/singleton.py index f8da9ec3d..f3f9abf85 100644 --- a/src/debugpy/common/singleton.py +++ b/src/debugpy/common/singleton.py @@ -141,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): # type: ignore + 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 7b26026ae..202bbbe7f 100644 --- a/src/debugpy/common/sockets.py +++ b/src/debugpy/common/sockets.py @@ -5,7 +5,7 @@ import socket import sys import threading -from typing import Callable, Union +from typing import Any, Callable, Union from debugpy.common import log from debugpy.common.util import hide_thread_from_debugger @@ -89,7 +89,7 @@ def close_socket(sock): sock.close() -def serve(name: str, handler: Callable[[socket.socket], None], host: str, port: int=0, backlog=socket.SOMAXCONN, timeout: Union[int, None]=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. diff --git a/src/debugpy/launcher/debuggee.py b/src/debugpy/launcher/debuggee.py index 4b6ac87c1..e5dc72062 100644 --- a/src/debugpy/launcher/debuggee.py +++ b/src/debugpy/launcher/debuggee.py @@ -91,7 +91,7 @@ def preexec_fn(): try: global process - process = subprocess.Popen(cmdline, env=env, bufsize=0, **kwargs) # type: ignore + process = subprocess.Popen(cmdline, env=env, bufsize=0, **kwargs) except Exception as exc: raise messaging.MessageHandlingError( "Couldn't spawn debuggee: {0}\n\nCommand line:{1!r}".format( @@ -244,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() # type: ignore + 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/winapi.py b/src/debugpy/launcher/winapi.py index e65d3ee90..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() # type: ignore + raise ctypes.WinError() # pyright: ignore[reportAttributeAccessIssue] else: return result return impl -kernel32 = ctypes.windll.kernel32 # type: ignore +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 f1698142b..6be27cf81 100644 --- a/src/debugpy/server/api.py +++ b/src/debugpy/server/api.py @@ -54,16 +54,16 @@ def __new__(cls, *args, **kwargs): def ensure_logging(): """Starts logging to log.log_dir, if it hasn't already been done.""" - if ensure_logging.ensured: # type: ignore + if ensure_logging.ensured: # pyright: ignore[reportFunctionMemberAccess] return - ensure_logging.ensured = True # type: ignore + 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 # type: ignore +ensure_logging.ensured = False # pyright: ignore[reportFunctionMemberAccess] def log_to(path): From 4d609d2683c81abb1817849fda6ebbc6f7fe6505 Mon Sep 17 00:00:00 2001 From: rchiodo Date: Wed, 24 Jul 2024 22:45:59 +0000 Subject: [PATCH 11/20] fix request_options --- src/debugpy/adapter/clients.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index 84084dd10..bab408146 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -8,7 +8,7 @@ import os import socket import sys -from typing import Literal, Union +from typing import Any, Literal, Union, cast import debugpy from debugpy import adapter, common, launcher @@ -219,7 +219,7 @@ def handle(self, request: messaging.Message): if self.session.no_debug: servers.dont_wait_for_first_connection() - request_options = request("debugOptions", json.array(str)) + request_options: list[Any] = cast("list[Any]", request("debugOptions", json.array(str))) self.session.debug_options = debug_options = set( request_options ) From fddf0de4fb805849fc662fd153c06a05a3e2ad9f Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Wed, 24 Jul 2024 17:34:23 -0700 Subject: [PATCH 12/20] Fix debug output and put wait back the way it was --- CONTRIBUTING.md | 2 +- pyproject.toml | 1 + src/debugpy/adapter/servers.py | 11 ++++++----- src/debugpy/adapter/sessions.py | 13 ++++++++++--- src/debugpy/common/log.py | 10 +++++++--- src/debugpy/common/messaging.py | 9 ++++++--- src/debugpy/common/util.py | 11 ----------- tests/logs.py | 17 ++++++++++++++++- tests/pytest_hooks.py | 5 ++--- tests/requirements.txt | 1 + 10 files changed, 50 insertions(+), 30 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3f4b09a1..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 ``` diff --git a/pyproject.toml b/pyproject.toml index 3addae674..b2011ad76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ 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/servers.py b/src/debugpy/adapter/servers.py index 219a85a66..b581d4e35 100644 --- a/src/debugpy/adapter/servers.py +++ b/src/debugpy/adapter/servers.py @@ -19,8 +19,6 @@ import traceback import io -from debugpy.common.util import WaitForTimeout - access_token = None """Access token used to authenticate with the servers.""" @@ -431,11 +429,14 @@ def wait_for_connection(session, predicate, timeout: Union[float, None]=None): If there is more than one server connection already available, returns the oldest one. """ - def after_wait(): + def wait_for_timeout(): + 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 = WaitForTimeout(timeout, after_wait) + 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" @@ -450,7 +451,7 @@ def after_wait(): _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() diff --git a/src/debugpy/adapter/sessions.py b/src/debugpy/adapter/sessions.py index 7b17b93ae..5b3ea748d 100644 --- a/src/debugpy/adapter/sessions.py +++ b/src/debugpy/adapter/sessions.py @@ -6,6 +6,7 @@ import os import signal import threading +import time from typing import Union from debugpy import common @@ -112,8 +113,14 @@ def wait_for(self, predicate, timeout: Union[float, None]=None): seconds regardless of whether the predicate was satisfied. The method returns False if it timed out, and True otherwise. """ - wait_for_timeout = util.WaitForTimeout(timeout, lambda: self.notify_changed()) - + def wait_for_timeout(): + 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 # pyright: ignore[reportFunctionMemberAccess] + if timeout is not None: thread = threading.Thread( target=wait_for_timeout, name="Session.wait_for() timeout" @@ -123,7 +130,7 @@ def wait_for(self, predicate, timeout: Union[float, None]=None): 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 diff --git a/src/debugpy/common/log.py b/src/debugpy/common/log.py index 343cf50f2..d52c92951 100644 --- a/src/debugpy/common/log.py +++ b/src/debugpy/common/log.py @@ -12,8 +12,12 @@ import sys import threading import traceback -from typing import Any, NoReturn, Protocol, Union -from typing_extensions import TypeIs +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 @@ -283,7 +287,7 @@ def prefixed(format_string, *args, **kwargs): class HasName(Protocol): name: str -def has_name(obj: Any) -> TypeIs[HasName]: +def has_name(obj: Any) -> "TypeIs[HasName]": try: return hasattr(obj, "name") except NameError: diff --git a/src/debugpy/common/messaging.py b/src/debugpy/common/messaging.py index 39e46ddfd..a04282323 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -20,8 +20,11 @@ import socket import sys import threading -from typing import BinaryIO, Callable, Union, cast, Any -from typing_extensions import TypeIs +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 @@ -429,7 +432,7 @@ class AssociableMessageDict(MessageDict): def associate_with(self, message: Message): self.message = message -def is_associable(obj) -> TypeIs[AssociableMessageDict]: +def is_associable(obj) -> "TypeIs[AssociableMessageDict]": return isinstance(obj, MessageDict) and hasattr(obj, "associate_with") def _payload(value): diff --git a/src/debugpy/common/util.py b/src/debugpy/common/util.py index b87d7640d..bc519320a 100644 --- a/src/debugpy/common/util.py +++ b/src/debugpy/common/util.py @@ -165,14 +165,3 @@ def hide_thread_from_debugger(thread): thread.pydev_do_not_trace = True thread.is_pydev_daemon_thread = True -class WaitForTimeout(): - def __init__(self, timeout: Union[float, None], func: Callable[[], None]): - self._func = func - self._timeout = timeout - self.timed_out = False - - def __call__(self): - if self._timeout is not None: - time.sleep(self._timeout) - self.timed_out = True - self._func() diff --git a/tests/logs.py b/tests/logs.py index 7d3089cad..3b7f6d74e 100644 --- a/tests/logs.py +++ b/tests/logs.py @@ -4,12 +4,27 @@ import io import os +import shutil import pytest_timeout 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 +42,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 5b8a498cd..7d48234e2 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -19,3 +19,4 @@ flask gevent numpy requests +typing_extensions \ No newline at end of file From c02affd4cf75063ff113c212389238e2896fb5c8 Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Thu, 25 Jul 2024 10:22:39 -0700 Subject: [PATCH 13/20] Skip importing sessions unless type checking --- src/debugpy/adapter/components.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/debugpy/adapter/components.py b/src/debugpy/adapter/components.py index ed01b6e71..28d227217 100644 --- a/src/debugpy/adapter/components.py +++ b/src/debugpy/adapter/components.py @@ -3,9 +3,12 @@ # for license information. import functools -from typing import Type, TypeVar, Union, cast +from typing import TYPE_CHECKING, Type, TypeVar, Union, cast -from debugpy.adapter.sessions import Session +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 From bfd9b88554b425f73dc0351a925723c21002ce92 Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Thu, 25 Jul 2024 10:35:34 -0700 Subject: [PATCH 14/20] Hide types to prevent runtime issue --- src/debugpy/adapter/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/debugpy/adapter/components.py b/src/debugpy/adapter/components.py index 28d227217..73e06c207 100644 --- a/src/debugpy/adapter/components.py +++ b/src/debugpy/adapter/components.py @@ -36,7 +36,7 @@ class Component(util.Observable): to wait_for() a change caused by another component. """ - def __init__(self, session: Session, stream: Union[messaging.JsonIOStream, None]=None, channel: Union[messaging.JsonMessageChannel, None]=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: From 046fe6faee642966dcf86dee7214cf369095d044 Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Thu, 25 Jul 2024 10:39:43 -0700 Subject: [PATCH 15/20] Fix linter --- src/debugpy/common/util.py | 2 -- tests/logs.py | 1 - 2 files changed, 3 deletions(-) diff --git a/src/debugpy/common/util.py b/src/debugpy/common/util.py index bc519320a..329fad95f 100644 --- a/src/debugpy/common/util.py +++ b/src/debugpy/common/util.py @@ -5,8 +5,6 @@ import inspect import os import sys -import time -from typing import Callable, Union def evaluate(code, path=__file__, mode="eval"): diff --git a/tests/logs.py b/tests/logs.py index 3b7f6d74e..5d1c63c8a 100644 --- a/tests/logs.py +++ b/tests/logs.py @@ -5,7 +5,6 @@ import io import os import shutil -import pytest_timeout import sys from debugpy.common import json, log From 019652238860475e90fa38996ac32708afc2f1ef Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Thu, 25 Jul 2024 12:45:56 -0700 Subject: [PATCH 16/20] Remove inappropriate static method --- src/debugpy/adapter/clients.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index bab408146..126a581d2 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -205,7 +205,6 @@ 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. - @staticmethod def _start_message_handler(f): @components.Component.message_handler def handle(self, request: messaging.Message): From 6e3bde7708af82e2ea16caa0d7e4f865e47a18f5 Mon Sep 17 00:00:00 2001 From: rchiodo Date: Thu, 25 Jul 2024 21:19:01 +0000 Subject: [PATCH 17/20] Fix linux failures --- src/debugpy/adapter/clients.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index 126a581d2..238d5dc7b 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -6,15 +6,17 @@ import atexit import os -import socket import sys -from typing import Any, Literal, Union, cast +from typing import TYPE_CHECKING, Any, Literal, 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 +if TYPE_CHECKING: + # socket can't be imported at runtime on linux, causes runtime issues. + import socket class Client(components.Component): """Handles the client side of a debug session.""" @@ -43,7 +45,7 @@ class Expectations(components.Capabilities): "pathFormat": json.enum("path", optional=True), # we don't support "uri" } - def __init__(self, sock: Union[Literal["stdio"], socket.socket]): + def __init__(self, sock: "Union[Literal[\"stdio\"], socket.socket]"): if sock == "stdio": log.info("Connecting to client over stdio...", self) self.using_stdio = True From 8b8467a69e9dbc2b13625a6d25058740d92c1ffa Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Thu, 25 Jul 2024 14:21:43 -0700 Subject: [PATCH 18/20] Fix type errors --- src/debugpy/adapter/clients.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index 238d5dc7b..9d01130b1 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -7,7 +7,7 @@ import atexit import os import sys -from typing import TYPE_CHECKING, Any, Literal, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Literal, Union, cast import debugpy from debugpy import adapter, common, launcher @@ -207,7 +207,7 @@ 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: messaging.Message): assert request.is_request("launch", "attach") From 2ce0c698401b0ad0af78f605e95de7cbe0bc4eda Mon Sep 17 00:00:00 2001 From: rchiodo Date: Thu, 25 Jul 2024 21:41:31 +0000 Subject: [PATCH 19/20] Cannot reference socket even in types --- src/debugpy/adapter/clients.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index 238d5dc7b..83384da48 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -14,10 +14,6 @@ from debugpy.common import json, log, messaging, sockets from debugpy.adapter import clients, components, launchers, servers, sessions -if TYPE_CHECKING: - # socket can't be imported at runtime on linux, causes runtime issues. - import socket - class Client(components.Component): """Handles the client side of a debug session.""" @@ -45,7 +41,7 @@ class Expectations(components.Capabilities): "pathFormat": json.enum("path", optional=True), # we don't support "uri" } - def __init__(self, sock: "Union[Literal[\"stdio\"], socket.socket]"): + def __init__(self, sock): if sock == "stdio": log.info("Connecting to client over stdio...", self) self.using_stdio = True From 9c7c06b319ffdf3cabc586b20b2d8a75853c2d2a Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Thu, 25 Jul 2024 14:46:00 -0700 Subject: [PATCH 20/20] Fix linter again --- src/debugpy/adapter/clients.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index 707a261c1..69a3fe77f 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -7,7 +7,7 @@ import atexit import os import sys -from typing import TYPE_CHECKING, Any, Callable, Literal, Union, cast +from typing import Any, Callable, Union, cast import debugpy from debugpy import adapter, common, launcher