zipwire 0.2.0__tar.gz → 0.4.0__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 (55) hide show
  1. {zipwire-0.2.0 → zipwire-0.4.0}/.github/dependabot.yml +2 -0
  2. {zipwire-0.2.0 → zipwire-0.4.0}/.github/workflows/ci.yml +8 -8
  3. {zipwire-0.2.0 → zipwire-0.4.0}/.github/workflows/codeql.yml +3 -3
  4. {zipwire-0.2.0 → zipwire-0.4.0}/.github/workflows/release.yml +4 -4
  5. {zipwire-0.2.0 → zipwire-0.4.0}/.github/workflows/scorecard.yml +3 -3
  6. {zipwire-0.2.0 → zipwire-0.4.0}/PKG-INFO +49 -4
  7. {zipwire-0.2.0 → zipwire-0.4.0}/README.md +47 -2
  8. {zipwire-0.2.0 → zipwire-0.4.0}/docs/api.rst +23 -0
  9. {zipwire-0.2.0 → zipwire-0.4.0}/docs/backends.rst +61 -0
  10. {zipwire-0.2.0 → zipwire-0.4.0}/docs/quickstart.rst +82 -1
  11. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/__init__.py +42 -1
  12. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/__main__.py +1 -1
  13. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/_async.py +23 -74
  14. zipwire-0.4.0/src/zipwire/_base.py +97 -0
  15. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/_sync.py +21 -70
  16. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/_version.py +2 -2
  17. zipwire-0.4.0/src/zipwire/_wheel.py +222 -0
  18. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/backends/__init__.py +4 -0
  19. zipwire-0.4.0/src/zipwire/backends/_file.py +206 -0
  20. {zipwire-0.2.0 → zipwire-0.4.0}/tests/test_async.py +25 -24
  21. {zipwire-0.2.0 → zipwire-0.4.0}/tests/test_backends.py +149 -2
  22. {zipwire-0.2.0 → zipwire-0.4.0}/tests/test_integration.py +11 -13
  23. zipwire-0.4.0/tests/test_wheel.py +268 -0
  24. {zipwire-0.2.0 → zipwire-0.4.0}/uv.lock +106 -106
  25. {zipwire-0.2.0 → zipwire-0.4.0}/.gitignore +0 -0
  26. {zipwire-0.2.0 → zipwire-0.4.0}/.readthedocs.yaml +0 -0
  27. {zipwire-0.2.0 → zipwire-0.4.0}/AGENTS.md +0 -0
  28. {zipwire-0.2.0 → zipwire-0.4.0}/CLAUDE.md +0 -0
  29. {zipwire-0.2.0 → zipwire-0.4.0}/LICENSE +0 -0
  30. {zipwire-0.2.0 → zipwire-0.4.0}/SECURITY.md +0 -0
  31. {zipwire-0.2.0 → zipwire-0.4.0}/docs/Makefile +0 -0
  32. {zipwire-0.2.0 → zipwire-0.4.0}/docs/conf.py +0 -0
  33. {zipwire-0.2.0 → zipwire-0.4.0}/docs/index.rst +0 -0
  34. {zipwire-0.2.0 → zipwire-0.4.0}/docs/requirements.txt +0 -0
  35. {zipwire-0.2.0 → zipwire-0.4.0}/docs/security.rst +0 -0
  36. {zipwire-0.2.0 → zipwire-0.4.0}/pyproject.toml +0 -0
  37. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/_constants.py +0 -0
  38. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/_decompress.py +0 -0
  39. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/_errors.py +0 -0
  40. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/_parser.py +0 -0
  41. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/_types.py +0 -0
  42. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/_zipinfo.py +0 -0
  43. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/backends/_aiohttp.py +0 -0
  44. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/backends/_httpx2.py +0 -0
  45. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/backends/_requests.py +0 -0
  46. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/backends/_urllib3.py +0 -0
  47. {zipwire-0.2.0 → zipwire-0.4.0}/src/zipwire/py.typed +0 -0
  48. {zipwire-0.2.0 → zipwire-0.4.0}/tests/__init__.py +0 -0
  49. {zipwire-0.2.0 → zipwire-0.4.0}/tests/conftest.py +0 -0
  50. {zipwire-0.2.0 → zipwire-0.4.0}/tests/test_cli.py +0 -0
  51. {zipwire-0.2.0 → zipwire-0.4.0}/tests/test_decompress.py +0 -0
  52. {zipwire-0.2.0 → zipwire-0.4.0}/tests/test_parser.py +0 -0
  53. {zipwire-0.2.0 → zipwire-0.4.0}/tests/test_sync.py +0 -0
  54. {zipwire-0.2.0 → zipwire-0.4.0}/tests/test_zipinfo.py +0 -0
  55. {zipwire-0.2.0 → zipwire-0.4.0}/tox.ini +0 -0
@@ -8,3 +8,5 @@ updates:
8
8
  codeql:
9
9
  patterns:
10
10
  - "github/codeql-action/*"
11
+ cooldown:
12
+ default-days: 7
@@ -18,10 +18,10 @@ jobs:
18
18
  permissions:
19
19
  contents: read
20
20
  steps:
21
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
22
  with:
23
23
  persist-credentials: false
24
- - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
24
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
25
25
  with:
26
26
  enable-cache: true
27
27
  - run: uvx ruff check src tests
@@ -37,10 +37,10 @@ jobs:
37
37
  python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
38
38
  continue-on-error: ${{ matrix.python-version == '3.15' }}
39
39
  steps:
40
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
40
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
41
41
  with:
42
42
  persist-credentials: false
43
- - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
43
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
44
44
  with:
45
45
  enable-cache: true
46
46
  - run: uv tool install --python ${{ matrix.python-version }} tox --with tox-uv --with tox-gh
@@ -58,10 +58,10 @@ jobs:
58
58
  permissions:
59
59
  contents: read
60
60
  steps:
61
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
61
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
62
62
  with:
63
63
  persist-credentials: false
64
- - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
64
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
65
65
  with:
66
66
  enable-cache: true
67
67
  - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -78,10 +78,10 @@ jobs:
78
78
  permissions:
79
79
  contents: read
80
80
  steps:
81
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
81
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
82
82
  with:
83
83
  persist-credentials: false
84
- - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
84
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
85
85
  with:
86
86
  enable-cache: true
87
87
  - run: uv sync --group docs
@@ -16,10 +16,10 @@ jobs:
16
16
  permissions:
17
17
  security-events: write
18
18
  steps:
19
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
19
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
20
  with:
21
21
  persist-credentials: false
22
- - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
22
+ - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
23
23
  with:
24
24
  languages: python
25
- - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
25
+ - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
@@ -12,12 +12,12 @@ jobs:
12
12
  permissions:
13
13
  contents: read
14
14
  steps:
15
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
15
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
16
16
  with:
17
17
  persist-credentials: false
18
- - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
18
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
19
19
  with:
20
- enable-cache: true
20
+ enable-cache: false
21
21
  - run: uv build
22
22
  - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
23
23
  with:
@@ -36,6 +36,6 @@ jobs:
36
36
  with:
37
37
  name: dist
38
38
  path: dist/
39
- - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
39
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
40
40
  with:
41
41
  attestations: true
@@ -15,10 +15,10 @@ jobs:
15
15
  security-events: write
16
16
  id-token: write
17
17
  steps:
18
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
18
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
19
19
  with:
20
20
  persist-credentials: false
21
- - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
21
+ - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
22
22
  with:
23
23
  results_file: results.sarif
24
24
  results_format: sarif
@@ -28,6 +28,6 @@ jobs:
28
28
  name: scorecard-results
29
29
  path: results.sarif
30
30
  retention-days: 5
31
- - uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
31
+ - uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
32
32
  with:
33
33
  sarif_file: results.sarif
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: zipwire
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Read and extract files from remote ZIP archives over HTTP range requests
5
5
  Project-URL: Homepage, https://github.com/tiran/zipwire
6
6
  Project-URL: Documentation, https://zipwire.readthedocs.io/
@@ -60,8 +60,13 @@ CDNs, object stores, and static file servers do.
60
60
  memory usage low even for large entries.
61
61
  - **Sync and async** - `SyncRemoteZip` for synchronous code,
62
62
  `AsyncRemoteZip` with `await`/`async with` for asyncio.
63
+ - **Wheel metadata** - `SyncRemoteWheel` / `AsyncRemoteWheel` read a Python
64
+ wheel's `.dist-info` (METADATA, WHEEL, RECORD) straight from PyPI in a single
65
+ adaptive tail request, without downloading the wheel.
63
66
  - **ZIP64** - supports archives and entries larger than 4 GiB.
64
67
  - **Pluggable backends** - bring your own HTTP library (see below).
68
+ - **Local files** - `FileReader` / `AsyncFileReader` open an archive on disk
69
+ through the exact same API, no HTTP server required.
65
70
 
66
71
  ## Installation and backends
67
72
 
@@ -81,11 +86,36 @@ pip install zipwire[httpx2]
81
86
  | requests | `RequestsReader` | sync | 1.1 | `requests` |
82
87
  | aiohttp | `AiohttpReader` | async | 1.1 | `aiohttp` |
83
88
 
84
- Every backend accepts an optional pre-configured client or session so you can
85
- share connection pools, authentication, and retry configuration.
89
+ Every HTTP backend accepts an optional pre-configured client or session so you
90
+ can share connection pools, authentication, and retry configuration.
91
+
92
+ For archives that already live on the local filesystem, `FileReader` and
93
+ `AsyncFileReader` (in `zipwire.backends`, no extra dependency) satisfy the same
94
+ reader protocols - see the local-file example below.
86
95
 
87
96
  ## Examples
88
97
 
98
+ ### Read Python wheel metadata without downloading the wheel
99
+
100
+ A common use case: fetch a wheel's `METADATA`, `WHEEL`, or `RECORD` from PyPI
101
+ without downloading the (often huge) wheel itself. `SyncRemoteWheel` and
102
+ `AsyncRemoteWheel` parse the wheel URL to locate the `.dist-info` directory and
103
+ fetch an adaptive tail, so metadata entries are served from memory without
104
+ extra HTTP requests:
105
+
106
+ ```python
107
+ from zipwire import SyncRemoteWheel
108
+ from zipwire.backends import Urllib3Reader
109
+
110
+ url = "https://files.pythonhosted.org/.../requests-2.32.3-py3-none-any.whl"
111
+ with SyncRemoteWheel(Urllib3Reader(url)) as whl:
112
+ print(whl.read(whl.metadata_name).decode())
113
+ ```
114
+
115
+ This optimization relies on the [recommended wheel layout](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#recommended-archiver-features)
116
+ of placing `.dist-info` at the end of the archive; wheels built otherwise still
117
+ work, falling back to a normal range request per entry.
118
+
89
119
  ### Sync - list files and read one
90
120
 
91
121
  ```python
@@ -130,6 +160,21 @@ async def main():
130
160
  asyncio.run(main())
131
161
  ```
132
162
 
163
+ ### Local archive - same API, no HTTP
164
+
165
+ `FileReader` opens an archive from disk through the same interface, accepting a
166
+ path or a `file://` URI. `AsyncFileReader` is the async counterpart. This lets
167
+ code that already uses zipwire handle local and remote archives the same way,
168
+ without special-casing either.
169
+
170
+ ```python
171
+ from zipwire import SyncRemoteZip
172
+ from zipwire.backends import FileReader
173
+
174
+ with SyncRemoteZip(FileReader("/path/to/archive.zip")) as rz:
175
+ data = rz.read("path/to/file.txt")
176
+ ```
177
+
133
178
  ## License
134
179
 
135
180
  Apache-2.0
@@ -25,8 +25,13 @@ CDNs, object stores, and static file servers do.
25
25
  memory usage low even for large entries.
26
26
  - **Sync and async** - `SyncRemoteZip` for synchronous code,
27
27
  `AsyncRemoteZip` with `await`/`async with` for asyncio.
28
+ - **Wheel metadata** - `SyncRemoteWheel` / `AsyncRemoteWheel` read a Python
29
+ wheel's `.dist-info` (METADATA, WHEEL, RECORD) straight from PyPI in a single
30
+ adaptive tail request, without downloading the wheel.
28
31
  - **ZIP64** - supports archives and entries larger than 4 GiB.
29
32
  - **Pluggable backends** - bring your own HTTP library (see below).
33
+ - **Local files** - `FileReader` / `AsyncFileReader` open an archive on disk
34
+ through the exact same API, no HTTP server required.
30
35
 
31
36
  ## Installation and backends
32
37
 
@@ -46,11 +51,36 @@ pip install zipwire[httpx2]
46
51
  | requests | `RequestsReader` | sync | 1.1 | `requests` |
47
52
  | aiohttp | `AiohttpReader` | async | 1.1 | `aiohttp` |
48
53
 
49
- Every backend accepts an optional pre-configured client or session so you can
50
- share connection pools, authentication, and retry configuration.
54
+ Every HTTP backend accepts an optional pre-configured client or session so you
55
+ can share connection pools, authentication, and retry configuration.
56
+
57
+ For archives that already live on the local filesystem, `FileReader` and
58
+ `AsyncFileReader` (in `zipwire.backends`, no extra dependency) satisfy the same
59
+ reader protocols - see the local-file example below.
51
60
 
52
61
  ## Examples
53
62
 
63
+ ### Read Python wheel metadata without downloading the wheel
64
+
65
+ A common use case: fetch a wheel's `METADATA`, `WHEEL`, or `RECORD` from PyPI
66
+ without downloading the (often huge) wheel itself. `SyncRemoteWheel` and
67
+ `AsyncRemoteWheel` parse the wheel URL to locate the `.dist-info` directory and
68
+ fetch an adaptive tail, so metadata entries are served from memory without
69
+ extra HTTP requests:
70
+
71
+ ```python
72
+ from zipwire import SyncRemoteWheel
73
+ from zipwire.backends import Urllib3Reader
74
+
75
+ url = "https://files.pythonhosted.org/.../requests-2.32.3-py3-none-any.whl"
76
+ with SyncRemoteWheel(Urllib3Reader(url)) as whl:
77
+ print(whl.read(whl.metadata_name).decode())
78
+ ```
79
+
80
+ This optimization relies on the [recommended wheel layout](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#recommended-archiver-features)
81
+ of placing `.dist-info` at the end of the archive; wheels built otherwise still
82
+ work, falling back to a normal range request per entry.
83
+
54
84
  ### Sync - list files and read one
55
85
 
56
86
  ```python
@@ -95,6 +125,21 @@ async def main():
95
125
  asyncio.run(main())
96
126
  ```
97
127
 
128
+ ### Local archive - same API, no HTTP
129
+
130
+ `FileReader` opens an archive from disk through the same interface, accepting a
131
+ path or a `file://` URI. `AsyncFileReader` is the async counterpart. This lets
132
+ code that already uses zipwire handle local and remote archives the same way,
133
+ without special-casing either.
134
+
135
+ ```python
136
+ from zipwire import SyncRemoteZip
137
+ from zipwire.backends import FileReader
138
+
139
+ with SyncRemoteZip(FileReader("/path/to/archive.zip")) as rz:
140
+ data = rz.read("path/to/file.txt")
141
+ ```
142
+
98
143
  ## License
99
144
 
100
145
  Apache-2.0
@@ -18,12 +18,27 @@ Core
18
18
 
19
19
  .. autoclass:: zipwire.SyncRemoteZip
20
20
  :members:
21
+ :inherited-members:
21
22
  :special-members: __enter__, __exit__
22
23
 
23
24
  .. autoclass:: zipwire.AsyncRemoteZip
24
25
  :members:
26
+ :inherited-members:
25
27
  :special-members: __aenter__, __aexit__
26
28
 
29
+ Wheel
30
+ -----
31
+
32
+ .. autoclass:: zipwire.SyncRemoteWheel
33
+ :members:
34
+ :inherited-members:
35
+ :show-inheritance:
36
+
37
+ .. autoclass:: zipwire.AsyncRemoteWheel
38
+ :members:
39
+ :inherited-members:
40
+ :show-inheritance:
41
+
27
42
  .. autoclass:: zipwire.RemoteZipInfo
28
43
  :show-inheritance:
29
44
 
@@ -63,6 +78,14 @@ Backends
63
78
  .. autoclass:: zipwire.backends._aiohttp.AiohttpReader
64
79
  :members:
65
80
 
81
+ .. autoclass:: zipwire.backends._file.FileReader
82
+ :members:
83
+ :special-members: __enter__, __exit__
84
+
85
+ .. autoclass:: zipwire.backends._file.AsyncFileReader
86
+ :members:
87
+ :special-members: __aenter__, __aexit__
88
+
66
89
  Exceptions
67
90
  ----------
68
91
 
@@ -43,6 +43,16 @@ Available backends
43
43
  - async
44
44
  - 1.1
45
45
  - ``aiohttp``
46
+ * - local files
47
+ - :class:`~zipwire.backends.FileReader`
48
+ - sync
49
+ - --
50
+ - *(included)*
51
+ * - local files
52
+ - :class:`~zipwire.backends.AsyncFileReader`
53
+ - async
54
+ - --
55
+ - *(included)*
46
56
 
47
57
  Choosing a backend
48
58
  ------------------
@@ -60,6 +70,57 @@ Choosing a backend
60
70
  - **Requests integration** - use :class:`~zipwire.backends.RequestsReader`
61
71
  if your project already uses ``requests`` and you want to share sessions,
62
72
  authentication, or retry configuration.
73
+ - **Local archives** - use :class:`~zipwire.backends.FileReader` or
74
+ :class:`~zipwire.backends.AsyncFileReader` to read a ZIP or wheel that
75
+ already lives on the local filesystem. No extra dependency is required.
76
+
77
+ Reading local files
78
+ -------------------
79
+
80
+ :class:`~zipwire.backends.FileReader` and
81
+ :class:`~zipwire.backends.AsyncFileReader` back the same
82
+ :class:`~zipwire.SyncRemoteZip` / :class:`~zipwire.AsyncRemoteZip` API with
83
+ local file IO, so identical code opens a local or a remote archive. Instead
84
+ of an HTTP round-trip, :meth:`~zipwire.backends.FileReader.head` synthesises
85
+ ``Content-Length`` / ``Accept-Ranges`` from ``os.fstat`` on the open handle
86
+ (the size is cached, assuming the file does not change), and range reads
87
+ seek into a lazily-opened file handle. A missing path raises
88
+ :exc:`FileNotFoundError`, mirroring how a network 404 surfaces as
89
+ :exc:`OSError`.
90
+
91
+ Both accept either a path (``str`` / :class:`os.PathLike`) or a ``file://``
92
+ URI via :meth:`~zipwire.backends.FileReader.from_uri` (percent-decoded, with
93
+ an empty or ``localhost`` host).
94
+
95
+ .. code-block:: python
96
+
97
+ from zipwire import SyncRemoteZip
98
+ from zipwire.backends import FileReader
99
+
100
+ with SyncRemoteZip(FileReader("/path/to/archive.zip")) as rz:
101
+ data = rz.read("file.txt")
102
+
103
+ reader = FileReader.from_uri("file:///path/to/archive.zip")
104
+
105
+ Regular files do not support true non-blocking IO, so
106
+ :class:`~zipwire.backends.AsyncFileReader` offloads every blocking call to a
107
+ worker thread via :func:`asyncio.to_thread` -- the same approach libraries
108
+ such as ``aiofiles`` use internally, without the extra dependency.
109
+
110
+ .. code-block:: python
111
+
112
+ import asyncio
113
+
114
+ from zipwire import AsyncRemoteZip
115
+ from zipwire.backends import AsyncFileReader
116
+
117
+
118
+ async def main():
119
+ async with AsyncRemoteZip(AsyncFileReader("/path/to/archive.zip")) as rz:
120
+ print(await rz.read("file.txt"))
121
+
122
+
123
+ asyncio.run(main())
63
124
 
64
125
  Passing an existing client
65
126
  --------------------------
@@ -46,6 +46,25 @@ entries:
46
46
  with open("output.bin", "wb") as f:
47
47
  rz.read_into("big-file.bin", f)
48
48
 
49
+ Read a local archive
50
+ ^^^^^^^^^^^^^^^^^^^^
51
+
52
+ :class:`~zipwire.backends.FileReader` opens an archive on the local
53
+ filesystem through the same interface, so the same code works for local and
54
+ remote archives. It accepts a path or a ``file://`` URI and needs no extra
55
+ dependency:
56
+
57
+ .. code-block:: python
58
+
59
+ from zipwire import SyncRemoteZip
60
+ from zipwire.backends import FileReader
61
+
62
+ with SyncRemoteZip(FileReader("/path/to/archive.zip")) as rz:
63
+ data = rz.read("path/to/file.txt")
64
+
65
+ # Or from a file:// URI
66
+ reader = FileReader.from_uri("file:///path/to/archive.zip")
67
+
49
68
  Asynchronous usage
50
69
  ------------------
51
70
 
@@ -83,12 +102,33 @@ Async with httpx2
83
102
  async def main():
84
103
  reader = Httpx2AsyncReader("https://archive.example/data.zip")
85
104
  async with AsyncRemoteZip(reader) as rz:
86
- names = await rz.namelist()
105
+ names = rz.namelist()
87
106
  print(names)
88
107
 
89
108
 
90
109
  asyncio.run(main())
91
110
 
111
+ Async local archive
112
+ ^^^^^^^^^^^^^^^^^^^
113
+
114
+ :class:`~zipwire.backends.AsyncFileReader` reads a local archive and offloads
115
+ blocking file IO to a worker thread so it never stalls the event loop:
116
+
117
+ .. code-block:: python
118
+
119
+ import asyncio
120
+
121
+ from zipwire import AsyncRemoteZip
122
+ from zipwire.backends import AsyncFileReader
123
+
124
+
125
+ async def main():
126
+ async with AsyncRemoteZip(AsyncFileReader("/path/to/archive.zip")) as rz:
127
+ print(await rz.read("path/to/file.txt"))
128
+
129
+
130
+ asyncio.run(main())
131
+
92
132
  Async streaming to disk
93
133
  ^^^^^^^^^^^^^^^^^^^^^^^^
94
134
 
@@ -109,6 +149,47 @@ Async streaming to disk
109
149
 
110
150
  asyncio.run(main())
111
151
 
152
+ Wheel dist-info metadata
153
+ ------------------------
154
+
155
+ ``SyncRemoteWheel`` and ``AsyncRemoteWheel`` extend the base classes
156
+ with an adaptive tail fetch and a ``distinfolist()`` method. The tail
157
+ size scales with the archive (at least 128 KiB, up to ~0.4% of the
158
+ file) so that dist-info entries are typically served from memory
159
+ without extra HTTP requests.
160
+
161
+ .. code-block:: python
162
+
163
+ from zipwire import SyncRemoteWheel
164
+ from zipwire.backends import Urllib3Reader
165
+
166
+ url = "https://files.pythonhosted.org/.../requests-2.32.3-py3-none-any.whl"
167
+ reader = Urllib3Reader(url)
168
+ with SyncRemoteWheel(reader) as whl:
169
+ for entry in whl.distinfolist():
170
+ data = whl.read(entry)
171
+ print(entry.filename, len(data))
172
+
173
+ Async variant:
174
+
175
+ .. code-block:: python
176
+
177
+ import asyncio
178
+
179
+ from zipwire import AsyncRemoteWheel
180
+ from zipwire.backends import AiohttpReader
181
+
182
+
183
+ async def main():
184
+ reader = AiohttpReader(url)
185
+ async with AsyncRemoteWheel(reader) as whl:
186
+ for entry in whl.distinfolist():
187
+ data = await whl.read(entry)
188
+ print(entry.filename, len(data))
189
+
190
+
191
+ asyncio.run(main())
192
+
112
193
  Passing a pre-configured client
113
194
  --------------------------------
114
195
 
@@ -34,7 +34,7 @@ Asynchronous example
34
34
  async def main():
35
35
  reader = AiohttpReader("https://example.com/archive.zip")
36
36
  async with AsyncRemoteZip(reader) as rz:
37
- print(await rz.namelist())
37
+ print(rz.namelist())
38
38
  data = await rz.read("path/inside/archive.txt")
39
39
 
40
40
  # Stream a large entry directly to disk
@@ -58,6 +58,44 @@ Asynchronous:
58
58
  - ``AiohttpReader`` -- uses *aiohttp* (``pip install zipwire[aiohttp]``)
59
59
  - ``Httpx2AsyncReader`` -- uses *httpx2*, supports HTTP/2
60
60
  (``pip install zipwire[httpx2]``)
61
+
62
+ Local files:
63
+ - ``FileReader`` / ``AsyncFileReader`` -- read an archive from the local
64
+ filesystem through the same API (no extra dependency). Useful for
65
+ testing or for handling ``file://`` URIs and local paths uniformly
66
+ with HTTP URLs.
67
+
68
+ ::
69
+
70
+ from zipwire import SyncRemoteZip
71
+ from zipwire.backends import FileReader
72
+
73
+ with SyncRemoteZip(FileReader("/path/to/archive.zip")) as rz:
74
+ data = rz.read("path/inside/archive.txt")
75
+
76
+ # From a file:// URI
77
+ reader = FileReader.from_uri("file:///path/to/archive.zip")
78
+
79
+ Wheel subclasses
80
+ ----------------
81
+
82
+ ``SyncRemoteWheel`` and ``AsyncRemoteWheel`` extend the base classes
83
+ for Python wheels. They parse the wheel URL to derive the
84
+ ``.dist-info`` directory name, fetch an adaptive tail (at least
85
+ 128 KiB, scaling with archive size) to cover dist-info entries at
86
+ the end of the archive, and serve matching reads from the tail
87
+ buffer without extra HTTP requests.
88
+
89
+ ::
90
+
91
+ from zipwire import SyncRemoteWheel
92
+ from zipwire.backends import Urllib3Reader
93
+
94
+ url = "https://files.pythonhosted.org/.../requests-2.32.3-py3-none-any.whl"
95
+ reader = Urllib3Reader(url)
96
+ with SyncRemoteWheel(reader) as whl:
97
+ for entry in whl.distinfolist():
98
+ print(entry.filename, whl.read(entry))
61
99
  """
62
100
 
63
101
  from __future__ import annotations
@@ -76,10 +114,12 @@ from zipwire._errors import (
76
114
  from zipwire._parser import EOCDInfo
77
115
  from zipwire._sync import SyncRemoteZip
78
116
  from zipwire._types import AsyncReader, Headers, SyncReader, Writable
117
+ from zipwire._wheel import AsyncRemoteWheel, SyncRemoteWheel
79
118
  from zipwire._zipinfo import RemoteZipInfo
80
119
 
81
120
  __all__ = [
82
121
  "AsyncReader",
122
+ "AsyncRemoteWheel",
83
123
  "AsyncRemoteZip",
84
124
  "BadZipFile",
85
125
  "CRCMismatch",
@@ -91,6 +131,7 @@ __all__ = [
91
131
  "RangeRequestUnsupported",
92
132
  "RemoteZipInfo",
93
133
  "SyncReader",
134
+ "SyncRemoteWheel",
94
135
  "SyncRemoteZip",
95
136
  "UnsupportedCompression",
96
137
  "Writable",
@@ -41,7 +41,7 @@ async def _run_async(url: str, backend: str, skip_dirs: bool) -> None:
41
41
  names = {"httpx2": "Httpx2AsyncReader", "aiohttp": "AiohttpReader"}
42
42
  reader_cls = getattr(backends, names[backend])
43
43
  async with AsyncRemoteZip(reader_cls(url)) as rz:
44
- _print_table(await rz.infolist(), skip_dirs)
44
+ _print_table(rz.infolist(), skip_dirs)
45
45
 
46
46
 
47
47
  def main(argv: list[str] | None = None) -> None: