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.
- {pathapi-0.1.0 → pathapi-0.1.1}/CHANGELOG.md +35 -1
- {pathapi-0.1.0 → pathapi-0.1.1}/PKG-INFO +1 -1
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/divergences.md +2 -2
- {pathapi-0.1.0 → pathapi-0.1.1}/pyproject.toml +1 -1
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/AGENTS.md +31 -12
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_client.py +38 -5
- pathapi-0.1.1/src/pathapi/http/_deadline.py +74 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_follow.py +25 -3
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/testing/__init__.py +8 -2
- pathapi-0.1.1/src/pathapi/protocols/_copytree.py +83 -0
- pathapi-0.1.1/src/pathapi/protocols/_removing.py +105 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_transfer.py +9 -0
- pathapi-0.1.1/src/pathapi/protocols/_writing.py +153 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/transfer.py +42 -117
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/tree.py +6 -80
- pathapi-0.1.1/src/pathapi/sync/_entry.py +544 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_syncer.py +8 -501
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/mutation/mutants.py +118 -46
- pathapi-0.1.1/tests/test_copy_from_names.py +83 -0
- pathapi-0.1.1/tests/test_copy_overwrite.py +141 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_http_testing.py +24 -0
- pathapi-0.1.1/tests/transport/test_client_deadline.py +184 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/.gitignore +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/LICENSE +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/README.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/archive.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/checksum.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/exceptions.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/ftp.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/glob.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/http.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/mempath.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/path.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/protocols.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/providers.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/sync.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/api/testing.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/benchmarks.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/changelog.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/guides/extending.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/docs/index.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/examples/archive_members.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/examples/ftp_listing.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/examples/http_request.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/examples/local_and_mem.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/mkdocs.yml +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/__init__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_commit.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_discard.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_errorpolicy.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_filestat.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_lru.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_names.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_permissions.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_stubs.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/_tree.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/__init__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_backend.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_names.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_pack.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_path.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_snapshot.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_tar.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_write.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_zip.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/archive/_zipentries.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/checksum.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/exceptions.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/fspath.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/ftp/__init__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/ftp/_backend.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/ftp/_facts.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/ftp/_path.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/__init__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/_entries.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/_patterns.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/_recursion.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/glob/_selection.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/__init__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_body.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_builtin.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_errors.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_headers.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_pool.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_proxies.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_response.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_settings.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_status.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/_transports.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/http/testing/__main__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/mempath.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/__init__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_info.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_path.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_pure.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_relation.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/path/_transfer.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/__init__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_bundles.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_errors.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_info.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_relation.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_removal.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/_support.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/checksum.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/fs.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/io.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/links.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/pure.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/protocols/resolve.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/providers.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/py.typed +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/__init__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_compare.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_events.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_links.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_offer.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_paths.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/sync/_replace.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/testing/__init__.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/testing/_contract.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/testing/_fixtures.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/src/pathapi/testing/_read.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/capabilities.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/census.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/conftest.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/ftp_loopback.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/hermetic.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/mutation/README.md +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/mutation/mutation_check.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/mutation/run.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/probes/probe_environment_leak.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/probes/probe_leaks_a_socket.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/probes/probe_session_environment.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_batch.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_batch_cost.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_damage.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_helpers.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_lazy.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_link_listing.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_links.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_names.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_parity.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_path.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_rewrite.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_safety.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_scale.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_archive_tree.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_census.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_checksum.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_commit_on_close.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_composed_protocols.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_consumer_needs.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_contract.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_contract_helpers.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_copy_move_fidelity.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_copy_round_trips.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_destructive_safety.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_documented_examples.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_error_predicate.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_examples_skip.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_exceptions.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_filestat.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_end_to_end.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_path.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_security.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_utime.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_ftp_wire.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_generic_pure_parity.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_glob.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_glob_edges.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_glob_parity.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_hermetic.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_io_parity.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_listing_hints.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_listing_names.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_local.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_local_stat_errors.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_low_core.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_low_sync.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_mempath.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_move_stats.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_mro_precedence.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_packaging.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_parity_io.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_parity_pure.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_path_gaps.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_path_info.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_path_moving.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_pathname.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_properties.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_protocol_composition.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_protocol_support.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_provider_seam.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_providers.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_public_surface.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_pure_parity.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_resolve.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_same_filesystem.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_shipped_header.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_smoke.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_symlink_target.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_sync.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_sync_cost.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_sync_mtime.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_sync_safety.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_walk.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_walk_junction.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/test_write_discard.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/conftest.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_default.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_errors.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_peer_text.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_redirect_origin.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_redirects.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_reply_limits.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_urls.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_client_write_redirects.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_body.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_pool.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_prepare.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_proxy.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_registry.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_send.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/test_transport_tls.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/transport_distribution.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/transport_fakes.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/transport_guard.py +0 -0
- {pathapi-0.1.0 → pathapi-0.1.1}/tests/transport/transport_wire.py +0 -0
- {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.
|
|
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
|
|
@@ -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`
|
|
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.
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
`
|
|
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
|
|
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),
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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)
|