fab-build 0.0.2__tar.gz → 0.0.2.dev1__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 (65) hide show
  1. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/PKG-INFO +2 -1
  2. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/pyproject.toml +9 -1
  3. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/pyproject.toml.orig +9 -13
  4. fab_build-0.0.2.dev1/src/fab/caching/diskcache.py +248 -0
  5. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/caching/hit_counter.py +1 -0
  6. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/caching/nullcache.py +2 -0
  7. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/caching/test_diskcache.py +10 -4
  8. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/caching/test_diskcache_log.py +5 -8
  9. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/cli.py +44 -17
  10. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/executors/simple_executor.py +1 -0
  11. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/buildins.py +6 -5
  12. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/data.py +1 -1
  13. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/evaluation_context.py +9 -11
  14. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/parser.py +1 -0
  15. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/test_parser.py +1 -0
  16. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/util.py +3 -2
  17. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/model/__init__.py +4 -4
  18. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/model/cache.py +1 -0
  19. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/model/oci.py +2 -0
  20. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/model/operation.py +1 -0
  21. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/model/outputchunks.py +1 -2
  22. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/oci/podman.py +1 -3
  23. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/operations/__init__.py +1 -0
  24. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/operations/containerized_gcc.py +5 -6
  25. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/operations/extract.py +1 -2
  26. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/operations/gcc.py +2 -5
  27. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/operations/test_containerized_gcc.py +6 -7
  28. fab_build-0.0.2.dev1/src/fab/util/bytesizestr.py +42 -0
  29. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/util/hash_objects.py +1 -0
  30. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/util/string_slices.py +2 -2
  31. fab_build-0.0.2.dev1/src/fab/util/test_bytesizestr.py +5 -0
  32. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/util/test_hash_path.py +7 -3
  33. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/util/test_string_slices.py +2 -1
  34. fab_build-0.0.2/src/fab/caching/diskcache.py +0 -165
  35. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/.pytest_cache/.gitignore +0 -0
  36. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/.pytest_cache/CACHEDIR.TAG +0 -0
  37. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/.pytest_cache/README.md +0 -0
  38. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/.pytest_cache/v/cache/nodeids +0 -0
  39. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/__init__.py +0 -0
  40. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/caching/__init__.py +2 -2
  41. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/caching/memory.py +1 -1
  42. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/.pytest_cache/.gitignore +0 -0
  43. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/.pytest_cache/CACHEDIR.TAG +0 -0
  44. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/.pytest_cache/README.md +0 -0
  45. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/.pytest_cache/v/cache/lastfailed +0 -0
  46. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/.pytest_cache/v/cache/nodeids +0 -0
  47. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/__init__.py +0 -0
  48. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/astree.py +0 -0
  49. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/evaluator.py +1 -1
  50. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/grammar.lark +0 -0
  51. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/test_buildins.py +0 -0
  52. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/lang/test_evaluator.py +0 -0
  53. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/model/errors.py +0 -0
  54. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/model/executor.py +0 -0
  55. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/oci/__init__.py +0 -0
  56. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/oci/container_admin.py +0 -0
  57. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/oci/test_podman.py +0 -0
  58. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/operations/http_archive.py +0 -0
  59. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/operations/http_get.py +0 -0
  60. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/operations/test_http_get.py +0 -0
  61. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/util/__init__.py +0 -0
  62. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/util/flatten_list.py +0 -0
  63. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/util/hash_path.py +1 -1
  64. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/util/temp_file_structure.py +0 -0
  65. {fab_build-0.0.2 → fab_build-0.0.2.dev1}/src/fab/util/test_flatten_list.py +0 -0
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fab-build
3
- Version: 0.0.2
3
+ Version: 0.0.2.dev1
4
4
  Requires-Dist: docopt
5
5
  Requires-Dist: blake3
6
6
  Requires-Dist: lark
7
7
  Requires-Dist: aiohttp
8
8
  Requires-Dist: yarl
9
9
  Requires-Dist: xdg-base-dirs
10
+ Requires-Dist: lark-rust>=0.2.1
10
11
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fab-build"
3
- version = "0.0.2"
3
+ version = "0.0.2.dev1"
4
4
  requires-python = ">=3.12"
5
5
  dependencies = [
6
6
  "docopt",
@@ -9,6 +9,7 @@ dependencies = [
9
9
  "aiohttp",
10
10
  "yarl",
11
11
  "xdg-base-dirs",
12
+ "lark-rust>=0.2.1",
12
13
  ]
13
14
 
14
15
  [project.scripts]
@@ -20,3 +21,10 @@ module-name = "fab"
20
21
  [build-system]
21
22
  requires = ["uv_build>=0.13.3,<0.14.0"]
22
23
  build-backend = "uv_build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "pytest>=9.1.1",
28
+ "pytest-aiohttp>=1.1.1",
29
+ "pytest-asyncio>=1.4.0",
30
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fab-build"
3
- version = "0.0.2"
3
+ version = "0.0.2.dev1"
4
4
  requires-python = ">=3.12"
5
5
  dependencies = [
6
6
  "docopt",
@@ -9,20 +9,9 @@ dependencies = [
9
9
  "aiohttp",
10
10
  "yarl",
11
11
  "xdg-base-dirs",
12
+ "lark-rust>=0.2.1",
12
13
  ]
13
14
 
14
- # packages = [
15
- # {include = "fab"}
16
- #
17
- # ]
18
-
19
- # [tool.setuptools.packages.find]
20
- # where = ["src"]
21
-
22
-
23
- # [tool.setuptools.package-dir]
24
- # fab = "src/fab"
25
-
26
15
  [tool.uv.build-backend]
27
16
  module-name = "fab"
28
17
 
@@ -33,3 +22,10 @@ fab = "fab:cli.main"
33
22
  [build-system]
34
23
  requires = ["uv_build>=0.13.3,<0.14.0"]
35
24
  build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest>=9.1.1",
29
+ "pytest-aiohttp>=1.1.1",
30
+ "pytest-asyncio>=1.4.0",
31
+ ]
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import os
5
+ import shutil
6
+ from collections.abc import Generator
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import override
10
+
11
+ import blake3
12
+
13
+ from ..model import Cache
14
+ from ..util.hash_path import hash_path
15
+ from ..util.string_slices import string_slices
16
+
17
+
18
+ class DiskCacheError(RuntimeError):
19
+ pass
20
+
21
+
22
+ class Log:
23
+ MAX_KEY_LENGTH = 32 # bytes
24
+ RECORD_LENGTH = len("YYYY-MM-DDTHH:MM:SS " + (MAX_KEY_LENGTH) * "00" + "\n")
25
+
26
+ def __init__(self, path: Path):
27
+ self.__path = path
28
+ self.__fd = self.__open(self.__path)
29
+
30
+ def __open(self, path: Path) -> int:
31
+ fd = os.open(path, os.O_APPEND | os.O_RDWR | os.O_CREAT)
32
+ os.lseek(fd, 0, os.SEEK_END)
33
+ return fd
34
+
35
+ def write(self, timestamp: datetime, op_key: bytes):
36
+ assert len(op_key) <= self.MAX_KEY_LENGTH
37
+ time_str = timestamp.isoformat(timespec="seconds")
38
+ os.write(
39
+ self.__fd,
40
+ f"{time_str} {op_key.hex().ljust(self.MAX_KEY_LENGTH * 2)}\n".encode(),
41
+ )
42
+
43
+ def read_new_to_old(self) -> Generator[tuple[datetime, bytes]]:
44
+ position = os.lseek(self.__fd, 0, os.SEEK_CUR) - self.RECORD_LENGTH
45
+ while position >= 0:
46
+ record = self.__read_record(position)
47
+ if record is None:
48
+ break
49
+ yield record
50
+ position -= self.RECORD_LENGTH
51
+
52
+ def oldest_entry(self) -> tuple[datetime, bytes] | None:
53
+ """Return the oldest entry from the log"""
54
+ return self.__read_record(0)
55
+
56
+ def newest_entry(self) -> tuple[datetime, bytes] | None:
57
+ """Return the newest entry from the log"""
58
+ position = os.lseek(self.__fd, 0, os.SEEK_CUR) - self.RECORD_LENGTH
59
+ if position < 0:
60
+ return None
61
+ return self.__read_record(position)
62
+
63
+ def record_count(self) -> int:
64
+ end = os.lseek(self.__fd, 0, os.SEEK_CUR)
65
+ return end // self.RECORD_LENGTH
66
+
67
+ def replace_with(self, other: Log):
68
+ os.close(self.__fd)
69
+ os.rename(other.__path, self.__path)
70
+ self.__fd = self.__open(self.__path)
71
+
72
+ def __read_record(self, position) -> tuple[datetime, bytes] | None:
73
+ record = os.pread(self.__fd, self.RECORD_LENGTH, position).decode()
74
+ if len(record) != self.RECORD_LENGTH:
75
+ return None
76
+ time_str, _, key_str = record.partition(" ")
77
+ timestamp = datetime.fromisoformat(time_str)
78
+ op_key = bytes.fromhex(key_str)
79
+ return timestamp, op_key
80
+
81
+
82
+ @dataclasses.dataclass
83
+ class DiskCacheInfo:
84
+ path: Path
85
+ n_ops: int
86
+ n_blobs: int
87
+ size_blobs: int
88
+ oldest_op: datetime | None
89
+ newest_op: datetime | None
90
+ log_records: int
91
+
92
+
93
+ class DiskCache(Cache):
94
+ def __init__(self, root: Path) -> None:
95
+ self.__root = root
96
+ self.__ops = root / "v1/ops"
97
+ self.__ops.mkdir(parents=True, exist_ok=True)
98
+
99
+ self.__blobs = root / "v1/blobs"
100
+ self.__blobs.mkdir(parents=True, exist_ok=True)
101
+
102
+ self.__log_path = root / "v1/log"
103
+ self.__log = Log(self.__log_path)
104
+
105
+ @override
106
+ async def has(self, op_key: bytes) -> bool:
107
+ op_path = self.__get_op_path(op_key)
108
+ result = op_path.exists()
109
+ if result:
110
+ self.__log.write(datetime.now(), op_key)
111
+ return result
112
+
113
+ @override
114
+ async def get_path(self, op_key: bytes) -> Path:
115
+ return self.__get_path(op_key)
116
+
117
+ def __get_path(self, op_key: bytes) -> Path:
118
+ op_path = self.__get_op_path(op_key)
119
+ if not op_path.is_dir():
120
+ raise DiskCacheError(f"{op_path} does not exist")
121
+
122
+ object_link = op_path / "object"
123
+ if not object_link.is_symlink():
124
+ raise DiskCacheError(f"Corrupt cache, {object_link} is not a symlink")
125
+
126
+ blob_path = object_link.resolve()
127
+
128
+ return blob_path
129
+
130
+ @override
131
+ async def store_path(self, op_key: bytes, path: Path) -> Path:
132
+ if not path.exists():
133
+ raise DiskCacheError(f"Cannot store non-existent path {path} in cache")
134
+
135
+ blob_key = hash_path(blake3.blake3(), path)
136
+
137
+ blob_path = self.__get_blob_path(blob_key)
138
+ blob_path.mkdir(parents=True, exist_ok=True)
139
+ obj_path = blob_path / path.name
140
+ if not obj_path.exists():
141
+ path.rename(obj_path)
142
+
143
+ op_path = self.__get_op_path(op_key)
144
+ if op_path.exists():
145
+ raise DiskCacheError(
146
+ f"Operation with key '{op_key.hex()}' already exists in cache"
147
+ )
148
+
149
+ op_path.mkdir(parents=True, exist_ok=True)
150
+ (op_path / "object").symlink_to(obj_path.relative_to(op_path, walk_up=True))
151
+
152
+ self.__log.write(datetime.now(), op_key)
153
+ return obj_path
154
+
155
+ def prune_to_size(self, size: int):
156
+
157
+ kept_ops: dict[bytes, datetime] = dict()
158
+ kept_blobs: set[bytes] = set()
159
+ kept_blobs_size: int = 0
160
+ discarded_ops: set[bytes] = set()
161
+ discarded_blobs: set[bytes] = set()
162
+
163
+ for timestamp, op_key in self.__log.read_new_to_old():
164
+ blob_path = self.__get_path(op_key)
165
+ blob_size = blob_path.stat().st_size
166
+ blob_key = bytes.fromhex(blob_path.parent.name)
167
+
168
+ if blob_key not in kept_blobs and kept_blobs_size + blob_size <= size:
169
+ kept_blobs.add(blob_key)
170
+ kept_blobs_size += blob_size
171
+
172
+ if blob_key in kept_blobs:
173
+ if op_key not in kept_ops:
174
+ kept_ops[op_key] = timestamp
175
+ else:
176
+ discarded_ops.add(op_key)
177
+ discarded_blobs.add(blob_key)
178
+
179
+ for op_key in discarded_ops:
180
+ self.__discard_op(op_key)
181
+
182
+ for blob_key in discarded_blobs:
183
+ self.__discard_blob(blob_key)
184
+
185
+ new_log = Log(self.__log_path.with_suffix(".new"))
186
+ for op_key, timestamp in reversed(kept_ops.items()):
187
+ new_log.write(timestamp, op_key)
188
+
189
+ self.__log.replace_with(new_log)
190
+
191
+ def prune_to_age(self, timestamp: datetime):
192
+ """Remove all entries that have not been used after timestamp."""
193
+ # TODO, sometime...
194
+ raise NotImplementedError()
195
+
196
+ def get_info(self) -> DiskCacheInfo:
197
+
198
+ n_ops = 0
199
+ blob_keys: set[bytes] = set()
200
+ size_blobs = 0
201
+
202
+ for op_path in self.__scan_ops():
203
+ n_ops += 1
204
+ object_path = op_path / "object"
205
+ blob_key = bytes.fromhex(object_path.parent.name)
206
+
207
+ if blob_key in blob_keys:
208
+ continue
209
+
210
+ blob_keys.add(blob_key)
211
+ blob_path = object_path.resolve()
212
+ size_blobs += blob_path.stat().st_size
213
+
214
+ oldest_op = self.__log.oldest_entry()
215
+ newest_op = self.__log.newest_entry()
216
+
217
+ return DiskCacheInfo(
218
+ path=self.__root,
219
+ n_ops=n_ops,
220
+ n_blobs=len(blob_keys),
221
+ size_blobs=size_blobs,
222
+ oldest_op=oldest_op[0] if oldest_op else None,
223
+ newest_op=newest_op[0] if newest_op else None,
224
+ log_records=self.__log.record_count(),
225
+ )
226
+
227
+ def __get_op_path(self, op_key: bytes) -> Path:
228
+ key_parts = string_slices(op_key.hex(), 2, 2)
229
+ return self.__ops / key_parts[0] / key_parts[1] / op_key.hex()
230
+
231
+ def __get_blob_path(self, blob_key: bytes) -> Path:
232
+ key_parts = string_slices(blob_key.hex(), 2, 2)
233
+ return self.__blobs / key_parts[0] / key_parts[1] / blob_key.hex()
234
+
235
+ def __discard_op(self, op_key: bytes):
236
+ op_path = self.__get_op_path(op_key)
237
+ os.unlink(op_path / "object")
238
+ os.rmdir(op_path)
239
+
240
+ def __discard_blob(self, blob_key: bytes):
241
+ blob_path = self.__get_blob_path(blob_key)
242
+ shutil.rmtree(blob_path)
243
+
244
+ def __scan_ops(self) -> Generator[Path]:
245
+ for dir1 in os.scandir(self.__ops):
246
+ for dir2 in os.scandir(dir1):
247
+ for entry in os.scandir(dir2):
248
+ yield Path(entry.path)
@@ -1,5 +1,6 @@
1
1
  from pathlib import Path
2
2
  from typing import override
3
+
3
4
  from fab.model import Cache
4
5
 
5
6
 
@@ -1,7 +1,9 @@
1
1
  from pathlib import Path
2
2
  from typing import override
3
+
3
4
  from ..model import Cache
4
5
 
6
+
5
7
  class NullCache(Cache):
6
8
  @override
7
9
  async def has(self, op_key: bytes) -> bool:
@@ -68,10 +68,16 @@ async def test_prune_to_size():
68
68
  cache = DiskCache(root / "cache_dir")
69
69
  op1_key = bytes.fromhex("abcd01")
70
70
  op2_key = bytes.fromhex("abcd02")
71
- file1 = await cache.store_path(op1_key, create_file(root / "file1", "aaa"))
72
- file2 = await cache.store_path(op2_key, create_file(root / "file2", "bbb"))
73
-
74
- cache.prune_to_size(3)
71
+ file1 = await cache.store_path(
72
+ op1_key, create_file(root / "file1", "aa")
73
+ ) # 2 bytes
74
+ file2 = await cache.store_path(
75
+ op2_key, create_file(root / "file2", "bb")
76
+ ) # 2 bytes
77
+
78
+ assert cache.get_info().log_records == 2
79
+ cache.prune_to_size(3) # 3 bytes
80
+ assert cache.get_info().log_records == 1
75
81
 
76
82
  assert not await cache.has(op1_key)
77
83
  assert not file1.exists()
@@ -1,6 +1,7 @@
1
+ import random
1
2
  from datetime import datetime, timedelta
2
3
  from pathlib import Path
3
- import random
4
+
4
5
  from .diskcache import Log
5
6
 
6
7
  ONE_HOUR = timedelta(hours=1)
@@ -11,11 +12,8 @@ def random_key() -> bytes:
11
12
 
12
13
 
13
14
  def test_empty(tmp_path: Path):
14
- log1 = Log(tmp_path / "log.log")
15
- log1.flush()
16
-
17
- log2 = Log(tmp_path / "log.log")
18
- assert list(log2.read_entries()) == []
15
+ log = Log(tmp_path / "log.log")
16
+ assert list(log.read_new_to_old()) == []
19
17
 
20
18
 
21
19
  def test_read(tmp_path: Path):
@@ -32,11 +30,10 @@ def test_read(tmp_path: Path):
32
30
  log1.write(t0, k0)
33
31
  log1.write(t1, k1)
34
32
  log1.write(t2, k2)
35
- log1.flush()
36
33
 
37
34
  log2 = Log(tmp_path / "log.log")
38
35
 
39
- gen = log2.read_entries()
36
+ gen = log2.read_new_to_old()
40
37
  assert next(gen) == (t2, k2)
41
38
  assert next(gen) == (t1, k1)
42
39
  assert next(gen) == (t0, k0)
@@ -3,9 +3,9 @@
3
3
  Usage:
4
4
  fab [options] (do|run|ls|tree) <target> [--file FILE] [--no-cache] [--no-remote-cache]
5
5
  fab [options] list-targets [--file FILE]
6
- fab [options] clean-containers
7
- fab [options] clean-cache
8
- fab [options] cache-info
6
+ fab [options] containers clean
7
+ fab [options] cache info
8
+ fab [options] cache prune <size>
9
9
  fab --version
10
10
 
11
11
  Options:
@@ -19,30 +19,29 @@ Options:
19
19
 
20
20
  import asyncio
21
21
  import subprocess
22
+ import tempfile
23
+ from pathlib import Path
22
24
  from typing import Any, override
25
+
23
26
  from docopt import docopt
24
- from pathlib import Path
25
27
  from xdg_base_dirs import xdg_data_home
26
- import tempfile
27
28
 
28
29
  from fab.oci.container_admin import ContainerAdmin
29
30
  from fab.oci.podman import PodmanClient
30
31
 
31
-
32
- from .lang.data import List, Object, PathObj
33
- from .caching import DiskCache, CacheHitCounter
32
+ from .caching import CacheHitCounter, DiskCache
34
33
  from .executors.simple_executor import SimpleOperationExecutor
34
+ from .lang.data import List, Object, PathObj
35
35
  from .lang.evaluation_context import DefaultEvaluationContext
36
36
  from .lang.evaluator import EvaluationError, evaluate_context
37
37
  from .model import (
38
38
  Cache,
39
39
  CommandFailedError,
40
- OperationContext,
41
40
  OciContainer,
42
- OperationError,
43
- StreamType,
41
+ OperationContext,
44
42
  )
45
43
  from .model.executor import OperationContextFactory
44
+ from .util.bytesizestr import bytesize2str, str2bytesize
46
45
 
47
46
 
48
47
  class MainOperationContextFactory(OperationContextFactory):
@@ -168,7 +167,8 @@ async def cli():
168
167
  # disk_cache = HitCounter(DiskCache(...))
169
168
  # remote_cache = HitCounter(RemoteCache(...))
170
169
  # combi_cache = ChainedCache(disk_cache, remote_cache)
171
- cache = CacheHitCounter(DiskCache(data_home / "cache"))
170
+ disk_cache = DiskCache(data_home / "cache")
171
+ cache = CacheHitCounter(disk_cache)
172
172
 
173
173
  # ui = Tui(disk_cache_counter, remote_cache_counter)
174
174
 
@@ -197,6 +197,7 @@ async def cli():
197
197
  try:
198
198
  eval_result = evaluate_context(evaluation_context)
199
199
  result = await eval_result.get_attr(target)
200
+ print("Done")
200
201
  print(f"Cache stats: {cache.hits} hits, {cache.misses} misses")
201
202
  except EvaluationError as e:
202
203
  print(format_evaluation_error(e))
@@ -208,13 +209,19 @@ async def cli():
208
209
  return result
209
210
 
210
211
  async def do():
212
+ await evaluate_target()
213
+
214
+ async def ls():
211
215
  result = await evaluate_target()
212
- print_result(result)
216
+ if not isinstance(result, PathObj):
217
+ print(f"Target '{target}' does not evaluate to a Path")
218
+ return
219
+ print(result.path)
213
220
 
214
221
  async def run():
215
222
  result = await evaluate_target()
216
223
  if not result:
217
- return
224
+ return
218
225
  if not isinstance(result, PathObj):
219
226
  print(f"Target '{target}' does not evaluate to a Path")
220
227
  return
@@ -226,18 +233,38 @@ async def cli():
226
233
  for name in build_object.attrs():
227
234
  print(name)
228
235
 
229
- async def clean_containers():
236
+ async def containers_clean():
230
237
  count = await container_admin.clean_containers()
231
238
  print(f"Removed {count} container(s).")
232
239
 
240
+ def cache_info():
241
+ info = disk_cache.get_info()
242
+ print(f"root: {info.path}")
243
+ print(f"count: {info.n_ops} ops / {info.n_blobs} blobs")
244
+ print(f"size: {bytesize2str(info.size_blobs)}")
245
+ print(f"oldest: {info.oldest_op or 'n/a'}")
246
+ print(f"newest: {info.newest_op or 'n/a'}")
247
+ print(f"records: {info.log_records}")
248
+
249
+ def cache_prune():
250
+ size = str2bytesize(args["<size>"])
251
+ print(f"Pruning to {bytesize2str(size)}")
252
+ disk_cache.prune_to_size(size)
253
+
233
254
  if args["list-targets"]:
234
255
  list_targets()
235
- elif args["clean-containers"]:
236
- await clean_containers()
256
+ elif args["containers"] and args["clean"]:
257
+ await containers_clean()
237
258
  elif args["do"]:
238
259
  await do()
260
+ elif args["ls"]:
261
+ await ls()
239
262
  elif args["run"]:
240
263
  await run()
264
+ elif args["cache"] and args["info"]:
265
+ cache_info()
266
+ elif args["cache"] and args["prune"]:
267
+ cache_prune()
241
268
  else:
242
269
  print(args)
243
270
 
@@ -1,4 +1,5 @@
1
1
  from typing import Any, override
2
+
2
3
  from fab.model import Operation, OperationExecutor
3
4
  from fab.model.executor import OperationContextFactory
4
5
 
@@ -3,23 +3,24 @@ from pathlib import Path
3
3
  from typing import override
4
4
 
5
5
  from fab.lang.util import DictObject
6
- from ..model import OperationError
7
6
 
8
- from ..operations.gcc import GccCompile, GccLink, GccCollectCompileCommands
7
+ from ..model import OperationError
8
+ from ..operations import Extract, HttpArchive, HttpGet
9
9
  from ..operations.containerized_gcc import (
10
10
  GccCompile as ContainerizedGccCompile,
11
+ )
12
+ from ..operations.containerized_gcc import (
11
13
  GccLink as ContainerizedGccLink,
12
14
  )
13
-
14
- from ..operations import Extract, HttpArchive, HttpGet
15
+ from ..operations.gcc import GccCollectCompileCommands, GccCompile, GccLink
15
16
  from .data import (
16
17
  EvaluationContext,
17
18
  Function,
19
+ FunctionCallError,
18
20
  List,
19
21
  Object,
20
22
  PathObj,
21
23
  String,
22
- FunctionCallError,
23
24
  )
24
25
  from .evaluator import evaluate_context
25
26
 
@@ -5,7 +5,7 @@ from abc import ABC, abstractmethod
5
5
  from collections.abc import Mapping, Sequence
6
6
  from typing import Any, override
7
7
 
8
- from ..model import Operation, Error
8
+ from ..model import Error, Operation
9
9
 
10
10
 
11
11
  class Object(ABC):
@@ -1,23 +1,21 @@
1
- from collections.abc import Mapping
2
1
  import pathlib
2
+ from collections.abc import Mapping
3
3
  from typing import Any, override
4
4
 
5
- from ..lang.data import EvaluationContext, Object
6
-
7
- from ..model import Operation, OperationExecutor
8
-
9
5
  from ..lang.buildins import (
6
+ ContainerizedGcc,
7
+ ExtractFunc,
8
+ GccCollectCompileCommandsFunc,
10
9
  GccCompileFunc,
11
10
  GccLinkFunc,
12
- GccCollectCompileCommandsFunc,
11
+ HttpArchiveFunc,
12
+ HttpGetFunc,
13
+ LinkFunc,
13
14
  LoadFunc,
14
15
  PathFunc,
15
- LinkFunc,
16
- HttpGetFunc,
17
- ExtractFunc,
18
- HttpArchiveFunc,
19
- ContainerizedGcc,
20
16
  )
17
+ from ..lang.data import EvaluationContext, Object
18
+ from ..model import Operation, OperationExecutor
21
19
 
22
20
  buildins = {
23
21
  "load": LoadFunc(),
@@ -107,6 +107,7 @@ __parser = Lark.open_from_package(
107
107
  start="listing",
108
108
  parser="lalr",
109
109
  propagate_positions=True,
110
+ # _plugins=lark_rust.plugins
110
111
  )
111
112
 
112
113
 
@@ -1,6 +1,7 @@
1
1
  import pytest
2
2
 
3
3
  from fab.lang.astree import SourcePosition
4
+
4
5
  from .parser import parse
5
6
 
6
7
 
@@ -1,8 +1,9 @@
1
+ import pathlib
1
2
  from abc import ABC
2
3
  from collections.abc import Mapping, Sequence
3
- import pathlib
4
4
  from typing import override
5
- from .data import Object, Function, EvaluationContext
5
+
6
+ from .data import EvaluationContext, Function, Object
6
7
 
7
8
 
8
9
  class DictObject(Object):
@@ -1,9 +1,9 @@
1
1
  from .cache import Cache
2
- from .operation import Operation, OperationContext, OperationError
3
- from .executor import OperationExecutor
4
- from .oci import OciContainer, OciClient, OciProcess
5
- from .outputchunks import StreamType, OutputChunk
6
2
  from .errors import CommandFailedError, Error
3
+ from .executor import OperationExecutor
4
+ from .oci import OciClient, OciContainer, OciProcess
5
+ from .operation import Operation, OperationContext, OperationError
6
+ from .outputchunks import OutputChunk, StreamType
7
7
 
8
8
  __all__ = [
9
9
  "Cache",
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  from abc import ABC, abstractmethod
3
4
  from pathlib import Path
4
5
 
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
+
2
3
  import abc
3
4
  from collections.abc import AsyncIterator
4
5
  from dataclasses import dataclass
6
+
5
7
  from .outputchunks import OutputChunk
6
8
 
7
9
 
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  from abc import ABC, abstractmethod
3
4
  from pathlib import Path
4
5
  from typing import Any
@@ -1,6 +1,5 @@
1
- from dataclasses import dataclass
2
-
3
1
  import enum
2
+ from dataclasses import dataclass
4
3
 
5
4
 
6
5
  class StreamType(enum.Enum):
@@ -14,9 +14,7 @@ The multiplexed exec stream follows the Docker framing protocol:
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- import abc
18
17
  import asyncio
19
- import enum
20
18
  import json
21
19
  import os
22
20
  import struct
@@ -27,6 +25,7 @@ import aiohttp
27
25
 
28
26
  from fab.model import OutputChunk
29
27
 
28
+ from ..model import StreamType
30
29
  from ..model.oci import (
31
30
  ImageNotFoundError,
32
31
  OciClient,
@@ -37,7 +36,6 @@ from ..model.oci import (
37
36
  PullError,
38
37
  PullEvent,
39
38
  )
40
- from ..model import StreamType
41
39
 
42
40
  # ---------------------------------------------------------------------------
43
41
  # Helpers
@@ -3,6 +3,7 @@ from .extract import Extract
3
3
  # from .gcc import gcc_compile, gcc_link
4
4
  from .http_archive import HttpArchive
5
5
  from .http_get import HttpGet
6
+
6
7
  # from .local_path import local_path
7
8
 
8
9
  __all__ = [
@@ -4,8 +4,7 @@ from typing import override
4
4
 
5
5
  from blake3 import blake3
6
6
 
7
-
8
- from ..model import Operation, OperationContext, CommandFailedError
7
+ from ..model import CommandFailedError, Operation, OperationContext
9
8
  from ..util.flatten_list import flatten
10
9
  from ..util.hash_objects import hash_objects
11
10
  from .gcc import CompileCommandsCollector, CompileObject
@@ -125,7 +124,7 @@ class GccCompile(Operation):
125
124
  )
126
125
  )
127
126
 
128
- # First see if we can find the dependencies of this file. If this file has
127
+ # First see if we can find the dependencies of this file. If this file has
129
128
  # been compiled before its deps are stored under the following key.
130
129
  deps_key = hash_objects(
131
130
  blake3(),
@@ -139,9 +138,9 @@ class GccCompile(Operation):
139
138
  deps_cached = await context.cache_check(deps_key)
140
139
 
141
140
  if deps_cached:
142
- # Deps file is found. Next step is to see if we can reuse the previous build
141
+ # Deps file is found. Next step is to see if we can reuse the previous build
143
142
  # result, ea: none of the dependencies have changed.
144
- # We do that by calculating the key for this source file combined with
143
+ # We do that by calculating the key for this source file combined with
145
144
  # all the dependencies.
146
145
 
147
146
  deps_file = await context.cache_load_path(deps_key)
@@ -157,7 +156,7 @@ class GccCompile(Operation):
157
156
  )
158
157
 
159
158
  if await context.cache_check(key):
160
- # Dependencies have not changed! Lets load the build result from cache
159
+ # Dependencies have not changed! Lets load the build result from cache
161
160
  # and return
162
161
  return await context.cache_load_path(key)
163
162
 
@@ -1,3 +1,4 @@
1
+ import shutil
1
2
  from pathlib import Path
2
3
  from typing import Any, override
3
4
 
@@ -6,8 +7,6 @@ from blake3 import blake3
6
7
  from ..model import Operation, OperationContext
7
8
  from ..util.hash_objects import hash_objects
8
9
 
9
- import shutil
10
-
11
10
 
12
11
  class Extract(Operation):
13
12
  def __init__(self, archive: Path) -> None:
@@ -1,16 +1,13 @@
1
1
  import asyncio
2
2
  import json
3
3
  from collections.abc import Sequence
4
- from dataclasses import dataclass, asdict
4
+ from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
  from typing import Any, override
7
7
 
8
8
  from blake3 import blake3
9
9
 
10
- from fab.lang import data
11
-
12
-
13
- from ..model import Operation, OperationContext
10
+ from ..model import Operation, OperationContext
14
11
  from ..util.hash_objects import hash_objects
15
12
 
16
13
 
@@ -9,9 +9,11 @@ from .containerized_gcc import GccLink, _parse_make_deps
9
9
 
10
10
 
11
11
  def test_parse_make_deps():
12
- assert list(_parse_make_deps(
13
- "main.o: main.cpp lib1.h lib2.h \\\n lib3.h lib4.h \\\n lib5.h path\\ with\\ spaces.h"
14
- )) == [
12
+ assert list(
13
+ _parse_make_deps(
14
+ "main.o: main.cpp lib1.h lib2.h \\\n lib3.h lib4.h \\\n lib5.h path\\ with\\ spaces.h"
15
+ )
16
+ ) == [
15
17
  Path("lib1.h"),
16
18
  Path("lib2.h"),
17
19
  Path("lib3.h"),
@@ -21,7 +23,6 @@ def test_parse_make_deps():
21
23
  ]
22
24
 
23
25
 
24
-
25
26
  async def _empty_chunks() -> AsyncIterator[OutputChunk]:
26
27
  for _ in ():
27
28
  yield OutputChunk(StreamType.STDOUT, b"")
@@ -126,9 +127,7 @@ async def test_link_options_appended(tmp_path: Path):
126
127
  context = RecordingContext(tmp_path)
127
128
  obj = tmp_path / "obj.o"
128
129
  obj.write_bytes(b"content")
129
- op = GccLink(
130
- "img", None, "out", [obj], options=["-lm", "-Wl,--as-needed"]
131
- )
130
+ op = GccLink("img", None, "out", [obj], options=["-lm", "-Wl,--as-needed"])
132
131
  await op.execute(context)
133
132
  assert context.container is not None
134
133
  cmd = context.container.cmd
@@ -0,0 +1,42 @@
1
+ import re
2
+
3
+ __steps = [
4
+ (1024**0, "B"),
5
+ (1024**1, "KiB"),
6
+ (1024**2, "MiB"),
7
+ (1024**3, "GiB"),
8
+ (1024**4, "TiB"),
9
+ (1024**5, "PiB"),
10
+ ]
11
+
12
+
13
+ def bytesize2str(size: int) -> str:
14
+ for step_size, suffix in __steps:
15
+ limit = step_size * 1024
16
+ if size < limit:
17
+ return f"{size / step_size:.1f} {suffix}"
18
+ return "huge!"
19
+
20
+
21
+ __suffixes = {
22
+ "": 1,
23
+ "K": 1024**1,
24
+ "M": 1024**2,
25
+ "G": 1024**3,
26
+ "T": 1024**4,
27
+ "P": 1024**5,
28
+ }
29
+
30
+
31
+ def str2bytesize(size: str) -> int:
32
+ match = re.match(r"(\d+(?:\.\d+)?)(\w?)", size)
33
+ if not match:
34
+ raise ValueError(f"Could not convert '{size}' to bytes")
35
+
36
+ value = float(match.group(1))
37
+ suffix = match.group(2).upper()
38
+
39
+ try:
40
+ return int(value * __suffixes[suffix])
41
+ except KeyError:
42
+ raise ValueError(f"Unknown suffix '{suffix}'")
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  from collections.abc import Iterable, Sequence
3
4
  from pathlib import Path
4
5
 
@@ -11,8 +11,8 @@ def string_slices(inp: str, *slices: int) -> Sequence[str]:
11
11
  for size in slices:
12
12
  if current_pos + size > len(inp):
13
13
  raise ValueError("Slice sizes exceed input string length.")
14
-
15
- result.append(inp[current_pos:current_pos + size])
14
+
15
+ result.append(inp[current_pos : current_pos + size])
16
16
  current_pos += size
17
17
 
18
18
  return result
@@ -0,0 +1,5 @@
1
+ from .bytesizestr import str2bytesize
2
+
3
+
4
+ def test_str2bytesize():
5
+ assert str2bytesize("0") == 0
@@ -1,13 +1,17 @@
1
- from .temp_file_structure import TempFileStructure
2
- from .hash_path import hash_path
3
- from pathlib import Path
4
1
  import hashlib
2
+ from pathlib import Path
3
+
5
4
  import pytest
6
5
 
6
+ from .hash_path import hash_path
7
+ from .temp_file_structure import TempFileStructure
8
+
9
+
7
10
  def path_key(path: Path) -> str:
8
11
  hashfunc = hashlib.sha256()
9
12
  return hash_path(hashfunc, path).hex()
10
13
 
14
+
11
15
  def test_path():
12
16
  with TempFileStructure(
13
17
  [
@@ -1,6 +1,7 @@
1
- from .string_slices import string_slices
2
1
  import pytest
3
2
 
3
+ from .string_slices import string_slices
4
+
4
5
 
5
6
  def test_string_slices():
6
7
  assert string_slices("abcdefg", 2, 2) == ["ab", "cd"]
@@ -1,165 +0,0 @@
1
- from collections.abc import Generator
2
- from datetime import datetime
3
- import os
4
- from typing import override
5
- from pathlib import Path
6
-
7
- from ..model import Cache
8
-
9
- from ..util.string_slices import string_slices
10
- from ..util.hash_path import hash_path
11
-
12
- import blake3
13
-
14
-
15
- class DiskCacheError(RuntimeError):
16
- pass
17
-
18
-
19
- class Log:
20
- def __init__(self, path: Path) -> None:
21
- self.__path = path
22
- self.__path.parent.mkdir(parents=True, exist_ok=True)
23
- self.__writer = open(self.__path, "a")
24
-
25
- def __del__(self):
26
- self.flush()
27
-
28
- def write(self, timestamp: datetime, key: bytes):
29
- time_str = timestamp.isoformat()
30
- self.__writer.write(f"{time_str} {key.hex()}\n")
31
-
32
- def flush(self):
33
- self.__writer.flush()
34
-
35
- def clear(self):
36
- self.clear()
37
- # TODO
38
- pass
39
-
40
- def read_entries(self) -> Generator[tuple[datetime, bytes]]:
41
- """Read entries from log, most recent first."""
42
-
43
- self.flush()
44
-
45
- # NOTE: This approach is very inefficient for large files, will need refactoring at some point!
46
- for line in reversed(self.__path.read_text().splitlines()):
47
- parts = line.split(" ", 1)
48
- timestamp = datetime.fromisoformat(parts[0])
49
- key = bytes.fromhex(parts[1])
50
- yield timestamp, key
51
-
52
-
53
- class DiskCache(Cache):
54
- def __init__(self, root: Path) -> None:
55
-
56
- self.__ops = root / "v1/ops"
57
- self.__ops.mkdir(parents=True, exist_ok=True)
58
-
59
- self.__blobs = root / "v1/blobs"
60
- self.__blobs.mkdir(parents=True, exist_ok=True)
61
-
62
- self.__log = Log(root / "v1/hits")
63
-
64
- def __get_op_path(self, op_key: bytes) -> Path:
65
- key_parts = string_slices(op_key.hex(), 2, 2)
66
- return self.__ops / key_parts[0] / key_parts[1] / op_key.hex()
67
-
68
- def __get_blob_path(self, blob_key: bytes) -> Path:
69
- key_parts = string_slices(blob_key.hex(), 2, 2)
70
- return self.__blobs / key_parts[0] / key_parts[1] / blob_key.hex()
71
-
72
- def prune_to_size(self, size: int):
73
- """Remove longest unused entries until the combined size of the
74
- remaining entries is <= size."""
75
-
76
- blobs_to_keep: set[bytes] = set()
77
- kept_blobs_size: int = 0
78
-
79
- for _, key in self.__log.read_entries():
80
- path = self.__get_path(key)
81
- blob_size = path.stat().st_size
82
-
83
- if kept_blobs_size + blob_size > size:
84
- break
85
-
86
- blobs_to_keep.add(key)
87
- kept_blobs_size += blob_size
88
-
89
- self.__prune(blobs_to_keep)
90
-
91
- def prune_to_age(self, timestamp: datetime):
92
- """Remove all entries that have not been used after timestamp."""
93
- pass
94
- # TODO
95
-
96
- def __prune(self, blobs_to_keep: set[bytes]):
97
-
98
- for op_path in self.__scan_ops():
99
- object_path = op_path / "object"
100
- blob_key = bytes.fromhex(object_path.parent.name)
101
-
102
- if blob_key in blobs_to_keep:
103
- continue
104
-
105
- blob_path = object_path.resolve().parent
106
- object_path.resolve().unlink()
107
- object_path.unlink()
108
- op_path.rmdir()
109
- blob_path.rmdir()
110
-
111
- def __scan_ops(self) -> Generator[Path]:
112
- for dir1 in os.scandir(self.__ops):
113
- for dir2 in os.scandir(dir1):
114
- for entry in os.scandir(dir2):
115
- yield Path(entry.path)
116
-
117
- @override
118
- async def has(self, op_key: bytes) -> bool:
119
- op_path = self.__get_op_path(op_key)
120
- result = op_path.exists()
121
- if result:
122
- self.__log.write(datetime.now(), op_key)
123
- return result
124
-
125
- @override
126
- async def get_path(self, op_key: bytes) -> Path:
127
- return self.__get_path(op_key)
128
-
129
- def __get_path(self, op_key: bytes) -> Path:
130
- op_path = self.__get_op_path(op_key)
131
- if not op_path.is_dir():
132
- raise DiskCacheError(f"{op_path} does not exist")
133
-
134
- object_link = op_path / "object"
135
- if not object_link.is_symlink():
136
- raise DiskCacheError(f"Corrupt cache, {object_link} is not a symlink")
137
-
138
- blob_path = object_link.resolve()
139
-
140
- return blob_path
141
-
142
- @override
143
- async def store_path(self, op_key: bytes, path: Path) -> Path:
144
- if not path.exists():
145
- raise DiskCacheError(f"Cannot store non-existent path {path} in cache")
146
-
147
- blob_key = hash_path(blake3.blake3(), path)
148
-
149
- blob_path = self.__get_blob_path(blob_key)
150
- blob_path.mkdir(parents=True, exist_ok=True)
151
- obj_path = blob_path / path.name
152
- if not obj_path.exists():
153
- path.rename(obj_path)
154
-
155
- op_path = self.__get_op_path(op_key)
156
- if op_path.exists():
157
- raise DiskCacheError(
158
- f"Operation with key '{op_key.hex()}' already exists in cache"
159
- )
160
-
161
- op_path.mkdir(parents=True, exist_ok=True)
162
- (op_path / "object").symlink_to(obj_path.relative_to(op_path, walk_up=True))
163
-
164
- self.__log.write(datetime.now(), op_key)
165
- return obj_path
@@ -1,6 +1,6 @@
1
- from .memory import MemoryCache
2
- from .nullcache import NullCache
3
1
  from .diskcache import DiskCache
4
2
  from .hit_counter import CacheHitCounter
3
+ from .memory import MemoryCache
4
+ from .nullcache import NullCache
5
5
 
6
6
  __all__ = ["MemoryCache", "NullCache", "DiskCache", "CacheHitCounter"]
@@ -1,9 +1,9 @@
1
1
  from pathlib import Path
2
2
  from typing import override
3
3
 
4
-
5
4
  from ..model import Cache
6
5
 
6
+
7
7
  class MemoryCache(Cache):
8
8
  def __init__(self):
9
9
  self.__cached_paths: dict[bytes, Path] = {}
@@ -2,9 +2,9 @@ from collections.abc import Mapping, Sequence
2
2
  from typing import override
3
3
 
4
4
  from ..lang import astree
5
+ from ..model import Error
5
6
  from .data import EvaluationContext, Function, List, Object, String
6
7
  from .parser import parse
7
- from ..model import Error
8
8
 
9
9
 
10
10
  class EvaluationError(RuntimeError):
@@ -1,5 +1,5 @@
1
- from pathlib import Path
2
1
  from collections.abc import Iterable
2
+ from pathlib import Path
3
3
 
4
4
 
5
5
  def chunkify_path(base_path: Path) -> Iterable[bytes]: