zipwire 0.1.0__tar.gz → 0.2.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 (52) hide show
  1. {zipwire-0.1.0 → zipwire-0.2.0}/.github/workflows/ci.yml +5 -5
  2. {zipwire-0.1.0 → zipwire-0.2.0}/.github/workflows/codeql.yml +2 -2
  3. {zipwire-0.1.0 → zipwire-0.2.0}/.github/workflows/scorecard.yml +1 -1
  4. {zipwire-0.1.0 → zipwire-0.2.0}/AGENTS.md +12 -0
  5. zipwire-0.2.0/CLAUDE.md +1 -0
  6. {zipwire-0.1.0 → zipwire-0.2.0}/PKG-INFO +1 -1
  7. {zipwire-0.1.0 → zipwire-0.2.0}/docs/api.rst +15 -0
  8. zipwire-0.2.0/src/zipwire/__init__.py +98 -0
  9. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/__main__.py +4 -8
  10. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_async.py +42 -8
  11. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_decompress.py +4 -4
  12. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_sync.py +43 -8
  13. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_types.py +10 -0
  14. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_version.py +2 -2
  15. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/_aiohttp.py +6 -1
  16. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/_httpx2.py +13 -3
  17. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/_requests.py +6 -1
  18. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/_urllib3.py +5 -0
  19. {zipwire-0.1.0 → zipwire-0.2.0}/tests/conftest.py +51 -4
  20. {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_async.py +67 -5
  21. {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_backends.py +79 -11
  22. {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_cli.py +4 -1
  23. {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_decompress.py +51 -7
  24. {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_integration.py +23 -9
  25. {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_sync.py +59 -6
  26. {zipwire-0.1.0 → zipwire-0.2.0}/tox.ini +25 -5
  27. {zipwire-0.1.0 → zipwire-0.2.0}/uv.lock +51 -16
  28. zipwire-0.1.0/src/zipwire/__init__.py +0 -36
  29. {zipwire-0.1.0 → zipwire-0.2.0}/.github/dependabot.yml +0 -0
  30. {zipwire-0.1.0 → zipwire-0.2.0}/.github/workflows/release.yml +0 -0
  31. {zipwire-0.1.0 → zipwire-0.2.0}/.gitignore +0 -0
  32. {zipwire-0.1.0 → zipwire-0.2.0}/.readthedocs.yaml +0 -0
  33. {zipwire-0.1.0 → zipwire-0.2.0}/LICENSE +0 -0
  34. {zipwire-0.1.0 → zipwire-0.2.0}/README.md +0 -0
  35. {zipwire-0.1.0 → zipwire-0.2.0}/SECURITY.md +0 -0
  36. {zipwire-0.1.0 → zipwire-0.2.0}/docs/Makefile +0 -0
  37. {zipwire-0.1.0 → zipwire-0.2.0}/docs/backends.rst +0 -0
  38. {zipwire-0.1.0 → zipwire-0.2.0}/docs/conf.py +0 -0
  39. {zipwire-0.1.0 → zipwire-0.2.0}/docs/index.rst +0 -0
  40. {zipwire-0.1.0 → zipwire-0.2.0}/docs/quickstart.rst +0 -0
  41. {zipwire-0.1.0 → zipwire-0.2.0}/docs/requirements.txt +0 -0
  42. {zipwire-0.1.0 → zipwire-0.2.0}/docs/security.rst +0 -0
  43. {zipwire-0.1.0 → zipwire-0.2.0}/pyproject.toml +0 -0
  44. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_constants.py +0 -0
  45. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_errors.py +0 -0
  46. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_parser.py +0 -0
  47. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_zipinfo.py +0 -0
  48. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/__init__.py +0 -0
  49. {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/py.typed +0 -0
  50. {zipwire-0.1.0 → zipwire-0.2.0}/tests/__init__.py +0 -0
  51. {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_parser.py +0 -0
  52. {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_zipinfo.py +0 -0
@@ -43,10 +43,10 @@ jobs:
43
43
  - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
44
44
  with:
45
45
  enable-cache: true
46
- - run: uv sync --group dev --python ${{ matrix.python-version }}
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
46
+ - run: uv tool install --python ${{ matrix.python-version }} tox --with tox-uv --with tox-gh
47
+ - run: tox
48
+ - run: tox -e integration
49
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
50
50
  with:
51
51
  name: coverage-${{ matrix.python-version }}
52
52
  path: .coverage.*
@@ -64,7 +64,7 @@ jobs:
64
64
  - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
65
65
  with:
66
66
  enable-cache: true
67
- - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
67
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
68
68
  with:
69
69
  pattern: coverage-*
70
70
  merge-multiple: true
@@ -19,7 +19,7 @@ jobs:
19
19
  - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
20
20
  with:
21
21
  persist-credentials: false
22
- - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
22
+ - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
23
23
  with:
24
24
  languages: python
25
- - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
25
+ - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
@@ -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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
31
+ - uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
32
32
  with:
33
33
  sarif_file: results.sarif
@@ -28,6 +28,18 @@ entries on the fly.
28
28
  - **No local imports.** All imports must be at the top of the file.
29
29
  Do not use `from foo import bar` inside functions, methods, or test bodies.
30
30
 
31
+ ## Documentation rules
32
+
33
+ - **Keep docs in sync with API changes.** Whenever you add, remove, rename,
34
+ or change the signature of any public API (classes, functions, exceptions,
35
+ type aliases, backends), you **must** update all of these:
36
+ 1. `src/zipwire/__init__.py` -- module docstring (usage examples, backend
37
+ list) and `__all__`.
38
+ 2. `docs/` -- Sphinx sources (`api.rst`, `backends.rst`, `quickstart.rst`,
39
+ and any other affected `.rst` file).
40
+ - After editing docs, verify they build cleanly:
41
+ `uvx --with tox-uv tox run -e docs`
42
+
31
43
  ## Git rules
32
44
 
33
45
  - **Always** sign off commits with `git commit -s`.
@@ -0,0 +1 @@
1
+ AGENTS.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zipwire
3
- Version: 0.1.0
3
+ Version: 0.2.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/
@@ -4,6 +4,18 @@ API Reference
4
4
  Core
5
5
  ----
6
6
 
7
+ .. note::
8
+
9
+ ``SyncRemoteZip`` and ``AsyncRemoteZip`` **must** be used as context
10
+ managers. The archive is loaded on entry and all internal state is
11
+ cleared on exit. Calling methods like :meth:`~zipwire.SyncRemoteZip.read`
12
+ outside a ``with`` / ``async with`` block raises :exc:`RuntimeError`.
13
+
14
+ .. warning::
15
+
16
+ ``SyncRemoteZip`` and ``AsyncRemoteZip`` instances are **not thread-safe**.
17
+ Each thread or task should use its own instance.
18
+
7
19
  .. autoclass:: zipwire.SyncRemoteZip
8
20
  :members:
9
21
  :special-members: __enter__, __exit__
@@ -15,6 +27,9 @@ Core
15
27
  .. autoclass:: zipwire.RemoteZipInfo
16
28
  :show-inheritance:
17
29
 
30
+ .. autoclass:: zipwire.EOCDInfo
31
+ :members:
32
+
18
33
  Protocols and Types
19
34
  -------------------
20
35
 
@@ -0,0 +1,98 @@
1
+ """Read and extract files from remote ZIP archives over HTTP range requests.
2
+
3
+ Only the central directory and the requested entries are downloaded,
4
+ so large archives can be inspected and partially extracted without
5
+ fetching the entire file.
6
+
7
+ Synchronous example
8
+ -------------------
9
+
10
+ ::
11
+
12
+ from zipwire import SyncRemoteZip
13
+ from zipwire.backends import Urllib3Reader
14
+
15
+ reader = Urllib3Reader("https://example.com/archive.zip")
16
+ with SyncRemoteZip(reader) as rz:
17
+ for info in rz.infolist():
18
+ print(f"{info.filename} {info.file_size} bytes")
19
+ data = rz.read("path/inside/archive.txt")
20
+
21
+ # Stream a large entry directly to disk
22
+ with open("local.bin", "wb") as fh:
23
+ rz.read_into("large-file.bin", fh)
24
+
25
+ Asynchronous example
26
+ --------------------
27
+
28
+ ::
29
+
30
+ import asyncio
31
+ from zipwire import AsyncRemoteZip
32
+ from zipwire.backends import AiohttpReader
33
+
34
+ async def main():
35
+ reader = AiohttpReader("https://example.com/archive.zip")
36
+ async with AsyncRemoteZip(reader) as rz:
37
+ print(await rz.namelist())
38
+ data = await rz.read("path/inside/archive.txt")
39
+
40
+ # Stream a large entry directly to disk
41
+ with open("local.bin", "wb") as fh:
42
+ await rz.read_into("large-file.bin", fh)
43
+
44
+ asyncio.run(main())
45
+
46
+ Backends
47
+ --------
48
+
49
+ All backends live in :mod:`zipwire.backends` and are lazily imported.
50
+
51
+ Synchronous:
52
+ - ``Urllib3Reader`` -- default, uses *urllib3*
53
+ - ``RequestsReader`` -- uses *requests* (``pip install zipwire[requests]``)
54
+ - ``Httpx2SyncReader`` -- uses *httpx2*, supports HTTP/2
55
+ (``pip install zipwire[httpx2]``)
56
+
57
+ Asynchronous:
58
+ - ``AiohttpReader`` -- uses *aiohttp* (``pip install zipwire[aiohttp]``)
59
+ - ``Httpx2AsyncReader`` -- uses *httpx2*, supports HTTP/2
60
+ (``pip install zipwire[httpx2]``)
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ from zipwire._async import AsyncRemoteZip
66
+ from zipwire._constants import CompressionMethod
67
+ from zipwire._errors import (
68
+ BadZipFile,
69
+ CRCMismatch,
70
+ FileNotFoundInZip,
71
+ FileTooLarge,
72
+ RangeRequestUnsupported,
73
+ UnsupportedCompression,
74
+ ZipwireError,
75
+ )
76
+ from zipwire._parser import EOCDInfo
77
+ from zipwire._sync import SyncRemoteZip
78
+ from zipwire._types import AsyncReader, Headers, SyncReader, Writable
79
+ from zipwire._zipinfo import RemoteZipInfo
80
+
81
+ __all__ = [
82
+ "AsyncReader",
83
+ "AsyncRemoteZip",
84
+ "BadZipFile",
85
+ "CRCMismatch",
86
+ "CompressionMethod",
87
+ "EOCDInfo",
88
+ "FileNotFoundInZip",
89
+ "FileTooLarge",
90
+ "Headers",
91
+ "RangeRequestUnsupported",
92
+ "RemoteZipInfo",
93
+ "SyncReader",
94
+ "SyncRemoteZip",
95
+ "UnsupportedCompression",
96
+ "Writable",
97
+ "ZipwireError",
98
+ ]
@@ -29,10 +29,8 @@ def _print_table(infos: list[RemoteZipInfo], skip_dirs: bool) -> None:
29
29
  def _run_sync(url: str, backend: str, skip_dirs: bool) -> None:
30
30
  from zipwire import backends
31
31
 
32
- reader_cls = {
33
- "urllib3": backends.Urllib3Reader,
34
- "requests": backends.RequestsReader,
35
- }[backend]
32
+ names = {"urllib3": "Urllib3Reader", "requests": "RequestsReader"}
33
+ reader_cls = getattr(backends, names[backend])
36
34
  with SyncRemoteZip(reader_cls(url)) as rz:
37
35
  _print_table(rz.infolist(), skip_dirs)
38
36
 
@@ -40,10 +38,8 @@ def _run_sync(url: str, backend: str, skip_dirs: bool) -> None:
40
38
  async def _run_async(url: str, backend: str, skip_dirs: bool) -> None:
41
39
  from zipwire import backends
42
40
 
43
- reader_cls = {
44
- "httpx2": backends.Httpx2AsyncReader,
45
- "aiohttp": backends.AiohttpReader,
46
- }[backend]
41
+ names = {"httpx2": "Httpx2AsyncReader", "aiohttp": "AiohttpReader"}
42
+ reader_cls = getattr(backends, names[backend])
47
43
  async with AsyncRemoteZip(reader_cls(url)) as rz:
48
44
  _print_table(await rz.infolist(), skip_dirs)
49
45
 
@@ -13,6 +13,7 @@ from zipwire._constants import (
13
13
  from zipwire._decompress import StreamingDecompressor, decompress
14
14
  from zipwire._errors import FileNotFoundInZip, FileTooLarge, RangeRequestUnsupported
15
15
  from zipwire._parser import (
16
+ EOCDInfo,
16
17
  find_eocd,
17
18
  parse_central_directory,
18
19
  parse_local_file_header,
@@ -22,10 +23,15 @@ from zipwire._zipinfo import RemoteZipInfo
22
23
  if typing.TYPE_CHECKING:
23
24
  from zipwire._types import AsyncReader, Writable
24
25
 
26
+ _NOT_LOADED = "archive not loaded; use as an async context manager"
27
+
25
28
 
26
29
  class AsyncRemoteZip:
27
30
  """Read files from a remote ZIP archive using async HTTP range requests.
28
31
 
32
+ Must be used as an async context manager. The archive is loaded on
33
+ entry and all internal state is cleared on exit. Not thread-safe.
34
+
29
35
  Usage::
30
36
 
31
37
  async with AsyncRemoteZip(reader) as rz:
@@ -39,21 +45,48 @@ class AsyncRemoteZip:
39
45
  self._entries: list[RemoteZipInfo] | None = None
40
46
  self._name_index: dict[str, RemoteZipInfo] | None = None
41
47
  self._file_size: int | None = None
48
+ self._eocd: EOCDInfo | None = None
42
49
 
43
50
  async def __aenter__(self) -> AsyncRemoteZip:
51
+ await self._ensure_loaded()
44
52
  return self
45
53
 
46
54
  async def __aexit__(self, *exc: object) -> None:
55
+ self._entries = None
56
+ self._name_index = None
57
+ self._file_size = None
58
+ self._eocd = None
47
59
  await self.close()
48
60
 
49
61
  async def close(self) -> None:
50
62
  """Close the underlying reader."""
51
63
  await self._reader.close()
52
64
 
65
+ @property
66
+ def url(self) -> str:
67
+ """The target URL of the underlying reader."""
68
+ return self._reader.url
69
+
70
+ @property
71
+ def file_size(self) -> int:
72
+ """Total size of the remote archive in bytes."""
73
+ if self._file_size is None:
74
+ raise RuntimeError(_NOT_LOADED)
75
+ return self._file_size
76
+
77
+ async def get_eocd_info(self) -> EOCDInfo:
78
+ """Return the parsed End of Central Directory record.
79
+
80
+ Contains ``cd_offset``, ``cd_size``, and ``cd_entry_count``.
81
+ """
82
+ if self._eocd is None:
83
+ raise RuntimeError(_NOT_LOADED)
84
+ return self._eocd
85
+
53
86
  async def _ensure_loaded(self) -> None:
54
- """Fetch and parse the central directory if not already done."""
87
+ """Fetch and parse the central directory."""
55
88
  if self._entries is not None:
56
- return
89
+ raise RuntimeError("_ensure_loaded() must not be called more than once")
57
90
 
58
91
  # Step 1: HEAD to get total size, then fetch the tail for EOCD search.
59
92
  # We use HEAD + absolute range instead of a single suffix-range
@@ -69,6 +102,7 @@ class AsyncRemoteZip:
69
102
 
70
103
  # Step 2: Parse EOCD
71
104
  eocd = find_eocd(tail, self._file_size)
105
+ self._eocd = eocd
72
106
 
73
107
  # Step 3: Fetch and parse central directory
74
108
  cd_data, _ = await self._reader.read_range(eocd.cd_offset, eocd.cd_size)
@@ -80,14 +114,14 @@ class AsyncRemoteZip:
80
114
 
81
115
  async def infolist(self) -> list[RemoteZipInfo]:
82
116
  """Return a list of RemoteZipInfo objects for all files in the archive."""
83
- await self._ensure_loaded()
84
- assert self._entries is not None
117
+ if self._entries is None:
118
+ raise RuntimeError(_NOT_LOADED)
85
119
  return list(self._entries)
86
120
 
87
121
  async def namelist(self) -> list[str]:
88
122
  """Return a list of filenames in the archive."""
89
- await self._ensure_loaded()
90
- assert self._entries is not None
123
+ if self._entries is None:
124
+ raise RuntimeError(_NOT_LOADED)
91
125
  return [info.filename for info in self._entries]
92
126
 
93
127
  async def getinfo(self, name: str) -> RemoteZipInfo:
@@ -96,8 +130,8 @@ class AsyncRemoteZip:
96
130
  Raises:
97
131
  FileNotFoundInZip: If the name is not in the archive.
98
132
  """
99
- await self._ensure_loaded()
100
- assert self._name_index is not None
133
+ if self._name_index is None:
134
+ raise RuntimeError(_NOT_LOADED)
101
135
  try:
102
136
  return self._name_index[name]
103
137
  except KeyError:
@@ -64,11 +64,11 @@ def decompress(
64
64
  result = lzma.decompress(data)
65
65
  case CompressionMethod.ZSTANDARD:
66
66
  try:
67
- import zstandard # ty: ignore[unresolved-import]
67
+ from compression.zstd import decompress as _zstd_decompress
68
68
  except ImportError: # pragma: no cover
69
69
  raise UnsupportedCompression(method) from None
70
70
 
71
- result = zstandard.ZstdDecompressor().decompress(data, max_output_size=expected_size)
71
+ result = _zstd_decompress(data)
72
72
  case _:
73
73
  raise UnsupportedCompression(method)
74
74
 
@@ -128,12 +128,12 @@ class StreamingDecompressor:
128
128
  self._dobj = lzma.LZMADecompressor()
129
129
  case CompressionMethod.ZSTANDARD:
130
130
  try:
131
- import zstandard # ty: ignore[unresolved-import]
131
+ from compression.zstd import ZstdDecompressor
132
132
  except ImportError: # pragma: no cover
133
133
  raise UnsupportedCompression(method) from None
134
134
 
135
135
  self._mode = _DecompressMode.INCREMENTAL
136
- self._dobj = zstandard.ZstdDecompressor().decompressobj()
136
+ self._dobj = ZstdDecompressor()
137
137
  case _:
138
138
  raise UnsupportedCompression(method)
139
139
 
@@ -13,6 +13,7 @@ from zipwire._constants import (
13
13
  from zipwire._decompress import StreamingDecompressor, decompress
14
14
  from zipwire._errors import FileNotFoundInZip, FileTooLarge, RangeRequestUnsupported
15
15
  from zipwire._parser import (
16
+ EOCDInfo,
16
17
  find_eocd,
17
18
  parse_central_directory,
18
19
  parse_local_file_header,
@@ -22,10 +23,15 @@ from zipwire._zipinfo import RemoteZipInfo
22
23
  if typing.TYPE_CHECKING:
23
24
  from zipwire._types import SyncReader, Writable
24
25
 
26
+ _NOT_LOADED = "archive not loaded; use as a context manager"
27
+
25
28
 
26
29
  class SyncRemoteZip:
27
30
  """Read files from a remote ZIP archive using synchronous HTTP range requests.
28
31
 
32
+ Must be used as a context manager. The archive is loaded on entry
33
+ and all internal state is cleared on exit. Not thread-safe.
34
+
29
35
  Usage::
30
36
 
31
37
  with SyncRemoteZip(reader) as rz:
@@ -39,21 +45,49 @@ class SyncRemoteZip:
39
45
  self._entries: list[RemoteZipInfo] | None = None
40
46
  self._name_index: dict[str, RemoteZipInfo] | None = None
41
47
  self._file_size: int | None = None
48
+ self._eocd: EOCDInfo | None = None
42
49
 
43
50
  def __enter__(self) -> SyncRemoteZip:
51
+ self._ensure_loaded()
44
52
  return self
45
53
 
46
54
  def __exit__(self, *exc: object) -> None:
55
+ self._entries = None
56
+ self._name_index = None
57
+ self._file_size = None
58
+ self._eocd = None
47
59
  self.close()
48
60
 
49
61
  def close(self) -> None:
50
62
  """Close the underlying reader."""
51
63
  self._reader.close()
52
64
 
65
+ @property
66
+ def url(self) -> str:
67
+ """The target URL of the underlying reader."""
68
+ return self._reader.url
69
+
70
+ @property
71
+ def file_size(self) -> int:
72
+ """Total size of the remote archive in bytes."""
73
+ if self._file_size is None:
74
+ raise RuntimeError(_NOT_LOADED)
75
+ return self._file_size
76
+
77
+ @property
78
+ def eocd_info(self) -> EOCDInfo:
79
+ """Parsed End of Central Directory record.
80
+
81
+ Contains ``cd_offset``, ``cd_size``, and ``cd_entry_count``.
82
+ """
83
+ if self._eocd is None:
84
+ raise RuntimeError(_NOT_LOADED)
85
+ return self._eocd
86
+
53
87
  def _ensure_loaded(self) -> None:
54
- """Fetch and parse the central directory if not already done."""
88
+ """Fetch and parse the central directory."""
55
89
  if self._entries is not None:
56
- return
90
+ raise RuntimeError("_ensure_loaded() must not be called more than once")
57
91
 
58
92
  # Step 1: HEAD to get total size, then fetch the tail for EOCD search.
59
93
  # We use HEAD + absolute range instead of a single suffix-range
@@ -69,6 +103,7 @@ class SyncRemoteZip:
69
103
 
70
104
  # Step 2: Parse EOCD
71
105
  eocd = find_eocd(tail, self._file_size)
106
+ self._eocd = eocd
72
107
 
73
108
  # Step 3: Fetch and parse central directory
74
109
  cd_data, _ = self._reader.read_range(eocd.cd_offset, eocd.cd_size)
@@ -80,14 +115,14 @@ class SyncRemoteZip:
80
115
 
81
116
  def infolist(self) -> list[RemoteZipInfo]:
82
117
  """Return a list of RemoteZipInfo objects for all files in the archive."""
83
- self._ensure_loaded()
84
- assert self._entries is not None
118
+ if self._entries is None:
119
+ raise RuntimeError(_NOT_LOADED)
85
120
  return list(self._entries)
86
121
 
87
122
  def namelist(self) -> list[str]:
88
123
  """Return a list of filenames in the archive."""
89
- self._ensure_loaded()
90
- assert self._entries is not None
124
+ if self._entries is None:
125
+ raise RuntimeError(_NOT_LOADED)
91
126
  return [info.filename for info in self._entries]
92
127
 
93
128
  def getinfo(self, name: str) -> RemoteZipInfo:
@@ -96,8 +131,8 @@ class SyncRemoteZip:
96
131
  Raises:
97
132
  FileNotFoundInZip: If the name is not in the archive.
98
133
  """
99
- self._ensure_loaded()
100
- assert self._name_index is not None
134
+ if self._name_index is None:
135
+ raise RuntimeError(_NOT_LOADED)
101
136
  try:
102
137
  return self._name_index[name]
103
138
  except KeyError:
@@ -32,6 +32,11 @@ class Headers(typing.Protocol):
32
32
  class SyncReader(typing.Protocol):
33
33
  """Protocol for synchronous HTTP range-request readers."""
34
34
 
35
+ @property
36
+ def url(self) -> str:
37
+ """The target URL for this reader."""
38
+ ...
39
+
35
40
  def read_range(
36
41
  self,
37
42
  offset: int,
@@ -67,6 +72,11 @@ class SyncReader(typing.Protocol):
67
72
  class AsyncReader(typing.Protocol):
68
73
  """Protocol for asynchronous HTTP range-request readers."""
69
74
 
75
+ @property
76
+ def url(self) -> str:
77
+ """The target URL for this reader."""
78
+ ...
79
+
70
80
  async def read_range(
71
81
  self,
72
82
  offset: int,
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.0'
22
- __version_tuple__ = version_tuple = (0, 1, 0)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -7,7 +7,7 @@ import typing
7
7
 
8
8
  try:
9
9
  import aiohttp
10
- except ImportError as exc: # pragma: no cover
10
+ except ImportError as exc:
11
11
  raise ImportError(
12
12
  "AiohttpReader requires aiohttp. Install it with: pip install zipwire[aiohttp]"
13
13
  ) from exc
@@ -38,6 +38,11 @@ class AiohttpReader:
38
38
  self._session = session or aiohttp.ClientSession()
39
39
  self._allow_redirects = allow_redirects
40
40
 
41
+ @property
42
+ def url(self) -> str:
43
+ """The target URL for this reader."""
44
+ return self._url
45
+
41
46
  async def head(self) -> Headers:
42
47
  logger.debug("HEAD %s", self._url)
43
48
  async with self._session.head(self._url, allow_redirects=self._allow_redirects) as resp:
@@ -7,7 +7,7 @@ import typing
7
7
 
8
8
  try:
9
9
  import httpx2
10
- except ImportError as exc: # pragma: no cover
10
+ except ImportError as exc:
11
11
  raise ImportError(
12
12
  "httpx2 readers require httpx2. Install it with: pip install zipwire[httpx2]"
13
13
  ) from exc
@@ -46,10 +46,15 @@ class Httpx2SyncReader:
46
46
  if client is not None:
47
47
  self._client = client
48
48
  else:
49
- if http2 is None:
49
+ if http2 is None: # pragma: no cover
50
50
  http2 = _h2_available
51
51
  self._client = httpx2.Client(http2=http2, follow_redirects=allow_redirects)
52
52
 
53
+ @property
54
+ def url(self) -> str:
55
+ """The target URL for this reader."""
56
+ return self._url
57
+
53
58
  def head(self) -> Headers:
54
59
  logger.debug("HEAD %s", self._url)
55
60
  resp = self._client.head(self._url)
@@ -109,10 +114,15 @@ class Httpx2AsyncReader:
109
114
  if client is not None:
110
115
  self._client = client
111
116
  else:
112
- if http2 is None:
117
+ if http2 is None: # pragma: no cover
113
118
  http2 = _h2_available
114
119
  self._client = httpx2.AsyncClient(http2=http2, follow_redirects=allow_redirects)
115
120
 
121
+ @property
122
+ def url(self) -> str:
123
+ """The target URL for this reader."""
124
+ return self._url
125
+
116
126
  async def head(self) -> Headers:
117
127
  logger.debug("HEAD %s", self._url)
118
128
  resp = await self._client.head(self._url)
@@ -7,7 +7,7 @@ import typing
7
7
 
8
8
  try:
9
9
  import requests
10
- except ImportError as exc: # pragma: no cover
10
+ except ImportError as exc:
11
11
  raise ImportError(
12
12
  "RequestsReader requires requests. Install it with: pip install zipwire[requests]"
13
13
  ) from exc
@@ -38,6 +38,11 @@ class RequestsReader:
38
38
  self._session = session or requests.Session()
39
39
  self._allow_redirects = allow_redirects
40
40
 
41
+ @property
42
+ def url(self) -> str:
43
+ """The target URL for this reader."""
44
+ return self._url
45
+
41
46
  def head(self) -> Headers:
42
47
  logger.debug("HEAD %s", self._url)
43
48
  resp = self._session.head(self._url, allow_redirects=self._allow_redirects)
@@ -33,6 +33,11 @@ class Urllib3Reader:
33
33
  self._pool = pool or urllib3.PoolManager()
34
34
  self._allow_redirects = allow_redirects
35
35
 
36
+ @property
37
+ def url(self) -> str:
38
+ """The target URL for this reader."""
39
+ return self._url
40
+
36
41
  def head(self) -> Headers:
37
42
  logger.debug("HEAD %s", self._url)
38
43
  resp = self._pool.request("HEAD", self._url, redirect=self._allow_redirects)