zipwire 0.2.0__tar.gz → 0.3.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 (54) hide show
  1. {zipwire-0.2.0 → zipwire-0.3.0}/PKG-INFO +1 -1
  2. {zipwire-0.2.0 → zipwire-0.3.0}/docs/api.rst +15 -0
  3. {zipwire-0.2.0 → zipwire-0.3.0}/docs/quickstart.rst +42 -1
  4. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/__init__.py +25 -1
  5. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/__main__.py +1 -1
  6. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_async.py +23 -74
  7. zipwire-0.3.0/src/zipwire/_base.py +97 -0
  8. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_sync.py +21 -70
  9. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_version.py +2 -2
  10. zipwire-0.3.0/src/zipwire/_wheel.py +222 -0
  11. {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_async.py +25 -24
  12. {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_integration.py +11 -13
  13. zipwire-0.3.0/tests/test_wheel.py +268 -0
  14. {zipwire-0.2.0 → zipwire-0.3.0}/.github/dependabot.yml +0 -0
  15. {zipwire-0.2.0 → zipwire-0.3.0}/.github/workflows/ci.yml +0 -0
  16. {zipwire-0.2.0 → zipwire-0.3.0}/.github/workflows/codeql.yml +0 -0
  17. {zipwire-0.2.0 → zipwire-0.3.0}/.github/workflows/release.yml +0 -0
  18. {zipwire-0.2.0 → zipwire-0.3.0}/.github/workflows/scorecard.yml +0 -0
  19. {zipwire-0.2.0 → zipwire-0.3.0}/.gitignore +0 -0
  20. {zipwire-0.2.0 → zipwire-0.3.0}/.readthedocs.yaml +0 -0
  21. {zipwire-0.2.0 → zipwire-0.3.0}/AGENTS.md +0 -0
  22. {zipwire-0.2.0 → zipwire-0.3.0}/CLAUDE.md +0 -0
  23. {zipwire-0.2.0 → zipwire-0.3.0}/LICENSE +0 -0
  24. {zipwire-0.2.0 → zipwire-0.3.0}/README.md +0 -0
  25. {zipwire-0.2.0 → zipwire-0.3.0}/SECURITY.md +0 -0
  26. {zipwire-0.2.0 → zipwire-0.3.0}/docs/Makefile +0 -0
  27. {zipwire-0.2.0 → zipwire-0.3.0}/docs/backends.rst +0 -0
  28. {zipwire-0.2.0 → zipwire-0.3.0}/docs/conf.py +0 -0
  29. {zipwire-0.2.0 → zipwire-0.3.0}/docs/index.rst +0 -0
  30. {zipwire-0.2.0 → zipwire-0.3.0}/docs/requirements.txt +0 -0
  31. {zipwire-0.2.0 → zipwire-0.3.0}/docs/security.rst +0 -0
  32. {zipwire-0.2.0 → zipwire-0.3.0}/pyproject.toml +0 -0
  33. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_constants.py +0 -0
  34. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_decompress.py +0 -0
  35. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_errors.py +0 -0
  36. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_parser.py +0 -0
  37. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_types.py +0 -0
  38. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_zipinfo.py +0 -0
  39. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/__init__.py +0 -0
  40. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/_aiohttp.py +0 -0
  41. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/_httpx2.py +0 -0
  42. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/_requests.py +0 -0
  43. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/_urllib3.py +0 -0
  44. {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/py.typed +0 -0
  45. {zipwire-0.2.0 → zipwire-0.3.0}/tests/__init__.py +0 -0
  46. {zipwire-0.2.0 → zipwire-0.3.0}/tests/conftest.py +0 -0
  47. {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_backends.py +0 -0
  48. {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_cli.py +0 -0
  49. {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_decompress.py +0 -0
  50. {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_parser.py +0 -0
  51. {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_sync.py +0 -0
  52. {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_zipinfo.py +0 -0
  53. {zipwire-0.2.0 → zipwire-0.3.0}/tox.ini +0 -0
  54. {zipwire-0.2.0 → zipwire-0.3.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zipwire
3
- Version: 0.2.0
3
+ Version: 0.3.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/
@@ -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
 
@@ -83,7 +83,7 @@ Async with httpx2
83
83
  async def main():
84
84
  reader = Httpx2AsyncReader("https://archive.example/data.zip")
85
85
  async with AsyncRemoteZip(reader) as rz:
86
- names = await rz.namelist()
86
+ names = rz.namelist()
87
87
  print(names)
88
88
 
89
89
 
@@ -109,6 +109,47 @@ Async streaming to disk
109
109
 
110
110
  asyncio.run(main())
111
111
 
112
+ Wheel dist-info metadata
113
+ ------------------------
114
+
115
+ ``SyncRemoteWheel`` and ``AsyncRemoteWheel`` extend the base classes
116
+ with an adaptive tail fetch and a ``distinfolist()`` method. The tail
117
+ size scales with the archive (at least 128 KiB, up to ~0.4% of the
118
+ file) so that dist-info entries are typically served from memory
119
+ without extra HTTP requests.
120
+
121
+ .. code-block:: python
122
+
123
+ from zipwire import SyncRemoteWheel
124
+ from zipwire.backends import Urllib3Reader
125
+
126
+ url = "https://files.pythonhosted.org/.../requests-2.32.3-py3-none-any.whl"
127
+ reader = Urllib3Reader(url)
128
+ with SyncRemoteWheel(reader) as whl:
129
+ for entry in whl.distinfolist():
130
+ data = whl.read(entry)
131
+ print(entry.filename, len(data))
132
+
133
+ Async variant:
134
+
135
+ .. code-block:: python
136
+
137
+ import asyncio
138
+
139
+ from zipwire import AsyncRemoteWheel
140
+ from zipwire.backends import AiohttpReader
141
+
142
+
143
+ async def main():
144
+ reader = AiohttpReader(url)
145
+ async with AsyncRemoteWheel(reader) as whl:
146
+ for entry in whl.distinfolist():
147
+ data = await whl.read(entry)
148
+ print(entry.filename, len(data))
149
+
150
+
151
+ asyncio.run(main())
152
+
112
153
  Passing a pre-configured client
113
154
  --------------------------------
114
155
 
@@ -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,27 @@ 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
+ Wheel subclasses
63
+ ----------------
64
+
65
+ ``SyncRemoteWheel`` and ``AsyncRemoteWheel`` extend the base classes
66
+ for Python wheels. They parse the wheel URL to derive the
67
+ ``.dist-info`` directory name, fetch an adaptive tail (at least
68
+ 128 KiB, scaling with archive size) to cover dist-info entries at
69
+ the end of the archive, and serve matching reads from the tail
70
+ buffer without extra HTTP requests.
71
+
72
+ ::
73
+
74
+ from zipwire import SyncRemoteWheel
75
+ from zipwire.backends import Urllib3Reader
76
+
77
+ url = "https://files.pythonhosted.org/.../requests-2.32.3-py3-none-any.whl"
78
+ reader = Urllib3Reader(url)
79
+ with SyncRemoteWheel(reader) as whl:
80
+ for entry in whl.distinfolist():
81
+ print(entry.filename, whl.read(entry))
61
82
  """
62
83
 
63
84
  from __future__ import annotations
@@ -76,10 +97,12 @@ from zipwire._errors import (
76
97
  from zipwire._parser import EOCDInfo
77
98
  from zipwire._sync import SyncRemoteZip
78
99
  from zipwire._types import AsyncReader, Headers, SyncReader, Writable
100
+ from zipwire._wheel import AsyncRemoteWheel, SyncRemoteWheel
79
101
  from zipwire._zipinfo import RemoteZipInfo
80
102
 
81
103
  __all__ = [
82
104
  "AsyncReader",
105
+ "AsyncRemoteWheel",
83
106
  "AsyncRemoteZip",
84
107
  "BadZipFile",
85
108
  "CRCMismatch",
@@ -91,6 +114,7 @@ __all__ = [
91
114
  "RangeRequestUnsupported",
92
115
  "RemoteZipInfo",
93
116
  "SyncReader",
117
+ "SyncRemoteWheel",
94
118
  "SyncRemoteZip",
95
119
  "UnsupportedCompression",
96
120
  "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:
@@ -4,29 +4,22 @@ from __future__ import annotations
4
4
 
5
5
  import typing
6
6
 
7
+ from zipwire._base import RemoteZipBase
7
8
  from zipwire._constants import (
8
9
  LOCAL_FILE_HEADER_SIZE,
9
- MAX_EOCD_SEARCH,
10
10
  PREFETCH_EXTRA,
11
11
  PREFETCH_THRESHOLD,
12
12
  )
13
13
  from zipwire._decompress import StreamingDecompressor, decompress
14
- from zipwire._errors import FileNotFoundInZip, FileTooLarge, RangeRequestUnsupported
15
- from zipwire._parser import (
16
- EOCDInfo,
17
- find_eocd,
18
- parse_central_directory,
19
- parse_local_file_header,
20
- )
14
+ from zipwire._errors import FileTooLarge, RangeRequestUnsupported
15
+ from zipwire._parser import find_eocd, parse_local_file_header
21
16
  from zipwire._zipinfo import RemoteZipInfo
22
17
 
23
18
  if typing.TYPE_CHECKING:
24
19
  from zipwire._types import AsyncReader, Writable
25
20
 
26
- _NOT_LOADED = "archive not loaded; use as an async context manager"
27
-
28
21
 
29
- class AsyncRemoteZip:
22
+ class AsyncRemoteZip(RemoteZipBase):
30
23
  """Read files from a remote ZIP archive using async HTTP range requests.
31
24
 
32
25
  Must be used as an async context manager. The archive is loaded on
@@ -35,27 +28,21 @@ class AsyncRemoteZip:
35
28
  Usage::
36
29
 
37
30
  async with AsyncRemoteZip(reader) as rz:
38
- for info in await rz.infolist():
31
+ for info in rz.infolist():
39
32
  print(info.filename)
40
33
  data = await rz.read("path/to/file.txt")
41
34
  """
42
35
 
43
36
  def __init__(self, reader: AsyncReader) -> None:
37
+ super().__init__()
44
38
  self._reader = reader
45
- self._entries: list[RemoteZipInfo] | None = None
46
- self._name_index: dict[str, RemoteZipInfo] | None = None
47
- self._file_size: int | None = None
48
- self._eocd: EOCDInfo | None = None
49
39
 
50
40
  async def __aenter__(self) -> AsyncRemoteZip:
51
41
  await self._ensure_loaded()
52
42
  return self
53
43
 
54
44
  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
45
+ self._clear()
59
46
  await self.close()
60
47
 
61
48
  async def close(self) -> None:
@@ -67,24 +54,16 @@ class AsyncRemoteZip:
67
54
  """The target URL of the underlying reader."""
68
55
  return self._reader.url
69
56
 
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
57
+ async def _ensure_loaded(self) -> tuple[int, bytes]:
58
+ """Fetch and parse the central directory.
76
59
 
77
- async def get_eocd_info(self) -> EOCDInfo:
78
- """Return the parsed End of Central Directory record.
60
+ Returns:
61
+ ``(tail_start, tail_data)`` -- the offset and bytes of the
62
+ initial tail fetch.
79
63
 
80
- Contains ``cd_offset``, ``cd_size``, and ``cd_entry_count``.
64
+ Raises:
65
+ RuntimeError: If called more than once.
81
66
  """
82
- if self._eocd is None:
83
- raise RuntimeError(_NOT_LOADED)
84
- return self._eocd
85
-
86
- async def _ensure_loaded(self) -> None:
87
- """Fetch and parse the central directory."""
88
67
  if self._entries is not None:
89
68
  raise RuntimeError("_ensure_loaded() must not be called more than once")
90
69
 
@@ -96,46 +75,19 @@ class AsyncRemoteZip:
96
75
  cl = head_headers.get("content-length")
97
76
  if cl is None:
98
77
  raise RangeRequestUnsupported("Server did not return a Content-Length header")
99
- self._file_size = int(cl)
100
- tail_start = max(0, self._file_size - MAX_EOCD_SEARCH)
101
- tail, _ = await self._reader.read_range(tail_start, self._file_size - tail_start)
78
+ file_size = int(cl)
79
+ fetch_size = min(file_size, self._tail_read_size(file_size))
80
+ tail_start = max(0, file_size - fetch_size)
81
+ tail, _ = await self._reader.read_range(tail_start, file_size - tail_start)
102
82
 
103
83
  # Step 2: Parse EOCD
104
- eocd = find_eocd(tail, self._file_size)
105
- self._eocd = eocd
84
+ eocd = find_eocd(tail, file_size)
106
85
 
107
- # Step 3: Fetch and parse central directory
86
+ # Step 3: Fetch central directory and store parsed state
108
87
  cd_data, _ = await self._reader.read_range(eocd.cd_offset, eocd.cd_size)
109
- raw_entries = parse_central_directory(cd_data, eocd.cd_entry_count)
110
-
111
- # Step 4: Convert to RemoteZipInfo objects
112
- self._entries = [RemoteZipInfo._from_central_dir_entry(e) for e in raw_entries]
113
- self._name_index = {info.filename: info for info in self._entries}
88
+ self._set_entries(cd_data, file_size, eocd)
114
89
 
115
- async def infolist(self) -> list[RemoteZipInfo]:
116
- """Return a list of RemoteZipInfo objects for all files in the archive."""
117
- if self._entries is None:
118
- raise RuntimeError(_NOT_LOADED)
119
- return list(self._entries)
120
-
121
- async def namelist(self) -> list[str]:
122
- """Return a list of filenames in the archive."""
123
- if self._entries is None:
124
- raise RuntimeError(_NOT_LOADED)
125
- return [info.filename for info in self._entries]
126
-
127
- async def getinfo(self, name: str) -> RemoteZipInfo:
128
- """Return the RemoteZipInfo for the given filename.
129
-
130
- Raises:
131
- FileNotFoundInZip: If the name is not in the archive.
132
- """
133
- if self._name_index is None:
134
- raise RuntimeError(_NOT_LOADED)
135
- try:
136
- return self._name_index[name]
137
- except KeyError:
138
- raise FileNotFoundInZip(name) from None
90
+ return tail_start, tail
139
91
 
140
92
  async def _resolve_entry(
141
93
  self,
@@ -149,10 +101,7 @@ class AsyncRemoteZip:
149
101
  compressed data are fetched in a single request and *prefetched*
150
102
  contains the compressed bytes. Otherwise *prefetched* is ``None``.
151
103
  """
152
- if isinstance(name, RemoteZipInfo):
153
- info = name
154
- else:
155
- info = await self.getinfo(name)
104
+ info = name if isinstance(name, RemoteZipInfo) else self.getinfo(name)
156
105
  if info.is_dir():
157
106
  return None
158
107
 
@@ -0,0 +1,97 @@
1
+ """Common base class for sync and async remote ZIP readers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zipwire._constants import MAX_EOCD_SEARCH
6
+ from zipwire._errors import FileNotFoundInZip
7
+ from zipwire._parser import EOCDInfo, parse_central_directory
8
+ from zipwire._zipinfo import RemoteZipInfo
9
+
10
+ _NOT_LOADED = "archive not loaded; use as a context manager"
11
+
12
+
13
+ class RemoteZipBase:
14
+ """Shared state and sync accessors for remote ZIP readers.
15
+
16
+ This class holds the parsed central directory entries, the name
17
+ index, file size, and EOCD record. It is not meant to be
18
+ instantiated directly.
19
+ """
20
+
21
+ def __init__(self) -> None:
22
+ self._entries: list[RemoteZipInfo] | None = None
23
+ self._name_index: dict[str, RemoteZipInfo] | None = None
24
+ self._file_size: int | None = None
25
+ self._eocd: EOCDInfo | None = None
26
+
27
+ def _clear(self) -> None:
28
+ """Reset all loaded state."""
29
+ self._entries = None
30
+ self._name_index = None
31
+ self._file_size = None
32
+ self._eocd = None
33
+
34
+ def _set_entries(
35
+ self,
36
+ cd_data: bytes,
37
+ file_size: int,
38
+ eocd: EOCDInfo,
39
+ ) -> None:
40
+ """Parse central directory data and store entries."""
41
+ raw_entries = parse_central_directory(cd_data, eocd.cd_entry_count)
42
+ entries = [RemoteZipInfo._from_central_dir_entry(e) for e in raw_entries]
43
+ self._entries = entries
44
+ self._name_index = {info.filename: info for info in entries}
45
+ self._file_size = file_size
46
+ self._eocd = eocd
47
+
48
+ @property
49
+ def file_size(self) -> int:
50
+ """Total size of the remote archive in bytes."""
51
+ if self._file_size is None:
52
+ raise RuntimeError(_NOT_LOADED)
53
+ return self._file_size
54
+
55
+ @property
56
+ def eocd_info(self) -> EOCDInfo:
57
+ """Parsed End of Central Directory record.
58
+
59
+ Contains ``cd_offset``, ``cd_size``, and ``cd_entry_count``.
60
+ """
61
+ if self._eocd is None:
62
+ raise RuntimeError(_NOT_LOADED)
63
+ return self._eocd
64
+
65
+ def _tail_read_size(self, file_size: int) -> int:
66
+ """Return the number of bytes to fetch from the end of the file.
67
+
68
+ Called during loading to determine the tail size.
69
+ The default returns :data:`MAX_EOCD_SEARCH`. Subclasses may
70
+ override this to request a larger tail.
71
+ """
72
+ return MAX_EOCD_SEARCH
73
+
74
+ def infolist(self) -> list[RemoteZipInfo]:
75
+ """Return a list of RemoteZipInfo objects for all files in the archive."""
76
+ if self._entries is None:
77
+ raise RuntimeError(_NOT_LOADED)
78
+ return list(self._entries)
79
+
80
+ def namelist(self) -> list[str]:
81
+ """Return a list of filenames in the archive."""
82
+ if self._entries is None:
83
+ raise RuntimeError(_NOT_LOADED)
84
+ return [info.filename for info in self._entries]
85
+
86
+ def getinfo(self, name: str) -> RemoteZipInfo:
87
+ """Return the RemoteZipInfo for the given filename.
88
+
89
+ Raises:
90
+ FileNotFoundInZip: If the name is not in the archive.
91
+ """
92
+ if self._name_index is None:
93
+ raise RuntimeError(_NOT_LOADED)
94
+ try:
95
+ return self._name_index[name]
96
+ except KeyError:
97
+ raise FileNotFoundInZip(name) from None
@@ -4,29 +4,22 @@ from __future__ import annotations
4
4
 
5
5
  import typing
6
6
 
7
+ from zipwire._base import RemoteZipBase
7
8
  from zipwire._constants import (
8
9
  LOCAL_FILE_HEADER_SIZE,
9
- MAX_EOCD_SEARCH,
10
10
  PREFETCH_EXTRA,
11
11
  PREFETCH_THRESHOLD,
12
12
  )
13
13
  from zipwire._decompress import StreamingDecompressor, decompress
14
- from zipwire._errors import FileNotFoundInZip, FileTooLarge, RangeRequestUnsupported
15
- from zipwire._parser import (
16
- EOCDInfo,
17
- find_eocd,
18
- parse_central_directory,
19
- parse_local_file_header,
20
- )
14
+ from zipwire._errors import FileTooLarge, RangeRequestUnsupported
15
+ from zipwire._parser import find_eocd, parse_local_file_header
21
16
  from zipwire._zipinfo import RemoteZipInfo
22
17
 
23
18
  if typing.TYPE_CHECKING:
24
19
  from zipwire._types import SyncReader, Writable
25
20
 
26
- _NOT_LOADED = "archive not loaded; use as a context manager"
27
-
28
21
 
29
- class SyncRemoteZip:
22
+ class SyncRemoteZip(RemoteZipBase):
30
23
  """Read files from a remote ZIP archive using synchronous HTTP range requests.
31
24
 
32
25
  Must be used as a context manager. The archive is loaded on entry
@@ -41,21 +34,15 @@ class SyncRemoteZip:
41
34
  """
42
35
 
43
36
  def __init__(self, reader: SyncReader) -> None:
37
+ super().__init__()
44
38
  self._reader = reader
45
- self._entries: list[RemoteZipInfo] | None = None
46
- self._name_index: dict[str, RemoteZipInfo] | None = None
47
- self._file_size: int | None = None
48
- self._eocd: EOCDInfo | None = None
49
39
 
50
40
  def __enter__(self) -> SyncRemoteZip:
51
41
  self._ensure_loaded()
52
42
  return self
53
43
 
54
44
  def __exit__(self, *exc: object) -> None:
55
- self._entries = None
56
- self._name_index = None
57
- self._file_size = None
58
- self._eocd = None
45
+ self._clear()
59
46
  self.close()
60
47
 
61
48
  def close(self) -> None:
@@ -67,25 +54,16 @@ class SyncRemoteZip:
67
54
  """The target URL of the underlying reader."""
68
55
  return self._reader.url
69
56
 
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
57
+ def _ensure_loaded(self) -> tuple[int, bytes]:
58
+ """Fetch and parse the central directory.
76
59
 
77
- @property
78
- def eocd_info(self) -> EOCDInfo:
79
- """Parsed End of Central Directory record.
60
+ Returns:
61
+ ``(tail_start, tail_data)`` -- the offset and bytes of the
62
+ initial tail fetch.
80
63
 
81
- Contains ``cd_offset``, ``cd_size``, and ``cd_entry_count``.
64
+ Raises:
65
+ RuntimeError: If called more than once.
82
66
  """
83
- if self._eocd is None:
84
- raise RuntimeError(_NOT_LOADED)
85
- return self._eocd
86
-
87
- def _ensure_loaded(self) -> None:
88
- """Fetch and parse the central directory."""
89
67
  if self._entries is not None:
90
68
  raise RuntimeError("_ensure_loaded() must not be called more than once")
91
69
 
@@ -97,46 +75,19 @@ class SyncRemoteZip:
97
75
  cl = head_headers.get("content-length")
98
76
  if cl is None:
99
77
  raise RangeRequestUnsupported("Server did not return a Content-Length header")
100
- self._file_size = int(cl)
101
- tail_start = max(0, self._file_size - MAX_EOCD_SEARCH)
102
- tail, _ = self._reader.read_range(tail_start, self._file_size - tail_start)
78
+ file_size = int(cl)
79
+ fetch_size = min(file_size, self._tail_read_size(file_size))
80
+ tail_start = max(0, file_size - fetch_size)
81
+ tail, _ = self._reader.read_range(tail_start, file_size - tail_start)
103
82
 
104
83
  # Step 2: Parse EOCD
105
- eocd = find_eocd(tail, self._file_size)
106
- self._eocd = eocd
84
+ eocd = find_eocd(tail, file_size)
107
85
 
108
- # Step 3: Fetch and parse central directory
86
+ # Step 3: Fetch central directory and store parsed state
109
87
  cd_data, _ = self._reader.read_range(eocd.cd_offset, eocd.cd_size)
110
- raw_entries = parse_central_directory(cd_data, eocd.cd_entry_count)
88
+ self._set_entries(cd_data, file_size, eocd)
111
89
 
112
- # Step 4: Convert to RemoteZipInfo objects
113
- self._entries = [RemoteZipInfo._from_central_dir_entry(e) for e in raw_entries]
114
- self._name_index = {info.filename: info for info in self._entries}
115
-
116
- def infolist(self) -> list[RemoteZipInfo]:
117
- """Return a list of RemoteZipInfo objects for all files in the archive."""
118
- if self._entries is None:
119
- raise RuntimeError(_NOT_LOADED)
120
- return list(self._entries)
121
-
122
- def namelist(self) -> list[str]:
123
- """Return a list of filenames in the archive."""
124
- if self._entries is None:
125
- raise RuntimeError(_NOT_LOADED)
126
- return [info.filename for info in self._entries]
127
-
128
- def getinfo(self, name: str) -> RemoteZipInfo:
129
- """Return the RemoteZipInfo for the given filename.
130
-
131
- Raises:
132
- FileNotFoundInZip: If the name is not in the archive.
133
- """
134
- if self._name_index is None:
135
- raise RuntimeError(_NOT_LOADED)
136
- try:
137
- return self._name_index[name]
138
- except KeyError:
139
- raise FileNotFoundInZip(name) from None
90
+ return tail_start, tail
140
91
 
141
92
  def _resolve_entry(
142
93
  self,
@@ -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.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.3.0'
22
+ __version_tuple__ = version_tuple = (0, 3, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None