zipwire 0.0.1__tar.gz → 0.1.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.
- {zipwire-0.0.1 → zipwire-0.1.0}/.github/workflows/ci.yml +30 -2
- zipwire-0.1.0/.readthedocs.yaml +25 -0
- zipwire-0.1.0/AGENTS.md +53 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/PKG-INFO +15 -14
- {zipwire-0.0.1 → zipwire-0.1.0}/README.md +7 -7
- {zipwire-0.0.1 → zipwire-0.1.0}/docs/api.rst +21 -2
- {zipwire-0.0.1 → zipwire-0.1.0}/docs/backends.rst +69 -8
- {zipwire-0.0.1 → zipwire-0.1.0}/docs/index.rst +1 -0
- zipwire-0.1.0/docs/requirements.txt +4 -0
- zipwire-0.1.0/docs/security.rst +96 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/pyproject.toml +24 -12
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/__init__.py +4 -1
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/__main__.py +40 -9
- zipwire-0.1.0/src/zipwire/_async.py +227 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/_constants.py +11 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/_decompress.py +39 -20
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/_errors.py +13 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/_parser.py +1 -7
- zipwire-0.1.0/src/zipwire/_sync.py +224 -0
- zipwire-0.1.0/src/zipwire/_types.py +105 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/_version.py +2 -2
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/backends/__init__.py +4 -4
- zipwire-0.1.0/src/zipwire/backends/_aiohttp.py +86 -0
- zipwire-0.1.0/src/zipwire/backends/_httpx2.py +157 -0
- zipwire-0.1.0/src/zipwire/backends/_requests.py +92 -0
- zipwire-0.1.0/src/zipwire/backends/_urllib3.py +93 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/tests/conftest.py +50 -8
- {zipwire-0.0.1 → zipwire-0.1.0}/tests/test_async.py +34 -0
- zipwire-0.1.0/tests/test_backends.py +405 -0
- zipwire-0.1.0/tests/test_cli.py +226 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/tests/test_decompress.py +117 -0
- zipwire-0.1.0/tests/test_integration.py +101 -0
- zipwire-0.1.0/tests/test_parser.py +262 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/tests/test_sync.py +34 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/tests/test_zipinfo.py +71 -1
- {zipwire-0.0.1 → zipwire-0.1.0}/tox.ini +9 -4
- zipwire-0.0.1/AGENTS.md +0 -29
- zipwire-0.0.1/src/zipwire/_async.py +0 -173
- zipwire-0.0.1/src/zipwire/_sync.py +0 -163
- zipwire-0.0.1/src/zipwire/_types.py +0 -57
- zipwire-0.0.1/src/zipwire/backends/_aiohttp.py +0 -55
- zipwire-0.0.1/src/zipwire/backends/_httpx2_async.py +0 -55
- zipwire-0.0.1/src/zipwire/backends/_httpx2_sync.py +0 -54
- zipwire-0.0.1/src/zipwire/backends/_requests.py +0 -54
- zipwire-0.0.1/src/zipwire/backends/_urllib3.py +0 -59
- zipwire-0.0.1/tests/test_backends.py +0 -255
- zipwire-0.0.1/tests/test_parser.py +0 -109
- {zipwire-0.0.1 → zipwire-0.1.0}/.github/dependabot.yml +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/.github/workflows/codeql.yml +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/.github/workflows/release.yml +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/.github/workflows/scorecard.yml +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/.gitignore +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/LICENSE +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/SECURITY.md +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/docs/Makefile +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/docs/conf.py +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/docs/quickstart.rst +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/_zipinfo.py +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/src/zipwire/py.typed +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/tests/__init__.py +0 -0
- {zipwire-0.0.1 → zipwire-0.1.0}/uv.lock +0 -0
|
@@ -34,7 +34,8 @@ jobs:
|
|
|
34
34
|
strategy:
|
|
35
35
|
fail-fast: false
|
|
36
36
|
matrix:
|
|
37
|
-
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
37
|
+
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
|
|
38
|
+
continue-on-error: ${{ matrix.python-version == '3.15' }}
|
|
38
39
|
steps:
|
|
39
40
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
40
41
|
with:
|
|
@@ -43,7 +44,34 @@ jobs:
|
|
|
43
44
|
with:
|
|
44
45
|
enable-cache: true
|
|
45
46
|
- run: uv sync --group dev --python ${{ matrix.python-version }}
|
|
46
|
-
- run: uv run pytest
|
|
47
|
+
- run: uv run coverage run -m pytest
|
|
48
|
+
- run: uv run pytest -m integration -v
|
|
49
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
50
|
+
with:
|
|
51
|
+
name: coverage-${{ matrix.python-version }}
|
|
52
|
+
path: .coverage.*
|
|
53
|
+
include-hidden-files: true
|
|
54
|
+
|
|
55
|
+
coverage:
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
needs: test
|
|
58
|
+
permissions:
|
|
59
|
+
contents: read
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
62
|
+
with:
|
|
63
|
+
persist-credentials: false
|
|
64
|
+
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
65
|
+
with:
|
|
66
|
+
enable-cache: true
|
|
67
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
68
|
+
with:
|
|
69
|
+
pattern: coverage-*
|
|
70
|
+
merge-multiple: true
|
|
71
|
+
- run: uv tool install "coverage[toml]>=7.0"
|
|
72
|
+
- run: coverage combine
|
|
73
|
+
- run: coverage report
|
|
74
|
+
- run: coverage xml
|
|
47
75
|
|
|
48
76
|
docs:
|
|
49
77
|
runs-on: ubuntu-latest
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Read the Docs configuration file
|
|
2
|
+
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
|
3
|
+
|
|
4
|
+
# Required
|
|
5
|
+
version: 2
|
|
6
|
+
|
|
7
|
+
# Set the OS, Python version, and other tools you might need
|
|
8
|
+
build:
|
|
9
|
+
os: ubuntu-24.04
|
|
10
|
+
tools:
|
|
11
|
+
python: "3.13"
|
|
12
|
+
|
|
13
|
+
# Build documentation in the "docs/" directory with Sphinx
|
|
14
|
+
sphinx:
|
|
15
|
+
configuration: docs/conf.py
|
|
16
|
+
|
|
17
|
+
# Declare the Python requirements required to build your documentation
|
|
18
|
+
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
|
|
19
|
+
python:
|
|
20
|
+
install:
|
|
21
|
+
- requirements: docs/requirements.txt
|
|
22
|
+
- method: pip
|
|
23
|
+
path: .
|
|
24
|
+
extra_requirements:
|
|
25
|
+
- all
|
zipwire-0.1.0/AGENTS.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# zipwire
|
|
2
|
+
|
|
3
|
+
Read and extract files from remote ZIP archives over HTTP range requests.
|
|
4
|
+
The library parses ZIP central directory and local file headers without
|
|
5
|
+
downloading the entire archive, then streams and decompresses individual
|
|
6
|
+
entries on the fly.
|
|
7
|
+
|
|
8
|
+
## Source layout
|
|
9
|
+
|
|
10
|
+
- `src/zipwire/` - main package (sync/async API, parsers, decompression)
|
|
11
|
+
- `src/zipwire/backends/` - HTTP backend adapters (urllib3, httpx2, aiohttp, requests)
|
|
12
|
+
- `tests/` - pytest test suite (async tests use `pytest-asyncio`)
|
|
13
|
+
- `docs/` - Sphinx documentation (Furo theme, MyST markdown)
|
|
14
|
+
|
|
15
|
+
## Virtual environment rules
|
|
16
|
+
|
|
17
|
+
- **Always** use the `.venv` virtual environment in the project root.
|
|
18
|
+
- Create it with `uv venv .venv` if it does not exist.
|
|
19
|
+
- **Never** install packages into the system Python or any other environment.
|
|
20
|
+
- Install the project: `uv pip install -e ".[all]" --python .venv/bin/python`
|
|
21
|
+
- Install dev deps: `uv pip install "pytest>=8.0" "pytest-asyncio>=0.24" "pytest-httpserver>=1.1" "coverage[toml]>=7.0" --python .venv/bin/python`
|
|
22
|
+
- Run tests directly: `.venv/bin/python -m pytest tests/`
|
|
23
|
+
- Run any Python tool via `.venv/bin/python -m <tool>` or `.venv/bin/<tool>`.
|
|
24
|
+
- Use `uv pip install --python .venv/bin/python` for all pip operations.
|
|
25
|
+
|
|
26
|
+
## Code style rules
|
|
27
|
+
|
|
28
|
+
- **No local imports.** All imports must be at the top of the file.
|
|
29
|
+
Do not use `from foo import bar` inside functions, methods, or test bodies.
|
|
30
|
+
|
|
31
|
+
## Git rules
|
|
32
|
+
|
|
33
|
+
- **Always** sign off commits with `git commit -s`.
|
|
34
|
+
Every commit message must include a `Signed-off-by:` line.
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# lint (ruff check + format)
|
|
40
|
+
uvx --with tox-uv tox run -e lint
|
|
41
|
+
|
|
42
|
+
# run tests (single Python version)
|
|
43
|
+
uvx --with tox-uv tox run -e py314
|
|
44
|
+
|
|
45
|
+
# run tests directly with .venv
|
|
46
|
+
.venv/bin/python -m pytest tests/ -v
|
|
47
|
+
|
|
48
|
+
# run full test matrix
|
|
49
|
+
uvx --with tox-uv tox run -e py311,py312,py313,py314
|
|
50
|
+
|
|
51
|
+
# build docs
|
|
52
|
+
uvx --with tox-uv tox run -e docs
|
|
53
|
+
```
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zipwire
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.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
|
+
Project-URL: Documentation, https://zipwire.readthedocs.io/
|
|
6
7
|
Project-URL: Source, https://github.com/tiran/zipwire
|
|
7
8
|
Project-URL: Issues, https://github.com/tiran/zipwire/issues
|
|
8
9
|
Author-email: Christian Heimes <christian@python.org>
|
|
9
10
|
License-Expression: Apache-2.0
|
|
10
11
|
License-File: LICENSE
|
|
11
|
-
Classifier: Development Status ::
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
13
|
Classifier: Intended Audience :: Developers
|
|
13
14
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
15
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -19,17 +20,17 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
19
20
|
Classifier: Topic :: Software Development :: Libraries
|
|
20
21
|
Classifier: Typing :: Typed
|
|
21
22
|
Requires-Python: >=3.11
|
|
22
|
-
Requires-Dist: urllib3>=2
|
|
23
|
+
Requires-Dist: urllib3>=2
|
|
23
24
|
Provides-Extra: aiohttp
|
|
24
25
|
Requires-Dist: aiohttp>=3.9; extra == 'aiohttp'
|
|
25
26
|
Provides-Extra: all
|
|
26
27
|
Requires-Dist: aiohttp>=3.9; extra == 'all'
|
|
27
|
-
Requires-Dist: httpx2>=2
|
|
28
|
-
Requires-Dist: requests>=2.
|
|
28
|
+
Requires-Dist: httpx2[http2]>=2; extra == 'all'
|
|
29
|
+
Requires-Dist: requests>=2.28; extra == 'all'
|
|
29
30
|
Provides-Extra: httpx2
|
|
30
|
-
Requires-Dist: httpx2>=2
|
|
31
|
+
Requires-Dist: httpx2[http2]>=2; extra == 'httpx2'
|
|
31
32
|
Provides-Extra: requests
|
|
32
|
-
Requires-Dist: requests>=2.
|
|
33
|
+
Requires-Dist: requests>=2.28; extra == 'requests'
|
|
33
34
|
Description-Content-Type: text/markdown
|
|
34
35
|
|
|
35
36
|
# zipwire
|
|
@@ -72,13 +73,13 @@ sync and async:
|
|
|
72
73
|
pip install zipwire[httpx2]
|
|
73
74
|
```
|
|
74
75
|
|
|
75
|
-
| Backend | Class
|
|
76
|
-
|
|
77
|
-
| urllib3 | `Urllib3Reader`
|
|
78
|
-
| httpx2 | `Httpx2SyncReader`
|
|
79
|
-
| httpx2 | `Httpx2AsyncReader
|
|
80
|
-
| requests | `RequestsReader`
|
|
81
|
-
| aiohttp | `AiohttpReader`
|
|
76
|
+
| Backend | Class | Mode | HTTP | Install extra |
|
|
77
|
+
|----------|----------------------|-------|---------|---------------|
|
|
78
|
+
| urllib3 | `Urllib3Reader` | sync | 1.1 | *(included)* |
|
|
79
|
+
| httpx2 | `Httpx2SyncReader` | sync | 1.1, 2 | `httpx2` |
|
|
80
|
+
| httpx2 | `Httpx2AsyncReader` | async | 1.1, 2 | `httpx2` |
|
|
81
|
+
| requests | `RequestsReader` | sync | 1.1 | `requests` |
|
|
82
|
+
| aiohttp | `AiohttpReader` | async | 1.1 | `aiohttp` |
|
|
82
83
|
|
|
83
84
|
Every backend accepts an optional pre-configured client or session so you can
|
|
84
85
|
share connection pools, authentication, and retry configuration.
|
|
@@ -38,13 +38,13 @@ sync and async:
|
|
|
38
38
|
pip install zipwire[httpx2]
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
| Backend | Class
|
|
42
|
-
|
|
43
|
-
| urllib3 | `Urllib3Reader`
|
|
44
|
-
| httpx2 | `Httpx2SyncReader`
|
|
45
|
-
| httpx2 | `Httpx2AsyncReader
|
|
46
|
-
| requests | `RequestsReader`
|
|
47
|
-
| aiohttp | `AiohttpReader`
|
|
41
|
+
| Backend | Class | Mode | HTTP | Install extra |
|
|
42
|
+
|----------|----------------------|-------|---------|---------------|
|
|
43
|
+
| urllib3 | `Urllib3Reader` | sync | 1.1 | *(included)* |
|
|
44
|
+
| httpx2 | `Httpx2SyncReader` | sync | 1.1, 2 | `httpx2` |
|
|
45
|
+
| httpx2 | `Httpx2AsyncReader` | async | 1.1, 2 | `httpx2` |
|
|
46
|
+
| requests | `RequestsReader` | sync | 1.1 | `requests` |
|
|
47
|
+
| aiohttp | `AiohttpReader` | async | 1.1 | `aiohttp` |
|
|
48
48
|
|
|
49
49
|
Every backend accepts an optional pre-configured client or session so you can
|
|
50
50
|
share connection pools, authentication, and retry configuration.
|
|
@@ -15,16 +15,31 @@ Core
|
|
|
15
15
|
.. autoclass:: zipwire.RemoteZipInfo
|
|
16
16
|
:show-inheritance:
|
|
17
17
|
|
|
18
|
+
Protocols and Types
|
|
19
|
+
-------------------
|
|
20
|
+
|
|
21
|
+
.. autoclass:: zipwire.SyncReader
|
|
22
|
+
:members:
|
|
23
|
+
|
|
24
|
+
.. autoclass:: zipwire.AsyncReader
|
|
25
|
+
:members:
|
|
26
|
+
|
|
27
|
+
.. autoclass:: zipwire.Headers
|
|
28
|
+
:members:
|
|
29
|
+
|
|
30
|
+
.. autoclass:: zipwire.Writable
|
|
31
|
+
:members:
|
|
32
|
+
|
|
18
33
|
Backends
|
|
19
34
|
--------
|
|
20
35
|
|
|
21
36
|
.. autoclass:: zipwire.backends._urllib3.Urllib3Reader
|
|
22
37
|
:members:
|
|
23
38
|
|
|
24
|
-
.. autoclass:: zipwire.backends.
|
|
39
|
+
.. autoclass:: zipwire.backends._httpx2.Httpx2SyncReader
|
|
25
40
|
:members:
|
|
26
41
|
|
|
27
|
-
.. autoclass:: zipwire.backends.
|
|
42
|
+
.. autoclass:: zipwire.backends._httpx2.Httpx2AsyncReader
|
|
28
43
|
:members:
|
|
29
44
|
|
|
30
45
|
.. autoclass:: zipwire.backends._requests.RequestsReader
|
|
@@ -43,6 +58,7 @@ Exceptions
|
|
|
43
58
|
├── BadZipFile
|
|
44
59
|
├── UnsupportedCompression
|
|
45
60
|
├── CRCMismatch
|
|
61
|
+
├── FileTooLarge
|
|
46
62
|
├── RangeRequestUnsupported
|
|
47
63
|
└── FileNotFoundInZip (also inherits KeyError)
|
|
48
64
|
|
|
@@ -58,6 +74,9 @@ Exceptions
|
|
|
58
74
|
.. autoexception:: zipwire.CRCMismatch
|
|
59
75
|
:show-inheritance:
|
|
60
76
|
|
|
77
|
+
.. autoexception:: zipwire.FileTooLarge
|
|
78
|
+
:show-inheritance:
|
|
79
|
+
|
|
61
80
|
.. autoexception:: zipwire.RangeRequestUnsupported
|
|
62
81
|
:show-inheritance:
|
|
63
82
|
|
|
@@ -11,31 +11,37 @@ Available backends
|
|
|
11
11
|
|
|
12
12
|
.. list-table::
|
|
13
13
|
:header-rows: 1
|
|
14
|
-
:widths: 15 25 10 20
|
|
14
|
+
:widths: 15 25 10 10 20
|
|
15
15
|
|
|
16
16
|
* - Library
|
|
17
17
|
- Class
|
|
18
18
|
- Mode
|
|
19
|
+
- HTTP
|
|
19
20
|
- Install extra
|
|
20
21
|
* - urllib3
|
|
21
22
|
- :class:`~zipwire.backends.Urllib3Reader`
|
|
22
23
|
- sync
|
|
24
|
+
- 1.1
|
|
23
25
|
- *(included)*
|
|
24
26
|
* - httpx2
|
|
25
27
|
- :class:`~zipwire.backends.Httpx2SyncReader`
|
|
26
28
|
- sync
|
|
29
|
+
- 1.1, 2
|
|
27
30
|
- ``httpx2``
|
|
28
31
|
* - httpx2
|
|
29
32
|
- :class:`~zipwire.backends.Httpx2AsyncReader`
|
|
30
33
|
- async
|
|
34
|
+
- 1.1, 2
|
|
31
35
|
- ``httpx2``
|
|
32
36
|
* - requests
|
|
33
37
|
- :class:`~zipwire.backends.RequestsReader`
|
|
34
38
|
- sync
|
|
39
|
+
- 1.1
|
|
35
40
|
- ``requests``
|
|
36
41
|
* - aiohttp
|
|
37
42
|
- :class:`~zipwire.backends.AiohttpReader`
|
|
38
43
|
- async
|
|
44
|
+
- 1.1
|
|
39
45
|
- ``aiohttp``
|
|
40
46
|
|
|
41
47
|
Choosing a backend
|
|
@@ -47,7 +53,10 @@ Choosing a backend
|
|
|
47
53
|
Best choice if your project already depends on aiohttp.
|
|
48
54
|
- **Sync + async from one library** - install ``httpx2`` and use
|
|
49
55
|
:class:`~zipwire.backends.Httpx2SyncReader` or
|
|
50
|
-
:class:`~zipwire.backends.Httpx2AsyncReader`.
|
|
56
|
+
:class:`~zipwire.backends.Httpx2AsyncReader`. The ``httpx2`` extra includes
|
|
57
|
+
HTTP/2 support via the ``h2`` package. When ``h2`` is available, HTTP/2 is
|
|
58
|
+
enabled automatically; pass ``http2=False`` to force HTTP/1.1 or
|
|
59
|
+
``http2=True`` to require it.
|
|
51
60
|
- **Requests integration** - use :class:`~zipwire.backends.RequestsReader`
|
|
52
61
|
if your project already uses ``requests`` and you want to share sessions,
|
|
53
62
|
authentication, or retry configuration.
|
|
@@ -84,7 +93,7 @@ protocol and pass an instance to :class:`~zipwire.SyncRemoteZip` or
|
|
|
84
93
|
|
|
85
94
|
from collections.abc import Iterator
|
|
86
95
|
|
|
87
|
-
from zipwire import SyncReader, SyncRemoteZip
|
|
96
|
+
from zipwire import Headers, SyncReader, SyncRemoteZip
|
|
88
97
|
|
|
89
98
|
|
|
90
99
|
class MyReader:
|
|
@@ -93,11 +102,15 @@ protocol and pass an instance to :class:`~zipwire.SyncRemoteZip` or
|
|
|
93
102
|
def __init__(self, url: str) -> None:
|
|
94
103
|
self._url = url
|
|
95
104
|
|
|
96
|
-
def
|
|
97
|
-
... # HEAD request, return
|
|
105
|
+
def head(self) -> Headers:
|
|
106
|
+
... # HEAD request, check Accept-Ranges, return headers
|
|
98
107
|
|
|
99
|
-
def read_range(
|
|
100
|
-
|
|
108
|
+
def read_range(
|
|
109
|
+
self,
|
|
110
|
+
offset: int,
|
|
111
|
+
length: int,
|
|
112
|
+
) -> tuple[bytes, Headers]:
|
|
113
|
+
... # GET with Range header, return (data, response_headers)
|
|
101
114
|
|
|
102
115
|
def stream_range(self, offset: int, length: int) -> Iterator[bytes]:
|
|
103
116
|
... # GET with Range header, yield chunks
|
|
@@ -108,4 +121,52 @@ protocol and pass an instance to :class:`~zipwire.SyncRemoteZip` or
|
|
|
108
121
|
assert isinstance(MyReader("..."), SyncReader) # runtime-checkable
|
|
109
122
|
|
|
110
123
|
See the :class:`~zipwire.SyncReader` and :class:`~zipwire.AsyncReader`
|
|
111
|
-
protocol definitions
|
|
124
|
+
protocol definitions for the full method signatures.
|
|
125
|
+
|
|
126
|
+
Why only absolute byte ranges?
|
|
127
|
+
-------------------------------
|
|
128
|
+
|
|
129
|
+
HTTP range requests support two forms: absolute ranges
|
|
130
|
+
(``bytes=<start>-<end>``) and suffix ranges (``bytes=-<N>``). Suffix
|
|
131
|
+
ranges request the last *N* bytes without requiring the client to know
|
|
132
|
+
the total file size. While RFC 7233 defines both forms, support varies
|
|
133
|
+
across CDNs and cloud storage services:
|
|
134
|
+
|
|
135
|
+
.. list-table::
|
|
136
|
+
:header-rows: 1
|
|
137
|
+
:widths: 25 15 40
|
|
138
|
+
|
|
139
|
+
* - Service
|
|
140
|
+
- Suffix ranges
|
|
141
|
+
- Notes
|
|
142
|
+
* - AWS S3
|
|
143
|
+
- supported
|
|
144
|
+
- Full RFC 7233 compliance.
|
|
145
|
+
* - Google Cloud Storage
|
|
146
|
+
- supported
|
|
147
|
+
- Confirmed by GCS team.
|
|
148
|
+
* - Azure Blob Storage
|
|
149
|
+
- **not supported**
|
|
150
|
+
- Returns 200 with full object instead of 206.
|
|
151
|
+
* - Amazon CloudFront
|
|
152
|
+
- **not supported**
|
|
153
|
+
- Returns 200 with full object instead of 206.
|
|
154
|
+
* - Cloudflare R2
|
|
155
|
+
- supported
|
|
156
|
+
- Via S3-compatible and Workers API.
|
|
157
|
+
* - Fastly
|
|
158
|
+
- varies
|
|
159
|
+
- Some Fastly-fronted origins (e.g. PyPI) return 501.
|
|
160
|
+
* - Cloudflare CDN
|
|
161
|
+
- uncertain
|
|
162
|
+
- Undocumented; may pass through to origin.
|
|
163
|
+
* - Akamai
|
|
164
|
+
- likely supported
|
|
165
|
+
- General range support confirmed; suffix ranges undocumented.
|
|
166
|
+
|
|
167
|
+
Because suffix ranges silently break on several major platforms (returning
|
|
168
|
+
the full object or an error), zipwire uses only absolute byte ranges. On
|
|
169
|
+
first access it sends a HEAD request to learn the file size, then
|
|
170
|
+
computes absolute offsets for all subsequent range requests. This adds
|
|
171
|
+
one round-trip compared to a suffix-range approach but works reliably
|
|
172
|
+
everywhere.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Security
|
|
2
|
+
========
|
|
3
|
+
|
|
4
|
+
zipwire fetches data from remote servers and parses binary ZIP
|
|
5
|
+
structures. When processing archives from untrusted sources, keep the
|
|
6
|
+
following risks in mind.
|
|
7
|
+
|
|
8
|
+
Zip bombs
|
|
9
|
+
---------
|
|
10
|
+
|
|
11
|
+
A *zip bomb* is a malicious archive containing entries that decompress
|
|
12
|
+
to a vastly larger size than their compressed representation, potentially
|
|
13
|
+
exhausting memory or disk space.
|
|
14
|
+
|
|
15
|
+
Both :meth:`~zipwire.SyncRemoteZip.read` and
|
|
16
|
+
:meth:`~zipwire.SyncRemoteZip.read_into` (and their async equivalents)
|
|
17
|
+
accept a ``max_file_size`` keyword argument that limits the uncompressed
|
|
18
|
+
size of a single entry:
|
|
19
|
+
|
|
20
|
+
.. code-block:: python
|
|
21
|
+
|
|
22
|
+
from zipwire import SyncRemoteZip
|
|
23
|
+
from zipwire.backends import Urllib3Reader
|
|
24
|
+
|
|
25
|
+
reader = Urllib3Reader("https://example.com/untrusted.zip")
|
|
26
|
+
with SyncRemoteZip(reader) as rz:
|
|
27
|
+
for info in rz.infolist():
|
|
28
|
+
data = rz.read(info, max_file_size=50 * 1024 * 1024) # 50 MiB
|
|
29
|
+
|
|
30
|
+
When the limit is exceeded, :exc:`~zipwire.FileTooLarge` is raised.
|
|
31
|
+
|
|
32
|
+
**Two layers of protection:**
|
|
33
|
+
|
|
34
|
+
1. **Pre-check** -- before decompression starts, the entry's declared
|
|
35
|
+
``file_size`` from the central directory is compared against the limit.
|
|
36
|
+
This catches honest zip bombs with no overhead.
|
|
37
|
+
|
|
38
|
+
2. **Streaming enforcement** (:meth:`read_into` only) -- the streaming
|
|
39
|
+
decompressor tracks the actual decompressed output and aborts if it
|
|
40
|
+
exceeds the limit. This catches crafted archives that lie about their
|
|
41
|
+
uncompressed size in the metadata.
|
|
42
|
+
|
|
43
|
+
:meth:`read` performs only the pre-check (layer 1). Because it
|
|
44
|
+
decompresses the entire entry into memory in one step, it cannot enforce
|
|
45
|
+
the limit during decompression. Use :meth:`read_into` when processing
|
|
46
|
+
untrusted archives for defence in depth.
|
|
47
|
+
|
|
48
|
+
You can also inspect entries before extracting them:
|
|
49
|
+
|
|
50
|
+
.. code-block:: python
|
|
51
|
+
|
|
52
|
+
MAX_SIZE = 100 * 1024 * 1024 # 100 MiB
|
|
53
|
+
|
|
54
|
+
with SyncRemoteZip(reader) as rz:
|
|
55
|
+
for info in rz.infolist():
|
|
56
|
+
if info.file_size > MAX_SIZE:
|
|
57
|
+
print(f"Skipping {info.filename}: too large ({info.file_size} bytes)")
|
|
58
|
+
continue
|
|
59
|
+
data = rz.read(info)
|
|
60
|
+
|
|
61
|
+
Path traversal
|
|
62
|
+
--------------
|
|
63
|
+
|
|
64
|
+
ZIP archives can contain entries with absolute paths or ``../``
|
|
65
|
+
components (e.g. ``../../etc/passwd``). zipwire does **not** extract
|
|
66
|
+
files to disk, so this is not a direct risk. However, if you use
|
|
67
|
+
filenames from the archive to construct file paths, always sanitise them:
|
|
68
|
+
|
|
69
|
+
.. code-block:: python
|
|
70
|
+
|
|
71
|
+
import os
|
|
72
|
+
|
|
73
|
+
with SyncRemoteZip(reader) as rz:
|
|
74
|
+
for info in rz.infolist():
|
|
75
|
+
# Reject absolute paths and path traversal
|
|
76
|
+
if os.path.isabs(info.filename) or ".." in info.filename.split("/"):
|
|
77
|
+
print(f"Skipping suspicious path: {info.filename}")
|
|
78
|
+
continue
|
|
79
|
+
dest = os.path.join(output_dir, info.filename)
|
|
80
|
+
# Verify the resolved path is inside output_dir
|
|
81
|
+
if not os.path.realpath(dest).startswith(os.path.realpath(output_dir)):
|
|
82
|
+
print(f"Skipping path traversal: {info.filename}")
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
Server trust
|
|
86
|
+
------------
|
|
87
|
+
|
|
88
|
+
zipwire sends HTTP requests to a URL you provide. The server controls
|
|
89
|
+
what data is returned. A malicious server could return crafted responses
|
|
90
|
+
designed to exploit ZIP parsing bugs, cause excessive memory use, or
|
|
91
|
+
return different data for different range requests.
|
|
92
|
+
|
|
93
|
+
- Only fetch archives from servers you trust.
|
|
94
|
+
- Use HTTPS to prevent tampering in transit.
|
|
95
|
+
- Consider setting ``max_file_size`` even for trusted servers as a
|
|
96
|
+
safety net.
|
|
@@ -11,7 +11,7 @@ authors = [{ name = "Christian Heimes", email = "christian@python.org" }]
|
|
|
11
11
|
requires-python = ">=3.11"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
classifiers = [
|
|
14
|
-
"Development Status ::
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
15
|
"Intended Audience :: Developers",
|
|
16
16
|
"License :: OSI Approved :: Apache Software License",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
@@ -22,34 +22,35 @@ classifiers = [
|
|
|
22
22
|
"Topic :: Software Development :: Libraries",
|
|
23
23
|
"Typing :: Typed",
|
|
24
24
|
]
|
|
25
|
-
dependencies = ["urllib3>=2
|
|
25
|
+
dependencies = ["urllib3>=2"]
|
|
26
26
|
|
|
27
27
|
[project.urls]
|
|
28
28
|
Homepage = "https://github.com/tiran/zipwire"
|
|
29
|
+
Documentation = "https://zipwire.readthedocs.io/"
|
|
29
30
|
Source = "https://github.com/tiran/zipwire"
|
|
30
31
|
Issues = "https://github.com/tiran/zipwire/issues"
|
|
31
32
|
|
|
32
33
|
[project.optional-dependencies]
|
|
33
|
-
httpx2 = ["httpx2>=2
|
|
34
|
+
httpx2 = ["httpx2[http2]>=2"]
|
|
34
35
|
aiohttp = ["aiohttp>=3.9"]
|
|
35
|
-
requests = ["requests>=2.
|
|
36
|
-
all = ["httpx2>=2
|
|
36
|
+
requests = ["requests>=2.28"]
|
|
37
|
+
all = ["httpx2[http2]>=2", "aiohttp>=3.9", "requests>=2.28"]
|
|
37
38
|
|
|
38
39
|
[dependency-groups]
|
|
39
40
|
dev = [
|
|
40
41
|
"zipwire[all]",
|
|
41
|
-
"pytest>=8
|
|
42
|
+
"pytest>=8",
|
|
42
43
|
"pytest-asyncio>=0.24",
|
|
43
|
-
"pytest-httpserver>=1
|
|
44
|
-
"coverage[toml]>=7
|
|
44
|
+
"pytest-httpserver>=1",
|
|
45
|
+
"coverage[toml]>=7",
|
|
45
46
|
"ruff>=0.11",
|
|
46
47
|
]
|
|
47
48
|
docs = [
|
|
48
49
|
"zipwire[all]",
|
|
49
|
-
"sphinx>=8
|
|
50
|
-
"furo>=2024
|
|
51
|
-
"sphinx-autobuild>=2024
|
|
52
|
-
"myst-parser>=4
|
|
50
|
+
"sphinx>=8",
|
|
51
|
+
"furo>=2024",
|
|
52
|
+
"sphinx-autobuild>=2024",
|
|
53
|
+
"myst-parser>=4",
|
|
53
54
|
]
|
|
54
55
|
|
|
55
56
|
[tool.hatch.version]
|
|
@@ -64,6 +65,8 @@ packages = ["src/zipwire"]
|
|
|
64
65
|
[tool.pytest.ini_options]
|
|
65
66
|
testpaths = ["tests"]
|
|
66
67
|
asyncio_mode = "auto"
|
|
68
|
+
markers = ["integration: tests that hit real network services (deselect with '-m not integration')"]
|
|
69
|
+
addopts = "-m 'not integration'"
|
|
67
70
|
|
|
68
71
|
[tool.ruff]
|
|
69
72
|
line-length = 99
|
|
@@ -73,7 +76,16 @@ target-version = "py311"
|
|
|
73
76
|
select = ["E", "W", "F", "I", "UP", "B", "SIM", "TCH", "RUF"]
|
|
74
77
|
|
|
75
78
|
[tool.coverage.run]
|
|
79
|
+
branch = true
|
|
80
|
+
parallel = true
|
|
76
81
|
source = ["zipwire"]
|
|
82
|
+
omit = ["*/_version.py"]
|
|
83
|
+
|
|
84
|
+
[tool.coverage.paths]
|
|
85
|
+
source = [
|
|
86
|
+
"src/zipwire",
|
|
87
|
+
".tox/*/lib/python*/site-packages/zipwire",
|
|
88
|
+
]
|
|
77
89
|
|
|
78
90
|
[tool.coverage.report]
|
|
79
91
|
show_missing = true
|
|
@@ -8,12 +8,13 @@ from zipwire._errors import (
|
|
|
8
8
|
BadZipFile,
|
|
9
9
|
CRCMismatch,
|
|
10
10
|
FileNotFoundInZip,
|
|
11
|
+
FileTooLarge,
|
|
11
12
|
RangeRequestUnsupported,
|
|
12
13
|
UnsupportedCompression,
|
|
13
14
|
ZipwireError,
|
|
14
15
|
)
|
|
15
16
|
from zipwire._sync import SyncRemoteZip
|
|
16
|
-
from zipwire._types import AsyncReader, SyncReader, Writable
|
|
17
|
+
from zipwire._types import AsyncReader, Headers, SyncReader, Writable
|
|
17
18
|
from zipwire._zipinfo import RemoteZipInfo
|
|
18
19
|
|
|
19
20
|
__all__ = [
|
|
@@ -23,6 +24,8 @@ __all__ = [
|
|
|
23
24
|
"CRCMismatch",
|
|
24
25
|
"CompressionMethod",
|
|
25
26
|
"FileNotFoundInZip",
|
|
27
|
+
"FileTooLarge",
|
|
28
|
+
"Headers",
|
|
26
29
|
"RangeRequestUnsupported",
|
|
27
30
|
"RemoteZipInfo",
|
|
28
31
|
"SyncReader",
|