asyncssh 2.20.0__tar.gz → 2.21.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. {asyncssh-2.20.0 → asyncssh-2.21.1}/.github/workflows/run_tests.yml +12 -1
  2. {asyncssh-2.20.0/asyncssh.egg-info → asyncssh-2.21.1}/PKG-INFO +4 -3
  3. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/auth.py +12 -12
  4. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/channel.py +12 -5
  5. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/connection.py +119 -28
  6. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/forward.py +2 -1
  7. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/kex_dh.py +17 -1
  8. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/misc.py +28 -4
  9. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/public_key.py +147 -97
  10. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/scp.py +69 -30
  11. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/server.py +54 -21
  12. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/sftp.py +367 -119
  13. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/stream.py +3 -5
  14. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/version.py +1 -1
  15. {asyncssh-2.20.0 → asyncssh-2.21.1/asyncssh.egg-info}/PKG-INFO +4 -3
  16. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh.egg-info/requires.txt +1 -1
  17. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/changes.rst +59 -0
  18. {asyncssh-2.20.0 → asyncssh-2.21.1}/pyproject.toml +1 -1
  19. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_agent.py +3 -4
  20. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_auth.py +2 -2
  21. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_channel.py +16 -0
  22. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_connection.py +18 -0
  23. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_connection_auth.py +9 -6
  24. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_forward.py +63 -6
  25. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_kex.py +44 -11
  26. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_public_key.py +5 -16
  27. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_sftp.py +121 -9
  28. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_stream.py +33 -1
  29. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_tuntap.py +59 -1
  30. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/util.py +0 -2
  31. {asyncssh-2.20.0 → asyncssh-2.21.1}/.coveragerc +0 -0
  32. {asyncssh-2.20.0 → asyncssh-2.21.1}/.gitignore +0 -0
  33. {asyncssh-2.20.0 → asyncssh-2.21.1}/.readthedocs.yaml +0 -0
  34. {asyncssh-2.20.0 → asyncssh-2.21.1}/CONTRIBUTING.rst +0 -0
  35. {asyncssh-2.20.0 → asyncssh-2.21.1}/COPYRIGHT +0 -0
  36. {asyncssh-2.20.0 → asyncssh-2.21.1}/LICENSE +0 -0
  37. {asyncssh-2.20.0 → asyncssh-2.21.1}/MANIFEST.in +0 -0
  38. {asyncssh-2.20.0 → asyncssh-2.21.1}/README.rst +0 -0
  39. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/__init__.py +0 -0
  40. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/agent.py +0 -0
  41. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/agent_unix.py +0 -0
  42. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/agent_win32.py +0 -0
  43. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/asn1.py +0 -0
  44. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/auth_keys.py +0 -0
  45. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/client.py +0 -0
  46. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/compression.py +0 -0
  47. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/config.py +0 -0
  48. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/constants.py +0 -0
  49. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/__init__.py +0 -0
  50. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/chacha.py +0 -0
  51. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/cipher.py +0 -0
  52. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/dh.py +0 -0
  53. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/dsa.py +0 -0
  54. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/ec.py +0 -0
  55. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/ec_params.py +0 -0
  56. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/ed.py +0 -0
  57. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/kdf.py +0 -0
  58. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/misc.py +0 -0
  59. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/pq.py +0 -0
  60. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/rsa.py +0 -0
  61. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/umac.py +0 -0
  62. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/crypto/x509.py +0 -0
  63. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/dsa.py +0 -0
  64. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/ecdsa.py +0 -0
  65. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/eddsa.py +0 -0
  66. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/editor.py +0 -0
  67. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/encryption.py +0 -0
  68. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/gss.py +0 -0
  69. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/gss_unix.py +0 -0
  70. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/gss_win32.py +0 -0
  71. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/kex.py +0 -0
  72. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/kex_rsa.py +0 -0
  73. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/keysign.py +0 -0
  74. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/known_hosts.py +0 -0
  75. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/listener.py +0 -0
  76. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/logging.py +0 -0
  77. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/mac.py +0 -0
  78. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/packet.py +0 -0
  79. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/pattern.py +0 -0
  80. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/pbe.py +0 -0
  81. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/pkcs11.py +0 -0
  82. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/process.py +0 -0
  83. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/py.typed +0 -0
  84. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/rsa.py +0 -0
  85. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/saslprep.py +0 -0
  86. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/session.py +0 -0
  87. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/sk.py +0 -0
  88. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/sk_ecdsa.py +0 -0
  89. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/sk_eddsa.py +0 -0
  90. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/socks.py +0 -0
  91. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/subprocess.py +0 -0
  92. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/tuntap.py +0 -0
  93. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh/x11.py +0 -0
  94. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh.egg-info/SOURCES.txt +0 -0
  95. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh.egg-info/dependency_links.txt +0 -0
  96. {asyncssh-2.20.0 → asyncssh-2.21.1}/asyncssh.egg-info/top_level.txt +0 -0
  97. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/_templates/sidebarbottom.html +0 -0
  98. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/_templates/sidebartop.html +0 -0
  99. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/api.rst +0 -0
  100. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/conf.py +0 -0
  101. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/contributing.rst +0 -0
  102. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/index.rst +0 -0
  103. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/requirements.txt +0 -0
  104. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/rftheme/layout.html +0 -0
  105. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/rftheme/static/rftheme.css_t +0 -0
  106. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/rftheme/theme.conf +0 -0
  107. {asyncssh-2.20.0 → asyncssh-2.21.1}/docs/rtd-req.txt +0 -0
  108. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/callback_client.py +0 -0
  109. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/callback_client2.py +0 -0
  110. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/callback_client3.py +0 -0
  111. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/callback_math_server.py +0 -0
  112. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/chat_server.py +0 -0
  113. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/check_exit_status.py +0 -0
  114. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/chroot_sftp_server.py +0 -0
  115. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/direct_client.py +0 -0
  116. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/direct_server.py +0 -0
  117. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/editor.py +0 -0
  118. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/gather_results.py +0 -0
  119. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/listening_client.py +0 -0
  120. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/local_forwarding_client.py +0 -0
  121. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/local_forwarding_client2.py +0 -0
  122. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/local_forwarding_server.py +0 -0
  123. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/math_client.py +0 -0
  124. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/math_server.py +0 -0
  125. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/redirect_input.py +0 -0
  126. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/redirect_local_pipe.py +0 -0
  127. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/redirect_remote_pipe.py +0 -0
  128. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/redirect_server.py +0 -0
  129. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/remote_forwarding_client.py +0 -0
  130. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/remote_forwarding_client2.py +0 -0
  131. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/remote_forwarding_server.py +0 -0
  132. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/reverse_client.py +0 -0
  133. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/reverse_server.py +0 -0
  134. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/scp_client.py +0 -0
  135. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/set_environment.py +0 -0
  136. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/set_terminal.py +0 -0
  137. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/sftp_client.py +0 -0
  138. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/show_environment.py +0 -0
  139. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/show_terminal.py +0 -0
  140. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/simple_cert_server.py +0 -0
  141. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/simple_client.py +0 -0
  142. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/simple_keyed_server.py +0 -0
  143. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/simple_scp_server.py +0 -0
  144. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/simple_server.py +0 -0
  145. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/simple_sftp_server.py +0 -0
  146. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/stream_direct_client.py +0 -0
  147. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/stream_direct_server.py +0 -0
  148. {asyncssh-2.20.0 → asyncssh-2.21.1}/examples/stream_listening_client.py +0 -0
  149. {asyncssh-2.20.0 → asyncssh-2.21.1}/mypy.ini +0 -0
  150. {asyncssh-2.20.0 → asyncssh-2.21.1}/pylintrc +0 -0
  151. {asyncssh-2.20.0 → asyncssh-2.21.1}/setup.cfg +0 -0
  152. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/__init__.py +0 -0
  153. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/gss_stub.py +0 -0
  154. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/gssapi_stub.py +0 -0
  155. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/keysign_stub.py +0 -0
  156. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/pkcs11_stub.py +0 -0
  157. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/server.py +0 -0
  158. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/sk_stub.py +0 -0
  159. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/sspi_stub.py +0 -0
  160. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_asn1.py +0 -0
  161. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_auth_keys.py +0 -0
  162. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_compression.py +0 -0
  163. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_config.py +0 -0
  164. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_editor.py +0 -0
  165. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_encryption.py +0 -0
  166. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_known_hosts.py +0 -0
  167. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_logging.py +0 -0
  168. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_mac.py +0 -0
  169. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_packet.py +0 -0
  170. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_pkcs11.py +0 -0
  171. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_process.py +0 -0
  172. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_saslprep.py +0 -0
  173. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_sk.py +0 -0
  174. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_subprocess.py +0 -0
  175. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_x11.py +0 -0
  176. {asyncssh-2.20.0 → asyncssh-2.21.1}/tests/test_x509.py +0 -0
  177. {asyncssh-2.20.0 → asyncssh-2.21.1}/tox.ini +0 -0
@@ -1,13 +1,19 @@
1
1
  name: Run tests
2
2
  on: [push, pull_request]
3
3
 
4
+ permissions:
5
+ contents: read
6
+
4
7
  jobs:
5
8
  run-tests:
6
9
  name: Run tests
10
+ permissions:
11
+ contents: read
12
+ actions: write
7
13
  strategy:
8
14
  fail-fast: false
9
15
  matrix:
10
- os: [ubuntu-latest, macos-latest, windows-latest]
16
+ os: [ubuntu-latest, macos-latest, windows-2022]
11
17
  python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
12
18
  include:
13
19
  - os: macos-latest
@@ -124,6 +130,8 @@ jobs:
124
130
  runs-on: ubuntu-latest
125
131
  needs: run-tests
126
132
  if: ${{ always() }}
133
+ permissions:
134
+ actions: write
127
135
  steps:
128
136
  - name: Merge coverage
129
137
  uses: actions/upload-artifact/merge@v4
@@ -137,6 +145,9 @@ jobs:
137
145
  runs-on: ubuntu-latest
138
146
  needs: merge-coverage
139
147
  if: ${{ always() }}
148
+ permissions:
149
+ contents: read
150
+ actions: read
140
151
  steps:
141
152
  - uses: actions/checkout@v4
142
153
  - uses: actions/setup-python@v5
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: asyncssh
3
- Version: 2.20.0
3
+ Version: 2.21.1
4
4
  Summary: AsyncSSH: Asynchronous SSHv2 client and server library
5
5
  Author-email: Ron Frederick <ronf@timeheart.net>
6
6
  License: EPL-2.0 OR GPL-2.0-or-later
@@ -32,7 +32,7 @@ Requires-Dist: typing_extensions>=4.0.0
32
32
  Provides-Extra: bcrypt
33
33
  Requires-Dist: bcrypt>=3.1.3; extra == "bcrypt"
34
34
  Provides-Extra: fido2
35
- Requires-Dist: fido2>=0.9.2; extra == "fido2"
35
+ Requires-Dist: fido2<2,>=0.9.2; extra == "fido2"
36
36
  Provides-Extra: gssapi
37
37
  Requires-Dist: gssapi>=1.2.0; extra == "gssapi"
38
38
  Provides-Extra: libnacl
@@ -43,6 +43,7 @@ Provides-Extra: pyopenssl
43
43
  Requires-Dist: pyOpenSSL>=23.0.0; extra == "pyopenssl"
44
44
  Provides-Extra: pywin32
45
45
  Requires-Dist: pywin32>=227; extra == "pywin32"
46
+ Dynamic: license-file
46
47
 
47
48
  .. image:: https://readthedocs.org/projects/asyncssh/badge/?version=latest
48
49
  :target: https://asyncssh.readthedocs.io/en/latest/?badge=latest
@@ -1,4 +1,4 @@
1
- # Copyright (c) 2013-2024 by Ron Frederick <ronf@timeheart.net> and others.
1
+ # Copyright (c) 2013-2025 by Ron Frederick <ronf@timeheart.net> and others.
2
2
  #
3
3
  # This program and the accompanying materials are made available under
4
4
  # the terms of the Eclipse Public License v2.0 which accompanies this
@@ -548,10 +548,10 @@ class ServerAuth(Auth):
548
548
 
549
549
  self._conn.send_userauth_failure(partial_success)
550
550
 
551
- def send_success(self) -> None:
551
+ async def send_success(self) -> None:
552
552
  """Send a user authentication success response"""
553
553
 
554
- self._conn.send_userauth_success()
554
+ await self._conn.send_userauth_success()
555
555
 
556
556
 
557
557
  class _ServerNullAuth(ServerAuth):
@@ -596,7 +596,7 @@ class _ServerGSSKexAuth(ServerAuth):
596
596
  (await self._conn.validate_gss_principal(self._username,
597
597
  self._gss.user,
598
598
  self._gss.host))):
599
- self.send_success()
599
+ await self.send_success()
600
600
  else:
601
601
  self.send_failure()
602
602
 
@@ -650,7 +650,7 @@ class _ServerGSSMICAuth(ServerAuth):
650
650
  if (await self._conn.validate_gss_principal(self._username,
651
651
  self._gss.user,
652
652
  self._gss.host)):
653
- self.send_success()
653
+ await self.send_success()
654
654
  else:
655
655
  self.send_failure()
656
656
 
@@ -757,7 +757,7 @@ class _ServerHostBasedAuth(ServerAuth):
757
757
  key_data, client_host,
758
758
  client_username,
759
759
  msg, signature)):
760
- self.send_success()
760
+ await self.send_success()
761
761
  else:
762
762
  self.send_failure()
763
763
 
@@ -795,7 +795,7 @@ class _ServerPublicKeyAuth(ServerAuth):
795
795
  if (await self._conn.validate_public_key(self._username, key_data,
796
796
  msg, signature)):
797
797
  if sig_present:
798
- self.send_success()
798
+ await self.send_success()
799
799
  else:
800
800
  self.send_packet(MSG_USERAUTH_PK_OK, String(algorithm),
801
801
  String(key_data))
@@ -832,9 +832,9 @@ class _ServerKbdIntAuth(ServerAuth):
832
832
 
833
833
  challenge = await self._conn.get_kbdint_challenge(self._username,
834
834
  lang, submethods)
835
- self._send_challenge(challenge)
835
+ await self._send_challenge(challenge)
836
836
 
837
- def _send_challenge(self, challenge: KbdIntChallenge) -> None:
837
+ async def _send_challenge(self, challenge: KbdIntChallenge) -> None:
838
838
  """Send a keyboard interactive authentication request"""
839
839
 
840
840
  if isinstance(challenge, (tuple, list)):
@@ -848,7 +848,7 @@ class _ServerKbdIntAuth(ServerAuth):
848
848
  String(instruction), String(lang),
849
849
  UInt32(num_prompts), *prompts_bytes)
850
850
  elif challenge:
851
- self.send_success()
851
+ await self.send_success()
852
852
  else:
853
853
  self.send_failure()
854
854
 
@@ -857,7 +857,7 @@ class _ServerKbdIntAuth(ServerAuth):
857
857
 
858
858
  next_challenge = \
859
859
  await self._conn.validate_kbdint_response(self._username, responses)
860
- self._send_challenge(next_challenge)
860
+ await self._send_challenge(next_challenge)
861
861
 
862
862
  def _process_info_response(self, _pkttype: int, _pktid: int,
863
863
  packet: SSHPacket) -> None:
@@ -922,7 +922,7 @@ class _ServerPasswordAuth(ServerAuth):
922
922
  await self._conn.validate_password(self._username, password)
923
923
 
924
924
  if result:
925
- self.send_success()
925
+ await self.send_success()
926
926
  else:
927
927
  self.send_failure()
928
928
  except PasswordChangeRequired as exc:
@@ -26,6 +26,7 @@ import codecs
26
26
  import inspect
27
27
  import re
28
28
  import signal as _signal
29
+ import sys
29
30
  from types import MappingProxyType
30
31
  from typing import TYPE_CHECKING, Any, AnyStr, Awaitable, Callable
31
32
  from typing import Dict, Generic, Iterable, List, Mapping, Optional
@@ -225,7 +226,13 @@ class SSHChannel(Generic[AnyStr], SSHPacketHandler):
225
226
  self._request_waiters = []
226
227
 
227
228
  if self._session is not None:
228
- self._session.connection_lost(exc)
229
+ # pylint: disable=broad-except
230
+ try:
231
+ self._session.connection_lost(exc)
232
+ except Exception:
233
+ self.logger.debug1('Uncaught exception in session ignored',
234
+ exc_info=sys.exc_info)
235
+
229
236
  self._session = None
230
237
 
231
238
  self._close_event.set()
@@ -2058,16 +2065,16 @@ class SSHTCPChannel(SSHForwardChannel, Generic[AnyStr]):
2058
2065
  SSHTCPSession[AnyStr]:
2059
2066
  """Create a new outbound TCP session"""
2060
2067
 
2061
- return (await self._open_tcp(session_factory, b'direct-tcpip',
2062
- host, port, orig_host, orig_port))
2068
+ return await self._open_tcp(session_factory, b'direct-tcpip',
2069
+ host, port, orig_host, orig_port)
2063
2070
 
2064
2071
  async def accept(self, session_factory: SSHTCPSessionFactory[AnyStr],
2065
2072
  host: str, port: int, orig_host: str,
2066
2073
  orig_port: int) -> SSHTCPSession[AnyStr]:
2067
2074
  """Create a new forwarded TCP session"""
2068
2075
 
2069
- return (await self._open_tcp(session_factory, b'forwarded-tcpip',
2070
- host, port, orig_host, orig_port))
2076
+ return await self._open_tcp(session_factory, b'forwarded-tcpip',
2077
+ host, port, orig_host, orig_port)
2071
2078
 
2072
2079
  def set_inbound_peer_names(self, dest_host: str, dest_port: int,
2073
2080
  orig_host: str, orig_port: int) -> None:
@@ -1,4 +1,4 @@
1
- # Copyright (c) 2013-2024 by Ron Frederick <ronf@timeheart.net> and others.
1
+ # Copyright (c) 2013-2025 by Ron Frederick <ronf@timeheart.net> and others.
2
2
  #
3
3
  # This program and the accompanying materials are made available under
4
4
  # the terms of the Eclipse Public License v2.0 which accompanies this
@@ -1075,7 +1075,13 @@ class SSHConnection(SSHPacketHandler, asyncio.Protocol):
1075
1075
  self._wait = None
1076
1076
 
1077
1077
  if self._owner: # pragma: no branch
1078
- self._owner.connection_lost(exc)
1078
+ # pylint: disable=broad-except
1079
+ try:
1080
+ self._owner.connection_lost(exc)
1081
+ except Exception:
1082
+ self.logger.debug1('Uncaught exception in owner ignored',
1083
+ exc_info=sys.exc_info)
1084
+
1079
1085
  self._owner = None
1080
1086
 
1081
1087
  self._cancel_login_timer()
@@ -1196,7 +1202,7 @@ class SSHConnection(SSHPacketHandler, asyncio.Protocol):
1196
1202
 
1197
1203
  return self._server
1198
1204
 
1199
- def is_closed(self):
1205
+ def is_closed(self) -> bool:
1200
1206
  """Return whether the connection is closed"""
1201
1207
 
1202
1208
  return self._close_event.is_set()
@@ -2069,7 +2075,7 @@ class SSHConnection(SSHPacketHandler, asyncio.Protocol):
2069
2075
  self.send_packet(MSG_USERAUTH_FAILURE, NameList(methods),
2070
2076
  Boolean(partial_success))
2071
2077
 
2072
- def send_userauth_success(self) -> None:
2078
+ async def send_userauth_success(self) -> None:
2073
2079
  """Send a user authentication success response"""
2074
2080
 
2075
2081
  self.logger.info('Auth for user %s succeeded', self._username)
@@ -2086,13 +2092,15 @@ class SSHConnection(SSHPacketHandler, asyncio.Protocol):
2086
2092
  self._set_keepalive_timer()
2087
2093
 
2088
2094
  if self._owner: # pragma: no branch
2089
- self._owner.auth_completed()
2095
+ result = self._owner.auth_completed()
2096
+
2097
+ if inspect.isawaitable(result):
2098
+ await result
2090
2099
 
2091
2100
  if self._acceptor:
2092
2101
  result = self._acceptor(self)
2093
2102
 
2094
2103
  if inspect.isawaitable(result):
2095
- assert result is not None
2096
2104
  self.create_task(result)
2097
2105
 
2098
2106
  self._acceptor = None
@@ -2506,7 +2514,7 @@ class SSHConnection(SSHPacketHandler, asyncio.Protocol):
2506
2514
  result = await cast(Awaitable[bool], result)
2507
2515
 
2508
2516
  if not result:
2509
- self.send_userauth_success()
2517
+ await self.send_userauth_success()
2510
2518
  return
2511
2519
 
2512
2520
  if not self._owner: # pragma: no cover
@@ -2603,7 +2611,6 @@ class SSHConnection(SSHPacketHandler, asyncio.Protocol):
2603
2611
  result = self._acceptor(self)
2604
2612
 
2605
2613
  if inspect.isawaitable(result):
2606
- assert result is not None
2607
2614
  self.create_task(result)
2608
2615
 
2609
2616
  self._acceptor = None
@@ -3229,9 +3236,8 @@ class SSHConnection(SSHPacketHandler, asyncio.Protocol):
3229
3236
  raise ChannelOpenError(OPEN_ADMINISTRATIVELY_PROHIBITED,
3230
3237
  'Connection forwarding denied')
3231
3238
 
3232
- return (await self.create_connection(session_factory,
3233
- dest_host, dest_port,
3234
- orig_host, orig_port))
3239
+ return await self.create_connection(session_factory, dest_host,
3240
+ dest_port, orig_host, orig_port)
3235
3241
 
3236
3242
  if (listen_host, listen_port) == (dest_host, dest_port):
3237
3243
  self.logger.info('Creating local TCP forwarder on %s',
@@ -4130,7 +4136,6 @@ class SSHClientConnection(SSHConnection):
4130
4136
  retained, revoked)
4131
4137
 
4132
4138
  if inspect.isawaitable(result):
4133
- assert result is not None
4134
4139
  await result
4135
4140
 
4136
4141
  self._report_global_response(True)
@@ -4164,7 +4169,7 @@ class SSHClientConnection(SSHConnection):
4164
4169
  async def create_session(self, session_factory: SSHClientSessionFactory,
4165
4170
  command: DefTuple[Optional[str]] = (), *,
4166
4171
  subsystem: DefTuple[Optional[str]]= (),
4167
- env: DefTuple[Env] = (),
4172
+ env: DefTuple[Optional[Env]] = (),
4168
4173
  send_env: DefTuple[Optional[EnvSeq]] = (),
4169
4174
  request_pty: DefTuple[Union[bool, str]] = (),
4170
4175
  term_type: DefTuple[Optional[str]] = (),
@@ -5052,8 +5057,8 @@ class SSHClientConnection(SSHConnection):
5052
5057
 
5053
5058
  """
5054
5059
 
5055
- return (await create_connection(client_factory, host, port,
5056
- tunnel=self, **kwargs)) # type: ignore
5060
+ return await create_connection(client_factory, host, port,
5061
+ tunnel=self, **kwargs) # type: ignore
5057
5062
 
5058
5063
  @async_context_manager
5059
5064
  async def connect_ssh(self, host: str, port: DefTuple[int] = (),
@@ -5321,8 +5326,7 @@ class SSHClientConnection(SSHConnection):
5321
5326
  raise ChannelOpenError(OPEN_ADMINISTRATIVELY_PROHIBITED,
5322
5327
  'Connection forwarding denied')
5323
5328
 
5324
- return (await self.create_unix_connection(session_factory,
5325
- dest_path))
5329
+ return await self.create_unix_connection(session_factory, dest_path)
5326
5330
 
5327
5331
  self.logger.info('Creating local TCP forwarder from %s to %s',
5328
5332
  (listen_host, listen_port), dest_path)
@@ -5683,7 +5687,7 @@ class SSHClientConnection(SSHConnection):
5683
5687
  return cast(SSHForwarder, peer)
5684
5688
 
5685
5689
  @async_context_manager
5686
- async def start_sftp_client(self, env: DefTuple[Env] = (),
5690
+ async def start_sftp_client(self, env: DefTuple[Optional[Env]] = (),
5687
5691
  send_env: DefTuple[Optional[EnvSeq]] = (),
5688
5692
  path_encoding: Optional[str] = 'utf-8',
5689
5693
  path_errors = 'strict',
@@ -6301,6 +6305,9 @@ class SSHServerConnection(SSHConnection):
6301
6305
  if not result:
6302
6306
  raise ChannelOpenError(OPEN_CONNECT_FAILED, 'Session refused')
6303
6307
 
6308
+ if isinstance(result, SSHClientConnection):
6309
+ result = self.forward_tunneled_session(result)
6310
+
6304
6311
  if isinstance(result, tuple):
6305
6312
  chan, result = result
6306
6313
  else:
@@ -6356,6 +6363,10 @@ class SSHServerConnection(SSHConnection):
6356
6363
  if result is True:
6357
6364
  result = cast(SSHTCPSession[bytes],
6358
6365
  self.forward_connection(dest_host, dest_port))
6366
+ elif isinstance(result, SSHClientConnection):
6367
+ result = cast(Awaitable[SSHTCPSession[bytes]],
6368
+ self.forward_tunneled_connection(
6369
+ result, dest_host, dest_port))
6359
6370
 
6360
6371
  if isinstance(result, tuple):
6361
6372
  chan, result = result
@@ -6502,6 +6513,10 @@ class SSHServerConnection(SSHConnection):
6502
6513
  if result is True:
6503
6514
  result = cast(SSHUNIXSession[bytes],
6504
6515
  self.forward_unix_connection(dest_path))
6516
+ elif isinstance(result, SSHClientConnection):
6517
+ result = cast(Awaitable[SSHUNIXSession[bytes]],
6518
+ self.forward_tunneled_unix_connection(
6519
+ result, dest_path))
6505
6520
 
6506
6521
  if isinstance(result, tuple):
6507
6522
  chan, result = result
@@ -6617,10 +6632,14 @@ class SSHServerConnection(SSHConnection):
6617
6632
  result = False
6618
6633
 
6619
6634
  if not result:
6620
- raise ChannelOpenError(OPEN_CONNECT_FAILED, 'Connection refused')
6635
+ raise ChannelOpenError(OPEN_CONNECT_FAILED,
6636
+ 'TUN/TAP request refused')
6621
6637
 
6622
6638
  if result is True:
6623
6639
  result = cast(SSHTunTapSession, self.forward_tuntap(mode, unit))
6640
+ elif isinstance(result, SSHClientConnection):
6641
+ result = cast(Awaitable[SSHTunTapSession],
6642
+ self.forward_tunneled_tuntap(result, mode, unit))
6624
6643
 
6625
6644
  if isinstance(result, tuple):
6626
6645
  chan, result = result
@@ -7175,6 +7194,76 @@ class SSHServerConnection(SSHConnection):
7175
7194
 
7176
7195
  return SSHReader[bytes](session, chan), SSHWriter[bytes](session, chan)
7177
7196
 
7197
+ async def forward_tunneled_session(
7198
+ self, conn: SSHClientConnection) -> SSHServerProcess:
7199
+ """Forward a tunneled session between SSH connections"""
7200
+
7201
+ async def process_factory(process: SSHServerProcess) -> None:
7202
+ """Return an upstream process used to forward the session"""
7203
+
7204
+ encoding, errors = process.channel.get_encoding()
7205
+
7206
+ upstream_process: SSHClientProcess = await conn.create_process(
7207
+ command=process.command, subsystem=process.subsystem,
7208
+ env=process.env, term_type=process.term_type,
7209
+ term_size=process.term_size, term_modes=process.term_modes,
7210
+ encoding=encoding, errors=errors, stdin=process.stdin,
7211
+ stdout=process.stdout, stderr=process.stderr)
7212
+
7213
+ await upstream_process.wait_closed()
7214
+
7215
+ self.logger.info(' Forwarding session via SSH tunnel')
7216
+
7217
+ return SSHServerProcess(process_factory, None, MIN_SFTP_VERSION, False)
7218
+
7219
+ async def forward_tunneled_connection(
7220
+ self, conn: SSHClientConnection,
7221
+ dest_host: str, dest_port: int) -> SSHForwarder:
7222
+ """Forward a tunneled TCP connection between SSH connections"""
7223
+
7224
+ _, peer = await conn.create_connection(
7225
+ cast(SSHTCPSessionFactory[bytes], SSHForwarder),
7226
+ dest_host, dest_port)
7227
+
7228
+ self.logger.info(' Forwarding TCP connection to %s via SSH tunnel',
7229
+ (dest_host, dest_port))
7230
+
7231
+ return SSHForwarder(cast(SSHForwarder, peer))
7232
+
7233
+ async def forward_tunneled_unix_connection(
7234
+ self, conn: SSHClientConnection,
7235
+ dest_path: str) -> SSHForwarder:
7236
+ """Forward a tunneled UNIX connection between SSH connections"""
7237
+
7238
+ _, peer = await conn.create_unix_connection(
7239
+ cast(SSHUNIXSessionFactory[bytes], SSHForwarder), dest_path)
7240
+
7241
+ self.logger.info(' Forwarding UNIX connection to %s via SSH tunnel',
7242
+ dest_path)
7243
+
7244
+ return SSHForwarder(cast(SSHForwarder, peer))
7245
+
7246
+ async def forward_tunneled_tuntap(
7247
+ self, conn: SSHClientConnection,
7248
+ mode: int, unit: Optional[int]) -> SSHForwarder:
7249
+ """Forward a TUN/TAP connection between SSH connections"""
7250
+
7251
+ if mode == SSH_TUN_MODE_POINTTOPOINT:
7252
+ create_func = conn.create_tun
7253
+ layer = 3
7254
+ else:
7255
+ create_func = conn.create_tap
7256
+ layer = 2
7257
+
7258
+ transport, peer = await create_func(
7259
+ cast(SSHTunTapSessionFactory, SSHForwarder), unit)
7260
+ interface = transport.get_extra_info('interface')
7261
+
7262
+ self.logger.info(' Forwarding layer %d traffic to %s via SSH tunnel',
7263
+ layer, interface)
7264
+
7265
+ return SSHForwarder(cast(SSHForwarder, peer))
7266
+
7178
7267
 
7179
7268
  class SSHConnectionOptions(Options, Generic[_Options]):
7180
7269
  """SSH connection options"""
@@ -7953,7 +8042,7 @@ class SSHClientConnectionOptions(SSHConnectionOptions):
7953
8042
  pkcs11_pin: Optional[str]
7954
8043
  command: Optional[str]
7955
8044
  subsystem: Optional[str]
7956
- env: Env
8045
+ env: Optional[Env]
7957
8046
  send_env: Optional[EnvSeq]
7958
8047
  request_pty: _RequestPTY
7959
8048
  term_type: Optional[str]
@@ -8026,7 +8115,8 @@ class SSHClientConnectionOptions(SSHConnectionOptions):
8026
8115
  pkcs11_provider: DefTuple[Optional[str]] = (),
8027
8116
  pkcs11_pin: Optional[str] = None,
8028
8117
  command: DefTuple[Optional[str]] = (),
8029
- subsystem: Optional[str] = None, env: DefTuple[Env] = (),
8118
+ subsystem: Optional[str] = None,
8119
+ env: DefTuple[Optional[Env]] = (),
8030
8120
  send_env: DefTuple[Optional[EnvSeq]] = (),
8031
8121
  request_pty: DefTuple[_RequestPTY] = (),
8032
8122
  term_type: Optional[str] = None,
@@ -8265,7 +8355,8 @@ class SSHClientConnectionOptions(SSHConnectionOptions):
8265
8355
 
8266
8356
  self.subsystem = subsystem
8267
8357
 
8268
- self.env = cast(Env, env if env != () else config.get('SetEnv'))
8358
+ self.env = cast(Optional[Env], env if env != () else
8359
+ config.get('SetEnv'))
8269
8360
 
8270
8361
  self.send_env = cast(Optional[EnvSeq], send_env if send_env != () else
8271
8362
  config.get('SendEnv'))
@@ -8473,11 +8564,11 @@ class SSHServerConnectionOptions(SSHConnectionOptions):
8473
8564
  errors of data exchanged on sessions on this server, defaulting
8474
8565
  to 'strict'.
8475
8566
  :param sftp_factory: (optional)
8476
- A `callable` which returns an :class:`SFTPServer` object that
8477
- will be created each time an SFTP session is requested by the
8478
- client, or `True` to use the base :class:`SFTPServer` class
8479
- to handle SFTP requests. If not specified, SFTP sessions are
8480
- rejected by default.
8567
+ A `callable` or coroutine which returns an :class:`SFTPServer`
8568
+ object that will be created each time an SFTP session is
8569
+ requested by the client, or `True` to use the base
8570
+ :class:`SFTPServer` class to handle SFTP requests. If not
8571
+ specified, SFTP sessions are rejected by default.
8481
8572
  :param sftp_version: (optional)
8482
8573
  The maximum version of the SFTP protocol to support, currently
8483
8574
  either 3 or 4, defaulting to 3.
@@ -8624,7 +8715,7 @@ class SSHServerConnectionOptions(SSHConnectionOptions):
8624
8715
  :type session_factory: `callable` or coroutine
8625
8716
  :type encoding: `str` or `None`
8626
8717
  :type errors: `str`
8627
- :type sftp_factory: `callable`
8718
+ :type sftp_factory: `callable` or coroutine
8628
8719
  :type sftp_version: `int`
8629
8720
  :type allow_scp: `bool`
8630
8721
  :type window: `int`
@@ -91,7 +91,8 @@ class SSHForwarder(asyncio.BaseProtocol):
91
91
  def write_eof(self) -> None:
92
92
  """Write end of file to the transport"""
93
93
 
94
- assert self._transport is not None
94
+ if not self._transport:
95
+ return # pragma: no cover
95
96
 
96
97
  try:
97
98
  self._transport.write_eof()
@@ -1,4 +1,4 @@
1
- # Copyright (c) 2013-2024 by Ron Frederick <ronf@timeheart.net> and others.
1
+ # Copyright (c) 2013-2025 by Ron Frederick <ronf@timeheart.net> and others.
2
2
  #
3
3
  # This program and the accompanying materials are made available under
4
4
  # the terms of the Eclipse Public License v2.0 which accompanies this
@@ -343,6 +343,9 @@ class _KexDHGex(_KexDHBase):
343
343
  if self._conn.is_client():
344
344
  raise ProtocolError('Unexpected kex request msg')
345
345
 
346
+ if self._p:
347
+ raise ProtocolError('Kex DH group already requested')
348
+
346
349
  self._gex_data = packet.get_remaining_payload()
347
350
 
348
351
  if pkttype == MSG_KEX_DH_GEX_REQUEST_OLD:
@@ -377,6 +380,9 @@ class _KexDHGex(_KexDHBase):
377
380
  if self._conn.is_server():
378
381
  raise ProtocolError('Unexpected kex group msg')
379
382
 
383
+ if self._p:
384
+ raise ProtocolError('Kex DH group already sent')
385
+
380
386
  p = packet.get_mpint()
381
387
  g = packet.get_mpint()
382
388
  packet.check_end()
@@ -529,6 +535,7 @@ class _KexGSSBase(_KexDHBase):
529
535
 
530
536
  self._gss = conn.get_gss_context()
531
537
  self._token: Optional[bytes] = None
538
+ self._host_key_msg_ok = False
532
539
  self._host_key_data = b''
533
540
 
534
541
  def _check_secure(self) -> None:
@@ -621,6 +628,8 @@ class _KexGSSBase(_KexDHBase):
621
628
  if self._conn.is_client() and self._gss.complete:
622
629
  raise ProtocolError('Unexpected kexgss continue msg')
623
630
 
631
+ self._host_key_msg_ok = False
632
+
624
633
  await self._process_token(token)
625
634
 
626
635
  if self._conn.is_server() and self._gss.complete:
@@ -636,6 +645,8 @@ class _KexGSSBase(_KexDHBase):
636
645
  if self._conn.is_server():
637
646
  raise ProtocolError('Unexpected kexgss complete msg')
638
647
 
648
+ self._host_key_msg_ok = False
649
+
639
650
  self._parse_server_key(packet)
640
651
  mic = packet.get_string()
641
652
  token_present = packet.get_boolean()
@@ -662,6 +673,10 @@ class _KexGSSBase(_KexDHBase):
662
673
  packet: SSHPacket) -> None:
663
674
  """Process a GSS hostkey message"""
664
675
 
676
+ if not self._host_key_msg_ok:
677
+ raise ProtocolError('Unexpected kexgss hostkey msg')
678
+
679
+ self._host_key_msg_ok = False
665
680
  self._host_key_data = packet.get_string()
666
681
  packet.check_end()
667
682
 
@@ -685,6 +700,7 @@ class _KexGSSBase(_KexDHBase):
685
700
  """Start GSS key exchange"""
686
701
 
687
702
  if self._conn.is_client():
703
+ self._host_key_msg_ok = True
688
704
  await self._process_token()
689
705
  await super().start()
690
706
 
@@ -1,4 +1,4 @@
1
- # Copyright (c) 2013-2024 by Ron Frederick <ronf@timeheart.net> and others.
1
+ # Copyright (c) 2013-2025 by Ron Frederick <ronf@timeheart.net> and others.
2
2
  #
3
3
  # This program and the accompanying materials are made available under
4
4
  # the terms of the Eclipse Public License v2.0 which accompanies this
@@ -46,6 +46,17 @@ from .constants import DISC_NO_MORE_AUTH_METHODS_AVAILABLE
46
46
  from .constants import DISC_PROTOCOL_ERROR, DISC_PROTOCOL_VERSION_NOT_SUPPORTED
47
47
  from .constants import DISC_SERVICE_NOT_AVAILABLE
48
48
 
49
+ _pywin32_available = False
50
+
51
+ if sys.platform == 'win32': # pragma: no cover
52
+ try:
53
+ import msvcrt
54
+ import win32file
55
+ import winioctlcon
56
+ _pywin32_available = True
57
+ except ImportError:
58
+ pass
59
+
49
60
  if sys.platform != 'win32': # pragma: no branch
50
61
  import fcntl
51
62
  import struct
@@ -114,7 +125,7 @@ SockAddr = Union[Tuple[str, int], Tuple[str, int, int, int]]
114
125
  EnvMap = Mapping[BytesOrStr, BytesOrStr]
115
126
  EnvItems = Sequence[Tuple[BytesOrStr, BytesOrStr]]
116
127
  EnvSeq = Sequence[BytesOrStr]
117
- Env = Optional[Union[EnvMap, EnvItems, EnvSeq]]
128
+ Env = Union[EnvMap, EnvItems, EnvSeq]
118
129
 
119
130
  # Define a version of randrange which is based on SystemRandom(), so that
120
131
  # we get back numbers suitable for cryptographic use.
@@ -130,8 +141,8 @@ _time_units = {'': 1, 's': 1, 'm': 60, 'h': 60*60,
130
141
  def encode_env(env: Env) -> Iterator[Tuple[bytes, bytes]]:
131
142
  """Convert environemnt dict or list to bytes-based dictionary"""
132
143
 
133
- env = cast(Sequence[Tuple[BytesOrStr, BytesOrStr]],
134
- env.items() if isinstance(env, dict) else env)
144
+ if hasattr(env, 'items'):
145
+ env = cast(Env, env.items())
135
146
 
136
147
  try:
137
148
  for item in env:
@@ -305,6 +316,19 @@ def write_file(filename: FilePath, data: bytes, mode: str = 'wb') -> int:
305
316
  return f.write(data)
306
317
 
307
318
 
319
+ if sys.platform == 'win32' and _pywin32_available: # pragma: no cover
320
+ def make_sparse_file(file_obj: IO) -> None:
321
+ """Enable sparse file support on a file on Windows"""
322
+
323
+ handle = msvcrt.get_osfhandle(file_obj.fileno())
324
+
325
+ win32file.DeviceIoControl(handle, winioctlcon.FSCTL_SET_SPARSE,
326
+ b'', 0, None)
327
+ else:
328
+ def make_sparse_file(_file_obj: IO) -> None:
329
+ """Sparse files are automatically enabled on non-Windows systems"""
330
+
331
+
308
332
  def _parse_units(value: str, suffixes: Mapping[str, int], label: str) -> float:
309
333
  """Parse a series of integers followed by unit suffixes"""
310
334