Skip to content

Refactor async_context_manager type annotations using ParamSpec - #828

Open
junyilou wants to merge 1 commit into
ronf:developfrom
junyilou:develop
Open

Refactor async_context_manager type annotations using ParamSpec#828
junyilou wants to merge 1 commit into
ronf:developfrom
junyilou:develop

Conversation

@junyilou

Copy link
Copy Markdown

Refactored the type annotations for the async_context_manager decorator in asyncssh/misc.py by replacing Callable[..., ...] with ParamSpec (_ACMParam). By capturing parameter types with ParamSpec, static analysis tools and IDEs now accurately inspect and auto-complete the original function's arguments (names, types, and defaults) when calling decorated functions, significantly improving type safety and developer experience.

typing.ParamSpec was introduced in Python 3.10, which aligns with the project's target Python version.

Updated async_context_manager function to use ParamSpec for better type hinting.
@ronf

ronf commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Thanks for this PR! I experimented with ParamSpec early on, but never could get it working the way I wanted to. Also, as you pointed out, ParamSpec is now a part of Python 3.10 which is the minimum supported version by AsyncSSH, so this is a good time to revisit this.

Unfortunately, I'm still seeing errors from mypy after making this change:

asyncssh/sftp.py:4227: error: Argument 1 to "_begin_copy" of "SFTPClient" has incompatible type "SFTPClient"; expected "_SFTPFSProtocol"  [arg-type]
asyncssh/sftp.py:4227: note: Following member(s) of "SFTPClient" have conflicts:
asyncssh/sftp.py:4227: note:     Expected:
asyncssh/sftp.py:4227: note:         def open(path: bytes, mode: str, block_size: int = ...) -> _ACMWrapper[SFTPFileProtocol]
asyncssh/sftp.py:4227: note:     Got:
asyncssh/sftp.py:4227: note:         def open(path: bytes | str | PurePath, pflags_or_mode: int | str = ..., attrs: SFTPAttrs = ..., encoding: str | None = ..., errors: str = ..., block_size: int = ..., max_requests: int = ...) -> _ACMWrapper[SFTPClientFile]
asyncssh/sftp.py:4338: error: Argument 2 to "_begin_copy" of "SFTPClient" has incompatible type "SFTPClient"; expected "_SFTPFSProtocol"  [arg-type]
asyncssh/sftp.py:4338: note: Following member(s) of "SFTPClient" have conflicts:
asyncssh/sftp.py:4338: note:     Expected:
asyncssh/sftp.py:4338: note:         def open(path: bytes, mode: str, block_size: int = ...) -> _ACMWrapper[SFTPFileProtocol]
asyncssh/sftp.py:4338: note:     Got:
asyncssh/sftp.py:4338: note:         def open(path: bytes | str | PurePath, pflags_or_mode: int | str = ..., attrs: SFTPAttrs = ..., encoding: str | None = ..., errors: str = ..., block_size: int = ..., max_requests: int = ...) -> _ACMWrapper[SFTPClientFile]
asyncssh/sftp.py:4453: error: Argument 1 to "_begin_copy" of "SFTPClient" has incompatible type "SFTPClient"; expected "_SFTPFSProtocol"  [arg-type]
asyncssh/sftp.py:4453: note: Following member(s) of "SFTPClient" have conflicts:
asyncssh/sftp.py:4453: note:     Expected:
asyncssh/sftp.py:4453: note:         def open(path: bytes, mode: str, block_size: int = ...) -> _ACMWrapper[SFTPFileProtocol]
asyncssh/sftp.py:4453: note:     Got:
asyncssh/sftp.py:4453: note:         def open(path: bytes | str | PurePath, pflags_or_mode: int | str = ..., attrs: SFTPAttrs = ..., encoding: str | None = ..., errors: str = ..., block_size: int = ..., max_requests: int = ...) -> _ACMWrapper[SFTPClientFile]
asyncssh/sftp.py:4453: error: Argument 2 to "_begin_copy" of "SFTPClient" has incompatible type "SFTPClient"; expected "_SFTPFSProtocol"  [arg-type]
asyncssh/sftp.py:4453: note: Following member(s) of "SFTPClient" have conflicts:
asyncssh/sftp.py:4453: note:     Expected:
asyncssh/sftp.py:4453: note:         def open(path: bytes, mode: str, block_size: int = ...) -> _ACMWrapper[SFTPFileProtocol]
asyncssh/sftp.py:4453: note:     Got:
asyncssh/sftp.py:4453: note:         def open(path: bytes | str | PurePath, pflags_or_mode: int | str = ..., attrs: SFTPAttrs = ..., encoding: str | None = ..., errors: str = ..., block_size: int = ..., max_requests: int = ...) -> _ACMWrapper[SFTPClientFile]
asyncssh/sftp.py:4476: error: Argument 1 to "_begin_copy" of "SFTPClient" has incompatible type "SFTPClient"; expected "_SFTPFSProtocol"  [arg-type]
asyncssh/sftp.py:4476: note: Following member(s) of "SFTPClient" have conflicts:
asyncssh/sftp.py:4476: note:     Expected:
asyncssh/sftp.py:4476: note:         def open(path: bytes, mode: str, block_size: int = ...) -> _ACMWrapper[SFTPFileProtocol]
asyncssh/sftp.py:4476: note:     Got:
asyncssh/sftp.py:4476: note:         def open(path: bytes | str | PurePath, pflags_or_mode: int | str = ..., attrs: SFTPAttrs = ..., encoding: str | None = ..., errors: str = ..., block_size: int = ..., max_requests: int = ...) -> _ACMWrapper[SFTPClientFile]
asyncssh/sftp.py:4499: error: Argument 2 to "_begin_copy" of "SFTPClient" has incompatible type "SFTPClient"; expected "_SFTPFSProtocol"  [arg-type]
asyncssh/sftp.py:4499: note: Following member(s) of "SFTPClient" have conflicts:
asyncssh/sftp.py:4499: note:     Expected:
asyncssh/sftp.py:4499: note:         def open(path: bytes, mode: str, block_size: int = ...) -> _ACMWrapper[SFTPFileProtocol]
asyncssh/sftp.py:4499: note:     Got:
asyncssh/sftp.py:4499: note:         def open(path: bytes | str | PurePath, pflags_or_mode: int | str = ..., attrs: SFTPAttrs = ..., encoding: str | None = ..., errors: str = ..., block_size: int = ..., max_requests: int = ...) -> _ACMWrapper[SFTPClientFile]
asyncssh/sftp.py:4523: error: Argument 1 to "_begin_copy" of "SFTPClient" has incompatible type "SFTPClient"; expected "_SFTPFSProtocol"  [arg-type]
asyncssh/sftp.py:4523: note: Following member(s) of "SFTPClient" have conflicts:
asyncssh/sftp.py:4523: note:     Expected:
asyncssh/sftp.py:4523: note:         def open(path: bytes, mode: str, block_size: int = ...) -> _ACMWrapper[SFTPFileProtocol]
asyncssh/sftp.py:4523: note:     Got:
asyncssh/sftp.py:4523: note:         def open(path: bytes | str | PurePath, pflags_or_mode: int | str = ..., attrs: SFTPAttrs = ..., encoding: str | None = ..., errors: str = ..., block_size: int = ..., max_requests: int = ...) -> _ACMWrapper[SFTPClientFile]
asyncssh/sftp.py:4523: error: Argument 2 to "_begin_copy" of "SFTPClient" has incompatible type "SFTPClient"; expected "_SFTPFSProtocol"  [arg-type]
asyncssh/sftp.py:4523: note: Following member(s) of "SFTPClient" have conflicts:
asyncssh/sftp.py:4523: note:     Expected:
asyncssh/sftp.py:4523: note:         def open(path: bytes, mode: str, block_size: int = ...) -> _ACMWrapper[SFTPFileProtocol]
asyncssh/sftp.py:4523: note:     Got:
asyncssh/sftp.py:4523: note:         def open(path: bytes | str | PurePath, pflags_or_mode: int | str = ..., attrs: SFTPAttrs = ..., encoding: str | None = ..., errors: str = ..., block_size: int = ..., max_requests: int = ...) -> _ACMWrapper[SFTPClientFile]
asyncssh/connection.py:5201: error: Argument "tunnel" to "listen_reverse" has incompatible type "SSHClientConnection"; expected "tuple[()] | str | _TunnelListenerProtocol | None"  [arg-type]
asyncssh/connection.py:5599: error: Argument 1 to "create_server" of "SSHClientConnection" has incompatible type "Callable[[str, int], Awaitable[SSHUNIXSession[Any]]]"; expected "Callable[[str, int], SSHTCPSession[str] | Awaitable[SSHTCPSession[str]]]"  [arg-type]
asyncssh/connection.py:5640: error: Argument 1 to "create_unix_server" of "SSHClientConnection" has incompatible type "Callable[[], Awaitable[SSHTCPSession[bytes]]]"; expected "Callable[[], SSHUNIXSession[str] | Awaitable[SSHUNIXSession[str]]]"  [arg-type]
Generated HTML report (via XSLT): /tmp/cov/index.html
Found 11 errors in 2 files (checked 67 source files)

I still need to go through these errors in detail -- it's possible some of the Protocol classes need to be adjusted now that we're doing stricter type checking on these ACM types. If you see a way to address these, please let me know!

@junyilou

Copy link
Copy Markdown
Author

Yes, as mentioned, because we're doing stricter type checking now, I think the mypy error in sftp.py, for example, is actually exposing an existing inconsistency in _SFTPFSProtocol.

In _SFTPFSProtocol, open() is currently declared as:

@async_context_manager
async def open(self, path: bytes, mode: str,
               block_size: int = -1) -> SFTPFileProtocol:
    ...

This means that an implementation of _SFTPFSProtocol must support calls such as:

fs.open(path, mode, block_size)

where the third positional argument is block_size.

However, SFTPClient.open() is defined as:

@async_context_manager
async def open(self, path: _SFTPPath,
               pflags_or_mode: Union[int, str] = FXF_READ,
               attrs: SFTPAttrs = SFTPAttrs(),
               encoding: Optional[str] = 'utf-8',
               errors: str = 'strict',
               block_size: int = -1,
               max_requests: int = -1) -> SFTPClientFile:
    ...

So SFTPClient is not actually structurally compatible with _SFTPFSProtocol.

In particular, this is not only a difference in parameter names (pflags_or_mode and mode). The positional calling convention is also different:

# Required by _SFTPFSProtocol
fs.open(path, mode, block_size)
#                    ^ third positional argument = block_size

# But SFTPClient.open() interprets it as:
sftp.open(path, mode, attrs)
#                    ^ third positional argument = attrs

Previously, the async_context_manager annotation used a Callable[..., ...]-style signature, so the decorator effectively hid the original parameter list from the type checker. As a result, this mismatch was not detected. With the new ParamSpec-based annotation, the wrapper preserves the decorated function's actual signature, so mypy can finally see the incompatibility.

I haven't had the time to read through enough of the rest of the codebase to confidently suggest how this particular issue should be addressed, so I don't want to speculate about a specific fix at this point.

My main reason for raising this PR is actually the benefit of preserving the decorated function's signature in the first place. With ParamSpec, IDEs and type checkers can understand what arguments a function decorated with @async_context_manager actually accepts and what it returns. This makes the API much easier to use correctly while writing code, since things like argument names, types, and defaults remain available to static analysis and IDE completion.

I hope the existing type inconsistencies can be addressed in a way that keeps this improved type information, as I think preserving the decorated function’s signature would be a valuable improvement to the developer experience.

@junyilou

Copy link
Copy Markdown
Author

BTW, in a protocol, although having only the docstring here does not cause a runtime error, a strict type checker may infer that the function returns None, which maynot match the declared return type if it's not None. Adding Ellipsis (...) makes it clearer that this is an interface-only declaration and avoids the misleading return type:

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants