pathapi 0.1.0__tar.gz → 0.1.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 (231) hide show
  1. {pathapi-0.1.0 → pathapi-0.1.1}/CHANGELOG.md +35 -1
  2. {pathapi-0.1.0 → pathapi-0.1.1}/PKG-INFO +1 -1
  3. {pathapi-0.1.0 → pathapi-0.1.1}/docs/divergences.md +2 -2
  4. {pathapi-0.1.0 → pathapi-0.1.1}/pyproject.toml +1 -1
  5. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/AGENTS.md +31 -12
  6. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_client.py +38 -5
  7. pathapi-0.1.1/src/pathapi/http/_deadline.py +74 -0
  8. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_follow.py +25 -3
  9. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/testing/__init__.py +8 -2
  10. pathapi-0.1.1/src/pathapi/protocols/_copytree.py +83 -0
  11. pathapi-0.1.1/src/pathapi/protocols/_removing.py +105 -0
  12. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_transfer.py +9 -0
  13. pathapi-0.1.1/src/pathapi/protocols/_writing.py +153 -0
  14. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/transfer.py +42 -117
  15. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/tree.py +6 -80
  16. pathapi-0.1.1/src/pathapi/sync/_entry.py +544 -0
  17. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_syncer.py +8 -501
  18. {pathapi-0.1.0 → pathapi-0.1.1}/tests/mutation/mutants.py +118 -46
  19. pathapi-0.1.1/tests/test_copy_from_names.py +83 -0
  20. pathapi-0.1.1/tests/test_copy_overwrite.py +141 -0
  21. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_http_testing.py +24 -0
  22. pathapi-0.1.1/tests/transport/test_client_deadline.py +184 -0
  23. {pathapi-0.1.0 → pathapi-0.1.1}/.gitignore +0 -0
  24. {pathapi-0.1.0 → pathapi-0.1.1}/LICENSE +0 -0
  25. {pathapi-0.1.0 → pathapi-0.1.1}/README.md +0 -0
  26. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/archive.md +0 -0
  27. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/checksum.md +0 -0
  28. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/exceptions.md +0 -0
  29. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/ftp.md +0 -0
  30. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/glob.md +0 -0
  31. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/http.md +0 -0
  32. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/mempath.md +0 -0
  33. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/path.md +0 -0
  34. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/protocols.md +0 -0
  35. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/providers.md +0 -0
  36. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/sync.md +0 -0
  37. {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/testing.md +0 -0
  38. {pathapi-0.1.0 → pathapi-0.1.1}/docs/benchmarks.md +0 -0
  39. {pathapi-0.1.0 → pathapi-0.1.1}/docs/changelog.md +0 -0
  40. {pathapi-0.1.0 → pathapi-0.1.1}/docs/guides/extending.md +0 -0
  41. {pathapi-0.1.0 → pathapi-0.1.1}/docs/index.md +0 -0
  42. {pathapi-0.1.0 → pathapi-0.1.1}/examples/archive_members.py +0 -0
  43. {pathapi-0.1.0 → pathapi-0.1.1}/examples/ftp_listing.py +0 -0
  44. {pathapi-0.1.0 → pathapi-0.1.1}/examples/http_request.py +0 -0
  45. {pathapi-0.1.0 → pathapi-0.1.1}/examples/local_and_mem.py +0 -0
  46. {pathapi-0.1.0 → pathapi-0.1.1}/mkdocs.yml +0 -0
  47. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/__init__.py +0 -0
  48. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_commit.py +0 -0
  49. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_discard.py +0 -0
  50. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_errorpolicy.py +0 -0
  51. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_filestat.py +0 -0
  52. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_lru.py +0 -0
  53. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_names.py +0 -0
  54. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_permissions.py +0 -0
  55. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_stubs.py +0 -0
  56. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_tree.py +0 -0
  57. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/__init__.py +0 -0
  58. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_backend.py +0 -0
  59. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_names.py +0 -0
  60. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_pack.py +0 -0
  61. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_path.py +0 -0
  62. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_snapshot.py +0 -0
  63. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_tar.py +0 -0
  64. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_write.py +0 -0
  65. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_zip.py +0 -0
  66. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_zipentries.py +0 -0
  67. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/checksum.py +0 -0
  68. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/exceptions.py +0 -0
  69. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/fspath.py +0 -0
  70. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/ftp/__init__.py +0 -0
  71. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/ftp/_backend.py +0 -0
  72. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/ftp/_facts.py +0 -0
  73. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/ftp/_path.py +0 -0
  74. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/__init__.py +0 -0
  75. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/_entries.py +0 -0
  76. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/_patterns.py +0 -0
  77. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/_recursion.py +0 -0
  78. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/_selection.py +0 -0
  79. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/__init__.py +0 -0
  80. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_body.py +0 -0
  81. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_builtin.py +0 -0
  82. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_errors.py +0 -0
  83. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_headers.py +0 -0
  84. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_pool.py +0 -0
  85. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_proxies.py +0 -0
  86. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_response.py +0 -0
  87. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_settings.py +0 -0
  88. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_status.py +0 -0
  89. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_transports.py +0 -0
  90. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/testing/__main__.py +0 -0
  91. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/mempath.py +0 -0
  92. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/__init__.py +0 -0
  93. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_info.py +0 -0
  94. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_path.py +0 -0
  95. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_pure.py +0 -0
  96. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_relation.py +0 -0
  97. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_transfer.py +0 -0
  98. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/__init__.py +0 -0
  99. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_bundles.py +0 -0
  100. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_errors.py +0 -0
  101. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_info.py +0 -0
  102. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_relation.py +0 -0
  103. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_removal.py +0 -0
  104. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_support.py +0 -0
  105. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/checksum.py +0 -0
  106. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/fs.py +0 -0
  107. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/io.py +0 -0
  108. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/links.py +0 -0
  109. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/pure.py +0 -0
  110. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/resolve.py +0 -0
  111. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/providers.py +0 -0
  112. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/py.typed +0 -0
  113. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/__init__.py +0 -0
  114. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_compare.py +0 -0
  115. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_events.py +0 -0
  116. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_links.py +0 -0
  117. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_offer.py +0 -0
  118. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_paths.py +0 -0
  119. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_replace.py +0 -0
  120. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/testing/__init__.py +0 -0
  121. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/testing/_contract.py +0 -0
  122. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/testing/_fixtures.py +0 -0
  123. {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/testing/_read.py +0 -0
  124. {pathapi-0.1.0 → pathapi-0.1.1}/tests/capabilities.py +0 -0
  125. {pathapi-0.1.0 → pathapi-0.1.1}/tests/census.py +0 -0
  126. {pathapi-0.1.0 → pathapi-0.1.1}/tests/conftest.py +0 -0
  127. {pathapi-0.1.0 → pathapi-0.1.1}/tests/ftp_loopback.py +0 -0
  128. {pathapi-0.1.0 → pathapi-0.1.1}/tests/hermetic.py +0 -0
  129. {pathapi-0.1.0 → pathapi-0.1.1}/tests/mutation/README.md +0 -0
  130. {pathapi-0.1.0 → pathapi-0.1.1}/tests/mutation/mutation_check.py +0 -0
  131. {pathapi-0.1.0 → pathapi-0.1.1}/tests/mutation/run.py +0 -0
  132. {pathapi-0.1.0 → pathapi-0.1.1}/tests/probes/probe_environment_leak.py +0 -0
  133. {pathapi-0.1.0 → pathapi-0.1.1}/tests/probes/probe_leaks_a_socket.py +0 -0
  134. {pathapi-0.1.0 → pathapi-0.1.1}/tests/probes/probe_session_environment.py +0 -0
  135. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_batch.py +0 -0
  136. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_batch_cost.py +0 -0
  137. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_damage.py +0 -0
  138. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_helpers.py +0 -0
  139. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_lazy.py +0 -0
  140. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_link_listing.py +0 -0
  141. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_links.py +0 -0
  142. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_names.py +0 -0
  143. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_parity.py +0 -0
  144. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_path.py +0 -0
  145. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_rewrite.py +0 -0
  146. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_safety.py +0 -0
  147. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_scale.py +0 -0
  148. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_tree.py +0 -0
  149. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_census.py +0 -0
  150. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_checksum.py +0 -0
  151. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_commit_on_close.py +0 -0
  152. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_composed_protocols.py +0 -0
  153. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_consumer_needs.py +0 -0
  154. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_contract.py +0 -0
  155. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_contract_helpers.py +0 -0
  156. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_copy_move_fidelity.py +0 -0
  157. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_copy_round_trips.py +0 -0
  158. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_destructive_safety.py +0 -0
  159. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_documented_examples.py +0 -0
  160. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_error_predicate.py +0 -0
  161. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_examples_skip.py +0 -0
  162. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_exceptions.py +0 -0
  163. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_filestat.py +0 -0
  164. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp.py +0 -0
  165. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_end_to_end.py +0 -0
  166. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_path.py +0 -0
  167. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_security.py +0 -0
  168. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_utime.py +0 -0
  169. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_wire.py +0 -0
  170. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_generic_pure_parity.py +0 -0
  171. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_glob.py +0 -0
  172. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_glob_edges.py +0 -0
  173. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_glob_parity.py +0 -0
  174. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_hermetic.py +0 -0
  175. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_io_parity.py +0 -0
  176. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_listing_hints.py +0 -0
  177. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_listing_names.py +0 -0
  178. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_local.py +0 -0
  179. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_local_stat_errors.py +0 -0
  180. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_low_core.py +0 -0
  181. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_low_sync.py +0 -0
  182. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_mempath.py +0 -0
  183. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_move_stats.py +0 -0
  184. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_mro_precedence.py +0 -0
  185. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_packaging.py +0 -0
  186. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_parity_io.py +0 -0
  187. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_parity_pure.py +0 -0
  188. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_path_gaps.py +0 -0
  189. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_path_info.py +0 -0
  190. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_path_moving.py +0 -0
  191. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_pathname.py +0 -0
  192. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_properties.py +0 -0
  193. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_protocol_composition.py +0 -0
  194. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_protocol_support.py +0 -0
  195. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_provider_seam.py +0 -0
  196. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_providers.py +0 -0
  197. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_public_surface.py +0 -0
  198. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_pure_parity.py +0 -0
  199. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_resolve.py +0 -0
  200. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_same_filesystem.py +0 -0
  201. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_shipped_header.py +0 -0
  202. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_smoke.py +0 -0
  203. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_symlink_target.py +0 -0
  204. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_sync.py +0 -0
  205. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_sync_cost.py +0 -0
  206. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_sync_mtime.py +0 -0
  207. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_sync_safety.py +0 -0
  208. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_walk.py +0 -0
  209. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_walk_junction.py +0 -0
  210. {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_write_discard.py +0 -0
  211. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/conftest.py +0 -0
  212. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_default.py +0 -0
  213. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_errors.py +0 -0
  214. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_peer_text.py +0 -0
  215. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_redirect_origin.py +0 -0
  216. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_redirects.py +0 -0
  217. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_reply_limits.py +0 -0
  218. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_urls.py +0 -0
  219. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_write_redirects.py +0 -0
  220. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_body.py +0 -0
  221. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_pool.py +0 -0
  222. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_prepare.py +0 -0
  223. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_proxy.py +0 -0
  224. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_registry.py +0 -0
  225. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_send.py +0 -0
  226. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_tls.py +0 -0
  227. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/transport_distribution.py +0 -0
  228. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/transport_fakes.py +0 -0
  229. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/transport_guard.py +0 -0
  230. {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/transport_wire.py +0 -0
  231. {pathapi-0.1.0 → pathapi-0.1.1}/tests/waits.py +0 -0
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.1] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - **`deadline` for a whole HTTP request.** `HTTPClient(deadline=...)` and the
15
+ `deadline=` keyword of `request()` and `redirect()` take the seconds a request may
16
+ take, redirects and the reading of the body included (`None`, the default, is no
17
+ limit). Each wait's timeout is held to the time that remains and `TimeoutError` is
18
+ raised once it has passed; `timeout` still bounds each wait. A read already blocked
19
+ can overrun the deadline by at most its timeout. A value that is not a number is
20
+ `TypeError`, one that is not positive `ValueError`. It works over every transport.
21
+
22
+ ### Fixed
23
+
24
+ - **`copy(overwrite=True)` no longer destroys the old target when the copy fails.**
25
+ An existing file was removed before the new content was written, so a source that
26
+ failed half way left no file, or a partial one. The old file is kept until the
27
+ new content is complete: it is written to a hidden `.NAME.<hex>.pathapi-tmp`
28
+ sibling and renamed over it, and a class whose write stream commits on close is
29
+ written to directly. A class that cannot rename writes the sibling and then writes
30
+ it over the target. If you list the directory while a copy runs, you may see that
31
+ sibling.
32
+ - **`pathlib.Path(src).copy(target)` (3.14) onto a `Path` of this library refuses
33
+ unsafe child names.** `_copy_from()` joined every listed name onto the target; a
34
+ name that is not one component (`..`, a separator, on a Windows target a drive or
35
+ a trailing dot) now raises `ValueError` before anything is written for it, as
36
+ `copy(recursive=True)` does. A source that lists such names needs a copy that
37
+ skips them.
38
+ - **The shipped transport suite names the file of its tests wherever it is run
39
+ from.** `python -m pathapi.http.testing` (and `run()`) roots pytest at the suite's
40
+ own directory unless `--rootdir` is given, so node ids keep their file when the
41
+ checkout and the installed package are on different drives.
42
+
10
43
  ## [0.1.0] - 2026-10-15
11
44
 
12
45
  Published as `pathlib-next` up to 0.10.0; from 0.1.0 as `pathapi`. The released
@@ -3539,7 +3572,8 @@ behaviour with what to do about it.
3539
3572
  - Sync error handling.
3540
3573
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
3541
3574
 
3542
- [Unreleased]: https://github.com/jose-pr/pathapi/compare/v0.1.0...HEAD
3575
+ [Unreleased]: https://github.com/jose-pr/pathapi/compare/v0.1.1...HEAD
3576
+ [0.1.1]: https://github.com/jose-pr/pathapi/compare/v0.1.0...v0.1.1
3543
3577
  [0.1.0]: https://github.com/jose-pr/pathapi/compare/v0.10.0...v0.1.0
3544
3578
  [0.10.0]: https://github.com/jose-pr/pathlib-next/compare/v0.9.14...v0.10.0
3545
3579
  [0.9.14]: https://github.com/jose-pr/pathlib-next/compare/v0.9.13...v0.9.14
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pathapi
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Generic Path Protocol based pathlib
5
5
  Project-URL: Homepage, https://github.com/jose-pr/pathapi/
6
6
  Project-URL: Documentation, https://jose-pr.github.io/pathapi/
@@ -70,7 +70,7 @@ these operations itself always keeps its own implementation.
70
70
  | `FTPPath.stat().st_mode` | pathlib reports the real mode | Taken from MLSD `unix.mode`, else derived from the `perm` fact (read/write bits only). | MLSD is the only mode source most servers offer. |
71
71
  | `Path.touch(mode=None, exist_ok=True)` on non-local backends | `pathlib.Path.touch(mode=0o666)` creates with the mode masked by the umask and updates an existing file's mtime | `mode` defaults to `None`: a new file keeps the backend's default permissions, and an explicit `mode` is applied with `chmod()` unmasked (a remote umask is unknown); a class that cannot `chmod()` raises `NotImplementedError` and removes the file it created, so a mode that was asked for is never silently dropped. An existing file's access and modification times are set to now through the overridable `_utime()` hook (`MemPath` and `FTPPath` on a server that lists `MFMT` implement it: whole seconds, no access time on FTP); a class that cannot set a time leaves the file as it is. `LocalPath` uses pathlib's `touch()` unchanged. | Always chmod'ing 0o666 left new files world-writable on FTP and other remote backends; a remote backend cannot apply the local umask. |
72
72
  | `Path.__iter__` | `pathlib.Path` is not iterable (no `__iter__`) | `iter(path)` is `path.iterdir()` | Deliberate extension for ergonomic `for child in path:` loops. **Caution:** on remote backends (FTP) this is a network call. **User decision, 2026-07-11.** |
73
- | `Path.copy(target, ...)` | CPython 3.14 `Path.copy(target, *, follow_symlinks=True, preserve_metadata=False)`: overwrites an existing file, copies a directory tree without being asked, returns the new path | Ours predates 3.14. Signature: `copy(target, *, overwrite=False, follow_symlinks=True, preserve_metadata=True, recursive=False, ignore_error=None, progress=None, on_error=None)`; returns `None`. `ignore_error=` keeps its notify-only callable, and `on_error=` is `rm()`'s predicate `(error, path) -> bool` for a child error of a recursive copy (`True` skips the entry, `False` raises, a predicate that raises ends the copy with its own exception); passing both raises `TypeError`. An existing target raises `FileExistsError` unless `overwrite=True`; a directory needs `recursive=True`. `overwrite=True` unlinks an existing non-directory target with `unlink(missing_ok=True)`, but only once the source is open (a missing or unreadable source never destroys or creates the target; a copy that fails mid-stream removes its partial target; a backend whose write replaces the file and which cannot delete makes `unlink(missing_ok=True)` return without removing); copying onto the same file, or a directory into itself, raises `OSError(EINVAL)`: two files of this machine (`LocalPath`, a subclass, `pathlib.Path`, a class that names a local file through `filepath`) are compared by identity whatever their classes, a type that answers the overridable `_node_key()` hook (`MemPath`, archive members) by its normalized node, and anything else by `samefile()` where `stat()` has `st_dev`/`st_ino`, otherwise equal paths of one type that the overridable `_same_filesystem()` hook places on the same host or store (two paths of one class that name one server, built separately, count as the same file); `preserve_metadata` defaults to **True** (opposite of 3.14) and only propagates `st_mode`, not timestamps/xattrs: the permission bits of a file, and of each directory of a recursive copy once its children are copied (as `shutil.copytree` and 3.14 do), with the setuid, setgid and sticky bits dropped when source and target are different classes; a recreated symlink (`follow_symlinks=False`) carries only its target text, not its own mode, times or owner; a dangling symlink at the target is written through, as `shutil.copyfile` and 3.14 do, with or without `overwrite`, while a live one counts as existing and `overwrite=True` replaces the link itself | Argument names aligned with 3.14 where cheap; `preserve_metadata=True` default kept for backward compat with this method's pre-existing (pre-3.14-alignment) behavior of always copying the mode bits. Full metadata preservation (timestamps, xattrs) is not implemented. |
73
+ | `Path.copy(target, ...)` | CPython 3.14 `Path.copy(target, *, follow_symlinks=True, preserve_metadata=False)`: overwrites an existing file, copies a directory tree without being asked, returns the new path | Ours predates 3.14. Signature: `copy(target, *, overwrite=False, follow_symlinks=True, preserve_metadata=True, recursive=False, ignore_error=None, progress=None, on_error=None)`; returns `None`. `ignore_error=` keeps its notify-only callable, and `on_error=` is `rm()`'s predicate `(error, path) -> bool` for a child error of a recursive copy (`True` skips the entry, `False` raises, a predicate that raises ends the copy with its own exception); passing both raises `TypeError`. An existing target raises `FileExistsError` unless `overwrite=True`; a directory needs `recursive=True`. `overwrite=True` never removes or truncates the old file before the new content is secured: a missing, unreadable or half-read source leaves the target as it was, and a new file whose copy fails is removed. The new content is finished in a hidden temporary sibling and renamed over the old file (`os.replace` for a local path); a class that cannot rename writes the temporary sibling and then writes it over the target, which truncates it in place for the moment of that second write (the temporary file is kept and named if that fails), and a class whose write stream commits on close is written to directly, since a failed write leaves the old file. 3.14's `Path.copy()` truncates an existing target in place; copying onto the same file, or a directory into itself, raises `OSError(EINVAL)`: two files of this machine (`LocalPath`, a subclass, `pathlib.Path`, a class that names a local file through `filepath`) are compared by identity whatever their classes, a type that answers the overridable `_node_key()` hook (`MemPath`, archive members) by its normalized node, and anything else by `samefile()` where `stat()` has `st_dev`/`st_ino`, otherwise equal paths of one type that the overridable `_same_filesystem()` hook places on the same host or store (two paths of one class that name one server, built separately, count as the same file); `preserve_metadata` defaults to **True** (opposite of 3.14) and only propagates `st_mode`, not timestamps/xattrs: the permission bits of a file, and of each directory of a recursive copy once its children are copied (as `shutil.copytree` and 3.14 do), with the setuid, setgid and sticky bits dropped when source and target are different classes; a recreated symlink (`follow_symlinks=False`) carries only its target text, not its own mode, times or owner; a dangling symlink at the target is written through, as `shutil.copyfile` and 3.14 do, with or without `overwrite`, while a live one counts as existing and `overwrite=True` replaces the link itself | Argument names aligned with 3.14 where cheap; `preserve_metadata=True` default kept for backward compat with this method's pre-existing (pre-3.14-alignment) behavior of always copying the mode bits. Full metadata preservation (timestamps, xattrs) is not implemented. |
74
74
  | `Path.move(target, ...)` | CPython 3.14 `Path.move(target)`: `os.replace` semantics (replaces an existing file), falls back to copy + delete across filesystems, returns the new path | Ours predates 3.14 and keeps `overwrite=False`: an existing target raises `FileExistsError`. Returns whatever `rename()` returns (`None` on the copy fallback). Our own extension: tries `rename()` (by default only onto a path of the same class; `LocalPath` and classes for URIs decide for themselves), falls back to copy+unlink, which recreates a symlink and the links inside a moved tree wherever the target can hold links, so the result does not depend on whether `rename()` was available. Validates before touching the target: a missing source raises `FileNotFoundError`, a file onto a directory raises `IsADirectoryError`, a target that holds the source (`OSError(ENOTEMPTY)`), lies inside it (`OSError(EINVAL)`) or is a link to or from the same file (`OSError(EINVAL)`) is refused with both trees intact, and the same file under another spelling (a case-only rename, another class, a relative `MemPath`, another spelling of the same archive) is renamed in place. "Same" is decided as for `copy()`. Two hard links of one file are two names: an existing target needs `overwrite=True`, and the source name is then removed. `overwrite=True` replaces a local file target atomically (`os.replace`); on other backends it unlinks the target immediately before `rename()`, so a failing rename can still lose it. | N/A -- pure extension, no pathlib method to diverge from. Removing the target before checking the source deleted it on a typo or a locked source. |
75
75
  | `Path.is_junction()` / `is_mount()` / `is_dir_binding()` | `pathlib` has `is_junction()` (3.12+) and `is_mount()`, but only for local paths and with no shared concept | Both are part of the `Path` contract (False by default, answered by `LocalPath`), and `is_dir_binding()` is their union: a directory that is another tree's second NAME rather than part of this one. A junction is the Windows spelling of a bind mount, and neither is a symlink -- `is_symlink()` is False and a non-following stat reports a plain directory. | Walking code needs the distinction: a symlink announces itself and every walker here already declines to follow one, while a binding looks exactly like an ordinary directory and its contents belong to whoever mounted or junctioned it. |
76
76
  | `Path.rm(recursive=, missing_ok=, ignore_error=)` | Not in `pathlib` (closest: `shutil.rmtree`) | Our own extension. Recursive removal deletes bottom-up and uses non-following stat/listing metadata, so a symlink to a directory -- or a binding (`is_dir_binding()`: a Windows junction, a mount point), which a non-following stat still reports as a directory -- is unlinked or `rmdir()`'d rather than traversed -- `rm -r` semantics. `follow_symlinks=`/`follow_binds=` select that per call: `False` (default), `True` (remove what is behind it, then the entry: a followed symlink is unlinked), `None` (leave it), or a callable asked per entry -- the same keyword `stat()`/`walk()`/`copy()` use for the same idea. They decide the path `rm()` is called on as they decide an entry met on the way down. A policy that is not one of those, or a callable's answer that is not, raises `ValueError` and is never offered to `ignore_error`. Every class that overrides `rm()` takes both keywords; a backend with no symlinks or bindings decides nothing with them, and one with a concurrent native walk runs it only for `follow_symlinks=False`. `rmdir()` on a live mount fails, which is the intended answer. | N/A -- pure extension. Non-following recursive deletion avoids deleting through symlinked directory targets and lets backends with metadata-rich listings remove trees without a stat round trip per child. |
@@ -94,7 +94,7 @@ these operations itself always keeps its own implementation.
94
94
  | `FTPPath`, `ZipPath`, `TarPath`, `ArchivePath`: `==`, rooted and relative spellings | `PurePosixPath("a") != PurePosixPath("/a")`, and equal paths are the same file because there is one filesystem | `==` and `hash` compare the names only (as `MemPath` does), so two paths of different servers or archives are equal; `copy()`, `move()` and `PathSyncer` ask the overridable `_same_filesystem()` and `_node_key()` hooks before they call two equal paths one file. `a/b` and `/a/b` are two spellings of one node, because the tree has no working directory and is read from its root: the server root for an `FTPPath`, the archive root for the archive paths (`_node_key()` is equal; `==` is not). An `FTPPath` sends `..` as written, and an archive path applies it to the member name and refuses a name that leaves the archive. | A remote tree and an archive hold names, not a current directory, and carry their connection or handle in the object (the model is `MemPath`). Making `==` compare the server too would make a path unequal to its own copy on a second connection, and would break `is_relative_to()`, which decides by `==`. |
95
95
  | `full_match()` on a generic path, with a bracket expression | `PurePath.full_match` (3.13+) compiles the whole pattern to one regular expression, so a negated bracket such as `[!x]` can match a separator: `PurePosixPath("a/b").full_match("a[!x]b")` and `PurePosixPath("/").full_match("[!a]")` are `True` | `Pathname.full_match` compares one component at a time and a bracket expression never matches a separator: both are `False` | A component-wise automaton keeps `**` linear in the path and the pattern (a regular expression backtracks exponentially on repeated `**`). A pattern that reads a separator out of a bracket is not a pattern anyone writes on purpose. Everything else, the root included, answers as `pathlib` does. |
96
96
  | `copy_into()`, `move_into()`, `replace()` | 3.14: `copy_into(target_dir, **kwargs)` and `move_into(target_dir)` call `copy()`/`move()` and return the new path; `copy()` overwrites an existing file and copies trees. `replace(target)` is `os.replace()` and returns the target | Defined once on `Path` over `copy()`/`move()`, on every backend and version. `copy_into(target_dir, *, overwrite=False, follow_symlinks=True, preserve_metadata=True, recursive=False, ignore_error=None, progress=None, on_error=None)` has the keywords and **the defaults of `copy()`**: an existing target needs `overwrite=True` and a directory needs `recursive=True`. `move_into(target_dir, *, overwrite=False)` has those of `move()`. Both return the new path. `replace(target)` is `move(target, overwrite=True)` and returns `target`: a directory is replaced only when empty (`OSError(ENOTEMPTY)` otherwise, nothing removed). On 3.14 `LocalPath` uses these rather than stdlib's `copy_into`/`move_into`; `LocalPath.replace` stays stdlib's `os.replace()` on every version. | One behaviour on every backend, interpreter and `Path` subclass, and one that cannot overwrite or recurse by accident (3.14's default replaces a file silently). A caller that wants 3.14's behaviour passes `overwrite=True, recursive=True`. |
97
- | `Path.info` and `pathlib.Path(...).copy(target)` onto a `Path` of this library (3.14) | 3.14 `Path.info` is a `PathInfo` kept on the path (its answers are cached for the life of the path object); `pathlib.Path.copy(target)` calls `target._copy_from(source, **kwargs)` on any target with `with_segments()`, so a target without it raises `AttributeError` | `info` is defined on every `Path`, on every version: an object with `exists()`, `is_dir()`, `is_file()` (each with `follow_symlinks=True`) and `is_symlink()`, over at most one following and one non-following `stat()`. A path holds no state, so its answers are cached on the `info` object, not on the path: each `path.info` asks again. `LocalPath` keeps stdlib's `info` on 3.14. `Path._copy_from(source, follow_symlinks=True, preserve_metadata=False)` makes a stdlib path's `copy()` work onto any `Path` of this library (overwrites a file and raises on an existing directory, as 3.14 does; `preserve_metadata` copies the permission bits and the modification time where the class can set them). A class that mixes a stdlib path keeps stdlib's. The private `pathlib` ABCs (`_ReadablePath`, ...) and `__open_rb__`/`__open_wb__` are not implemented. | Code written for 3.14's `PathInfo` and `Path.copy()` accepts these paths. Only the hooks `Path.copy()` reads from its target are carried, since the ABCs are private in 3.14 and have changed between `pathlib-abc` releases. |
97
+ | `Path.info` and `pathlib.Path(...).copy(target)` onto a `Path` of this library (3.14) | 3.14 `Path.info` is a `PathInfo` kept on the path (its answers are cached for the life of the path object); `pathlib.Path.copy(target)` calls `target._copy_from(source, **kwargs)` on any target with `with_segments()`, so a target without it raises `AttributeError` | `info` is defined on every `Path`, on every version: an object with `exists()`, `is_dir()`, `is_file()` (each with `follow_symlinks=True`) and `is_symlink()`, over at most one following and one non-following `stat()`. A path holds no state, so its answers are cached on the `info` object, not on the path: each `path.info` asks again. `LocalPath` keeps stdlib's `info` on 3.14. `Path._copy_from(source, follow_symlinks=True, preserve_metadata=False)` makes a stdlib path's `copy()` work onto any `Path` of this library (overwrites a file and raises on an existing directory, as 3.14 does; a listed child name that is not one safe component raises `ValueError`, as in `copy(recursive=True)`; `preserve_metadata` copies the permission bits and the modification time where the class can set them). A class that mixes a stdlib path keeps stdlib's. The private `pathlib` ABCs (`_ReadablePath`, ...) and `__open_rb__`/`__open_wb__` are not implemented. | Code written for 3.14's `PathInfo` and `Path.copy()` accepts these paths. Only the hooks `Path.copy()` reads from its target are carried, since the ABCs are private in 3.14 and have changed between `pathlib-abc` releases. |
98
98
  | `Path.walk()` and a Windows junction | 3.14: `Path.walk()` (`os.walk` with symlinks as files) lists a junction in `filenames` and does not enter it unless `follow_symlinks=True` | A non-following `walk()` lists a junction in `filenames` and does not enter it, on every version and for every class whose `is_junction()` says so (`LocalPath`, and a class for `file:` URIs). `follow_symlinks=True` enters it, as pathlib does. | A junction, like a symlink, is a second name for another tree; a junction that points at its own parent was walked 64 levels deep, until the path got too long. 3.14's behaviour is the safe one and the only one that can be had on 3.9-3.11, where `Path.walk` does not exist. |
99
99
  | `LocalPath.symlink_to(target)` with a relative `str` target, on Windows | `pathlib` stores the text as given, `../real.txt` included, and Windows cannot resolve a link whose relative target spells its separators `/` | The path class rewrites the text into Windows separators (`..
100
100
  eal.txt`) before the link is made; on POSIX the text is stored as given | A link that cannot be followed is never what a caller wants, and `PathSyncer` recreates links from `as_posix()` text. |
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
  [project]
7
7
  # The distribution and the import package are both `pathapi`.
8
8
  name = "pathapi"
9
- version = "0.1.0"
9
+ version = "0.1.1"
10
10
  authors = [{ name = "Jose A" }]
11
11
  description = "Generic Path Protocol based pathlib"
12
12
  readme = "README.md"
@@ -424,10 +424,16 @@ Base class for I/O paths.
424
424
  `samefile()` where `stat()` carries `st_dev`/`st_ino`, else equal paths
425
425
  of one type that `_same_filesystem()` places together.
426
426
  - The source is opened before the target is touched; a failed stream
427
- removes the partial target. `overwrite=True` removes an existing file
428
- target with `unlink(missing_ok=True)` just before writing: a backend
429
- whose write replaces the file and which cannot delete implements
430
- `unlink()` so that `missing_ok=True` returns without removing.
427
+ removes the partial target it created. `overwrite=True` never removes
428
+ or truncates the old file first: a source that fails half way leaves it
429
+ as it was. The new content is finished in a hidden temporary sibling
430
+ (`.NAME.<hex>.pathapi-tmp`) and renamed over the old file (`os.replace`
431
+ for a local path); where `rename()` is refused it is written over the
432
+ target from the finished copy, and the sibling is kept and named if that
433
+ fails. A class without `rename()` stages the same way when its write
434
+ stream takes bytes in place; one whose stream commits on close (it has
435
+ `discard()`) is written to directly, since a failed write leaves the old
436
+ file.
431
437
  - A recursive copy runs inside `target._write_batch()`: onto a zip
432
438
  archive it is one rewrite of the file, and a copy that raises leaves
433
439
  the archive as it was.
@@ -591,7 +597,9 @@ class CommitOnClose(DiscardOnError, BytesIO):
591
597
  works on 3.14, and returns `target.joinpath()`. It copies `source` (anything
592
598
  with `info`, `iterdir()`, `readlink()`, `open()` and `stat()`) as 3.14 does:
593
599
  an existing file is overwritten, an existing directory raises, children are
594
- copied one by one, `follow_symlinks=False` makes a link through
600
+ copied one by one (a listed name that is not one safe component for this
601
+ path, by `providers.is_safe_child_name()`, is `ValueError` naming it before
602
+ anything is written for it), `follow_symlinks=False` makes a link through
595
603
  `symlink_to()`, and `preserve_metadata` copies the permission bits and the
596
604
  modification time where the class can set them (`chmod()`, `_utime()`).
597
605
  `Path.copy()` of this library does not call it. A class that mixes a
@@ -1150,18 +1158,20 @@ distributions are built on it.
1150
1158
  class HTTPClient:
1151
1159
  headers: dict
1152
1160
  timeout: float | tuple | None
1161
+ deadline: float | None
1153
1162
  user_agent: str
1154
1163
  def __init__(
1155
- self, transport=None, *, headers=None, timeout=(10, 60), user_agent=None,
1164
+ self, transport=None, *, headers=None, timeout=(10, 60), deadline=None,
1165
+ user_agent=None,
1156
1166
  ): ...
1157
1167
  transport
1158
1168
  def request(
1159
1169
  self, method, url, *, data=None, headers=None, stream=False,
1160
- allow_redirects=None, timeout=..., auth=None,
1170
+ allow_redirects=None, timeout=..., deadline=..., auth=None,
1161
1171
  ): ...
1162
1172
  def redirect(
1163
1173
  self, method, url, resp, *, headers=None, stream=False, timeout=...,
1164
- auth=None,
1174
+ deadline=..., auth=None,
1165
1175
  ): ...
1166
1176
  def close(self): ...
1167
1177
 
@@ -1170,7 +1180,7 @@ MAX_REPLY_BYTES = 8388608
1170
1180
  ```
1171
1181
 
1172
1182
  - **`HTTPClient(transport=None, *, headers=None, timeout=(10, 60),
1173
- user_agent=None)`** holds a transport and what every request is sent with.
1183
+ deadline=None, user_agent=None)`** holds a transport and what every request is sent with.
1174
1184
  `transport` is any object with `send()` (the contract below); without one the
1175
1185
  client builds the transport that `PATHAPI_HTTP_TRANSPORT` names (the
1176
1186
  built-in `HTTPTransport` by default) on first use, and `close()` or leaving
@@ -1181,8 +1191,16 @@ MAX_REPLY_BYTES = 8388608
1181
1191
  `pathapi/<version>`) and `Accept: */*`. Every request gets
1182
1192
  `timeout=(10, 60)` seconds (`connect`, `read`) unless the client or the call
1183
1193
  gives one; `timeout=None` waits without limit. `repr()` shows no header.
1194
+ `timeout` bounds each wait; `deadline` (seconds, `None` for none) bounds the
1195
+ whole request, redirects and the reading of the body included: every wait is
1196
+ held to the time that remains, and `TimeoutError` is raised once it has
1197
+ passed (also between reads of a streamed body). A call's `deadline=` replaces
1198
+ the client's; a value that is not a number or `None` is `TypeError`, one
1199
+ that is not positive `ValueError`, before anything is sent. A read already
1200
+ blocked can overrun it by at most the timeout of that wait. It works over
1201
+ any transport: the contract below is unchanged.
1184
1202
  - **`request(method, url, *, data=None, headers=None, stream=False,
1185
- allow_redirects=None, timeout, auth=None)`** returns the final
1203
+ allow_redirects=None, timeout, deadline, auth=None)`** returns the final
1186
1204
  `HTTPResponse`. `data` is `bytes`, `str` (UTF-8), a binary file or an
1187
1205
  iterable of `bytes`; one that is not `bytes` cannot be sent twice, so it is
1188
1206
  not sent again after a redirect or a dropped connection. `auth` is a
@@ -1213,7 +1231,7 @@ MAX_REPLY_BYTES = 8388608
1213
1231
  status and the `Location` (userinfo, query and fragment removed). A body
1214
1232
  that is not `bytes` cannot be sent again, so it raises too.
1215
1233
  - **`redirect(method, url, resp, *, headers=None, stream=False, timeout,
1216
- auth=None)`** sends `method` to where `resp`, the answer to a request for
1234
+ deadline, auth=None)`** sends `method` to where `resp`, the answer to a request for
1217
1235
  `url` made with `allow_redirects=False`, redirects to, and follows what that
1218
1236
  redirects to in turn. `Authorization` is left off whenever the target is
1219
1237
  another scheme, host or port than `url`'s. No usable `Location` is
@@ -2279,7 +2297,8 @@ distribution and `pytest` installed:
2279
2297
  python -m pathapi.http.testing --http-transports requests -q
2280
2298
  ```
2281
2299
 
2282
- Everything after `python -m pathapi.http.testing` goes to pytest.
2300
+ Everything after `python -m pathapi.http.testing` goes to pytest, which is
2301
+ rooted at `suite_directory()` unless the arguments give `--rootdir`.
2283
2302
  `--http-transports` takes names that `transport_names()` lists, separated by
2284
2303
  commas (default `builtin`): each test that depends on the transport runs once
2285
2304
  per name, with `PATHAPI_HTTP_TRANSPORT` set to it, so the clients the
@@ -8,6 +8,7 @@ import typing as _ty
8
8
 
9
9
  from .. import __version__
10
10
  from . import _follow, _settings
11
+ from ._deadline import Deadline, check_deadline
11
12
  from ._headers import check_headers, check_url, has_header, merge_headers
12
13
  from ._response import HTTPResponse
13
14
  from ._status import too_large
@@ -19,7 +20,7 @@ unless it is given `timeout` (`HTTPClient(timeout=...)` or per request);
19
20
  `timeout=None` waits without a limit."""
20
21
 
21
22
  _UNSET = object()
22
- """The `timeout` of a call that did not give one: the client's own."""
23
+ """The `timeout` or `deadline` of a call that did not give one: the client's own."""
23
24
 
24
25
 
25
26
  class HTTPClient:
@@ -35,6 +36,11 @@ class HTTPClient:
35
36
  `user_agent` (`pathapi/<version>` by default) and `Accept: */*`.
36
37
  Every request gets `timeout=DEFAULT_TIMEOUT` (`(10, 60)` seconds) unless
37
38
  `timeout` here or in the call says otherwise (`None` waits without limit).
39
+ `timeout` bounds each wait; `deadline` (seconds, `None` for no limit, the
40
+ default) bounds the whole request: redirects and the reading of the body
41
+ included, each wait's timeout is held to the time that remains, and
42
+ `TimeoutError` is raised once it has passed. A read already blocked can
43
+ overrun it by at most the timeout of that wait.
38
44
 
39
45
  The client, not the transport, follows redirects. `GET`, `HEAD` and
40
46
  `OPTIONS` follow up to 30; a redirect that changes the scheme, host or
@@ -47,6 +53,7 @@ class HTTPClient:
47
53
  __slots__ = (
48
54
  "headers",
49
55
  "timeout",
56
+ "deadline",
50
57
  "user_agent",
51
58
  "_transport",
52
59
  "_built",
@@ -60,6 +67,7 @@ class HTTPClient:
60
67
  *,
61
68
  headers: "_ty.Mapping[str, str] | None" = None,
62
69
  timeout: "float | tuple | None" = DEFAULT_TIMEOUT,
70
+ deadline: "float | None" = None,
63
71
  user_agent: "str | None" = None,
64
72
  ):
65
73
  if transport is not None and not callable(getattr(transport, "send", None)):
@@ -69,6 +77,7 @@ class HTTPClient:
69
77
  self._transport_lock = _threading.Lock()
70
78
  self.headers = dict(headers or {})
71
79
  self.timeout = timeout
80
+ self.deadline = check_deadline(deadline)
72
81
  self.user_agent = user_agent or f"pathapi/{__version__}"
73
82
 
74
83
  def __repr__(self) -> str:
@@ -115,12 +124,24 @@ class HTTPClient:
115
124
  return sent
116
125
 
117
126
  def _dispatch(
118
- self, method, url, sent, data, allow_redirects, stream, timeout, first=None
127
+ self,
128
+ method,
129
+ url,
130
+ sent,
131
+ data,
132
+ allow_redirects,
133
+ stream,
134
+ timeout,
135
+ deadline,
136
+ first=None,
119
137
  ) -> HTTPResponse:
120
138
  check_url(url)
121
139
  check_headers(sent)
122
140
  if timeout is _UNSET:
123
141
  timeout = self.timeout
142
+ deadline = self.deadline if deadline is _UNSET else check_deadline(deadline)
143
+ if deadline is not None:
144
+ deadline = Deadline(deadline, f"{method} {_follow.shown(first or url)}")
124
145
  if isinstance(data, str):
125
146
  data = data.encode("utf-8")
126
147
  transport = self.transport
@@ -129,7 +150,11 @@ class HTTPClient:
129
150
  if method.upper() in _follow.FOLLOWED_METHODS and data is None:
130
151
  if allow_redirects is False and first is None:
131
152
  resp = transport.send(
132
- method, url, headers=sent, timeout=timeout, stream=True
153
+ method,
154
+ url,
155
+ headers=sent,
156
+ timeout=timeout if deadline is None else deadline.cap(timeout),
157
+ stream=True,
133
158
  )
134
159
  else:
135
160
  resp = _follow.send_following(
@@ -140,6 +165,7 @@ class HTTPClient:
140
165
  timeout=timeout,
141
166
  stream=True,
142
167
  first=first,
168
+ deadline=deadline,
143
169
  )
144
170
  else:
145
171
  resp = _follow.send_once(
@@ -150,7 +176,10 @@ class HTTPClient:
150
176
  body=data,
151
177
  timeout=timeout,
152
178
  stream=True,
179
+ deadline=deadline,
153
180
  )
181
+ if deadline is not None:
182
+ deadline.bound(resp)
154
183
  if not stream:
155
184
  limit = _settings.MAX_REPLY_BYTES
156
185
  resp._buffered(limit, too_large(_follow.shown(url), limit))
@@ -166,6 +195,7 @@ class HTTPClient:
166
195
  stream: bool = False,
167
196
  allow_redirects: "bool | None" = None,
168
197
  timeout=_UNSET,
198
+ deadline=_UNSET,
169
199
  auth: "tuple[str | bytes, str | bytes] | str | None" = None,
170
200
  ) -> HTTPResponse:
171
201
  """Send `method` to `url` and return the final response. A `data` that
@@ -182,7 +212,9 @@ class HTTPClient:
182
212
  body over `MAX_REPLY_BYTES` is `OSError(EFBIG)`; with `stream=True` the
183
213
  caller reads it, and closes the response."""
184
214
  sent = self._sent_headers(headers, auth)
185
- return self._dispatch(method, url, sent, data, allow_redirects, stream, timeout)
215
+ return self._dispatch(
216
+ method, url, sent, data, allow_redirects, stream, timeout, deadline
217
+ )
186
218
 
187
219
  def redirect(
188
220
  self,
@@ -193,6 +225,7 @@ class HTTPClient:
193
225
  headers: "_ty.Mapping[str, str] | None" = None,
194
226
  stream: bool = False,
195
227
  timeout=_UNSET,
228
+ deadline=_UNSET,
196
229
  auth: "tuple[str | bytes, str | bytes] | str | None" = None,
197
230
  ) -> HTTPResponse:
198
231
  """Send `method` to where `resp`, the answer to a request for `url`,
@@ -207,5 +240,5 @@ class HTTPClient:
207
240
  method, url, resp, None, "no usable Location"
208
241
  )
209
242
  return self._dispatch(
210
- method, location, sent, None, None, stream, timeout, first=url
243
+ method, location, sent, None, None, stream, timeout, deadline, first=url
211
244
  )
@@ -0,0 +1,74 @@
1
+ """A time limit for a whole request, above the per-wait timeout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math as _math
6
+ import time as _time
7
+
8
+
9
+ def check_deadline(deadline) -> "float | None":
10
+ """`deadline` as seconds, or `None` for no limit. `TypeError` for anything
11
+ but a number or `None`, `ValueError` for a number that is not positive."""
12
+ if deadline is None:
13
+ return None
14
+ if isinstance(deadline, bool) or not isinstance(deadline, (int, float)):
15
+ raise TypeError("a deadline is a number of seconds or None")
16
+ if _math.isnan(deadline) or deadline <= 0:
17
+ raise ValueError("a deadline is a positive number of seconds, or None")
18
+ return float(deadline)
19
+
20
+
21
+ class Deadline:
22
+ """The moment a request must be finished by. It lives for one request,
23
+ redirects included."""
24
+
25
+ __slots__ = ("_seconds", "_at", "_what")
26
+
27
+ def __init__(self, seconds: float, what: str):
28
+ self._seconds = seconds
29
+ self._at = _time.monotonic() + seconds
30
+ self._what = what
31
+
32
+ def check(self) -> float:
33
+ """The seconds that remain; `TimeoutError` once there are none."""
34
+ left = self._at - _time.monotonic()
35
+ if left <= 0:
36
+ raise TimeoutError(
37
+ f"the {self._seconds:g} s deadline of {self._what} passed"
38
+ )
39
+ return left
40
+
41
+ def cap(self, timeout):
42
+ """`timeout` (a number, `None` or a `(connect, read)` pair) with each
43
+ wait held to the time that remains."""
44
+ left = self.check()
45
+ if isinstance(timeout, tuple):
46
+ return tuple(self._capped(part, left) for part in timeout)
47
+ return self._capped(timeout, left)
48
+
49
+ @staticmethod
50
+ def _capped(wait, left):
51
+ return left if wait is None else min(wait, left)
52
+
53
+ def bound(self, response):
54
+ """Make `response` check the deadline before each read of its body."""
55
+ source = getattr(response, "_source", None)
56
+ if source is not None:
57
+ response._source = _Bounded(source, self)
58
+ return response
59
+
60
+
61
+ class _Bounded:
62
+ """A response body that raises `TimeoutError` instead of reading once the
63
+ deadline has passed."""
64
+
65
+ def __init__(self, source, deadline: Deadline):
66
+ self._source = source
67
+ self._deadline = deadline
68
+
69
+ def read(self, amount: int) -> bytes:
70
+ self._deadline.check()
71
+ return self._source.read(amount)
72
+
73
+ def close(self) -> None:
74
+ self._source.close()
@@ -137,6 +137,11 @@ def _discard(resp: HTTPResponse) -> None:
137
137
  resp.close()
138
138
 
139
139
 
140
+ def _capped(timeout, deadline):
141
+ """`timeout` held to what remains of `deadline`; as it is without one."""
142
+ return timeout if deadline is None else deadline.cap(timeout)
143
+
144
+
140
145
  def can_resend_body(body) -> bool:
141
146
  return body is None or isinstance(body, (bytes, bytearray, memoryview))
142
147
 
@@ -150,6 +155,7 @@ def send_following(
150
155
  timeout,
151
156
  stream: bool,
152
157
  first: "str | None" = None,
158
+ deadline=None,
153
159
  ) -> HTTPResponse:
154
160
  """Send a request that carries no body and follow its redirects, up to
155
161
  `MAX_REDIRECTS`. `Authorization` goes to the origin of `first` (the URL
@@ -163,7 +169,12 @@ def send_following(
163
169
  if origin(current) != home:
164
170
  headers = drop_header(headers, "Authorization")
165
171
  resp = transport.send(
166
- method, current, headers=headers, body=None, timeout=timeout, stream=stream
172
+ method,
173
+ current,
174
+ headers=headers,
175
+ body=None,
176
+ timeout=_capped(timeout, deadline),
177
+ stream=stream,
167
178
  )
168
179
  if not is_redirect(resp):
169
180
  return resp
@@ -193,13 +204,19 @@ def send_once(
193
204
  body,
194
205
  timeout,
195
206
  stream: bool,
207
+ deadline=None,
196
208
  ) -> HTTPResponse:
197
209
  """Send a request that must not be redirected the way a browser does: a
198
210
  redirect with a status in `RESENT_STATUSES` to the same scheme, host and
199
211
  port is sent once more with the same method and body, and any other 3xx
200
212
  raises `OSError(EIO)` naming the status and the `Location`."""
201
213
  resp = transport.send(
202
- method, url, headers=headers, body=body, timeout=timeout, stream=stream
214
+ method,
215
+ url,
216
+ headers=headers,
217
+ body=body,
218
+ timeout=_capped(timeout, deadline),
219
+ stream=stream,
203
220
  )
204
221
  if not 300 <= resp.status < 400:
205
222
  return resp
@@ -220,7 +237,12 @@ def send_once(
220
237
  else:
221
238
  _discard(resp)
222
239
  resp = transport.send(
223
- method, location, headers=headers, body=body, timeout=timeout, stream=stream
240
+ method,
241
+ location,
242
+ headers=headers,
243
+ body=body,
244
+ timeout=_capped(timeout, deadline),
245
+ stream=stream,
224
246
  )
225
247
  if not 300 <= resp.status < 400:
226
248
  return resp
@@ -64,7 +64,8 @@ def suite_directory() -> _pathlib.Path:
64
64
 
65
65
  def run(arguments: "_ty.Sequence[str] | None" = None) -> int:
66
66
  """Runs `MODULES` with pytest and `arguments` (pytest options, such as
67
- `--http-transports requests`) and returns the exit status. Raises
67
+ `--http-transports requests`) and returns the exit status. pytest's
68
+ rootdir is `suite_directory()` unless `arguments` give `--rootdir`. Raises
68
69
  `ImportError` when pytest is not installed."""
69
70
  try:
70
71
  import pytest
@@ -77,4 +78,9 @@ def run(arguments: "_ty.Sequence[str] | None" = None) -> int:
77
78
  # The files go first: with an option and its value ahead of them, pytest
78
79
  # 8 reads the value as the first path and loads no `conftest.py`, so the
79
80
  # option `--http-transports` is not known.
80
- return int(pytest.main([*files, *(arguments or ())]))
81
+ arguments = list(arguments or ())
82
+ # pytest roots node ids at its rootdir; a checkout on another drive than
83
+ # the installed suite would list them without their file.
84
+ if not any(a == "--rootdir" or a.startswith("--rootdir=") for a in arguments):
85
+ files.append(f"--rootdir={directory}")
86
+ return int(pytest.main([*files, *arguments]))
@@ -0,0 +1,83 @@
1
+ """The recursive part of `copy()`: a directory and what is listed in it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno as _errno
6
+ import typing as _ty
7
+
8
+ from .. import _names
9
+ from ._relation import contains
10
+ from ._removal import _write_batch_of
11
+ from ._transfer import _copy_mode, _require_safe_child
12
+
13
+ if _ty.TYPE_CHECKING:
14
+ from ..path import Path
15
+
16
+
17
+ def _copy_tree(
18
+ src: "Path",
19
+ target: "Path",
20
+ src_stat,
21
+ target_absent: bool,
22
+ *,
23
+ overwrite,
24
+ follow_symlinks,
25
+ preserve_metadata,
26
+ ignore_error,
27
+ progress,
28
+ offering,
29
+ ) -> None:
30
+ """Copy the directory `src` and what it holds to `target`, each child
31
+ through its own `copy(recursive=True)`; `copy()` passes its keywords on."""
32
+ if contains(src, target):
33
+ # Checked before anything is created: the new directory would
34
+ # be listed and copied into itself without end.
35
+ raise OSError(
36
+ _errno.EINVAL,
37
+ "Cannot copy a directory into itself",
38
+ str(target),
39
+ )
40
+ # The writes below may be committed together by a backend that
41
+ # pays for each one (see `_write_batch`).
42
+ with _write_batch_of(target):
43
+ # Listed before the target exists, as shutil.copytree does.
44
+ children = list(src.iterdir())
45
+ if not target_absent and target.exists():
46
+ if not target.is_dir():
47
+ raise FileExistsError(target)
48
+ if not overwrite:
49
+ raise FileExistsError(target)
50
+ else:
51
+ target.mkdir()
52
+ windows_target = _names.is_windows_flavoured(target)
53
+ for child in children:
54
+ try:
55
+ # A listed name is untrusted: one that is not a single component inside
56
+ # `target` is refused (see the child-name paragraph of the docstring).
57
+ _require_safe_child(target, child.name, windows_target)
58
+ child.copy(
59
+ target / child.name,
60
+ overwrite=overwrite,
61
+ follow_symlinks=follow_symlinks,
62
+ preserve_metadata=preserve_metadata,
63
+ recursive=True,
64
+ ignore_error=ignore_error,
65
+ progress=progress,
66
+ # Passed on only when given, so a `copy()` override
67
+ # without the keyword still works as a child.
68
+ **({} if offering is None else {"on_error": offering}),
69
+ )
70
+ except Exception as e:
71
+ if offering is not None:
72
+ if not offering(e, child):
73
+ raise
74
+ elif callable(ignore_error):
75
+ # A notify-and-suppress hook: its return value is not
76
+ # consulted, so `errors.append` works.
77
+ ignore_error(e)
78
+ elif not ignore_error:
79
+ raise
80
+ if preserve_metadata:
81
+ # After the children: a read-only directory could not take
82
+ # them, and its mode is only settled once they are in.
83
+ _copy_mode(src, target, follow_symlinks, src_stat)