boatyard 0.6.0.dev170__py3-none-any.whl

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 (42) hide show
  1. boatyard/__init__.py +0 -0
  2. boatyard/build.py +267 -0
  3. boatyard/cli.py +23 -0
  4. boatyard/compose.py +24 -0
  5. boatyard/config/__init__.py +0 -0
  6. boatyard/config/config.py +787 -0
  7. boatyard/config/lock.py +152 -0
  8. boatyard/config/values.py +188 -0
  9. boatyard/crane.py +23 -0
  10. boatyard/dockerfiles.py +201 -0
  11. boatyard/lock.py +233 -0
  12. boatyard/markers.py +201 -0
  13. boatyard/paths.py +5 -0
  14. boatyard/publish.py +72 -0
  15. boatyard/py.typed +0 -0
  16. boatyard/score.py +267 -0
  17. boatyard/score_compose.py +274 -0
  18. boatyard/score_k8s.py +152 -0
  19. boatyard/score_patch_templates/gpu-cuda.tpl +24 -0
  20. boatyard/score_patch_templates/gpu-pod.tpl +43 -0
  21. boatyard/score_patch_templates/gpu-rocm.tpl +30 -0
  22. boatyard/score_patch_templates/probe-adapter-healthchecks.tpl +55 -0
  23. boatyard/score_patch_templates/restart.tpl +21 -0
  24. boatyard/score_patch_templates/scale-to-zero.tpl +30 -0
  25. boatyard/temp.py +5 -0
  26. boatyard/verbs/__init__.py +0 -0
  27. boatyard/verbs/build.py +54 -0
  28. boatyard/verbs/down.py +39 -0
  29. boatyard/verbs/lock.py +34 -0
  30. boatyard/verbs/matrix.py +57 -0
  31. boatyard/verbs/mirror.py +50 -0
  32. boatyard/verbs/publish.py +138 -0
  33. boatyard/verbs/score.py +161 -0
  34. boatyard/verbs/tree_tags.py +35 -0
  35. boatyard/verbs/up.py +265 -0
  36. boatyard/yaml.py +34 -0
  37. boatyard-0.6.0.dev170.dist-info/METADATA +12 -0
  38. boatyard-0.6.0.dev170.dist-info/RECORD +42 -0
  39. boatyard-0.6.0.dev170.dist-info/WHEEL +4 -0
  40. boatyard-0.6.0.dev170.dist-info/entry_points.txt +2 -0
  41. boatyard-0.6.0.dev170.dist-info/licenses/LICENSE +201 -0
  42. boatyard-0.6.0.dev170.dist-info/licenses/NOTICE +1 -0
boatyard/lock.py ADDED
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ import shlex
7
+
8
+ import yaml
9
+ from bashrun.bash import bash_output
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from boatyard.config.config import (
13
+ OCI_REFERENCE_PREFIX,
14
+ ConfigFile,
15
+ Yaml,
16
+ bundle_lock_key,
17
+ chart_lock_key,
18
+ is_list,
19
+ is_mapping,
20
+ mirrored_ref,
21
+ scan_bundle_texts,
22
+ )
23
+ from boatyard.crane import install_crane
24
+ from boatyard.config.lock import (
25
+ LOCK_PATH,
26
+ LockFile,
27
+ ConfigLockBundle,
28
+ ConfigLockEntry,
29
+ )
30
+ from boatyard.temp import SHA256_DIGEST_REGEX
31
+ from boatyard.yaml import load_yaml_model
32
+
33
+ DIGEST_TAIL_PATTERN = re.compile(rf"@{SHA256_DIGEST_REGEX}$")
34
+ COMPOSE_FILE_MEDIA_TYPE = "application/vnd.docker.compose.file+yaml"
35
+
36
+
37
+ # Model the OCI shapes crane answers with: index children and artifact layers.
38
+ class OciPlatform(BaseModel):
39
+ model_config = ConfigDict(extra="ignore")
40
+
41
+ os: str | None = None
42
+ architecture: str | None = None
43
+
44
+
45
+ class OciDescriptor(BaseModel):
46
+ model_config = ConfigDict(extra="ignore")
47
+
48
+ digest: str
49
+ media_type: str | None = Field(default=None, alias="mediaType")
50
+ data: str | None = None
51
+ platform: OciPlatform | None = None
52
+ annotations: dict[str, str] = Field(default_factory=dict)
53
+
54
+
55
+ class OciIndex(BaseModel):
56
+ model_config = ConfigDict(extra="ignore")
57
+
58
+ manifests: list[OciDescriptor] = Field(default_factory=list)
59
+
60
+
61
+ class OciArtifactManifest(BaseModel):
62
+ model_config = ConfigDict(extra="ignore")
63
+
64
+ layers: list[OciDescriptor] = Field(default_factory=list)
65
+
66
+ @classmethod
67
+ def fetch_compose_texts(cls, pinned: str) -> list[str]:
68
+ # Read the bundle's compose files as inline base64 layer data, with crane blob as the fallback.
69
+ print(f"Reading bundle content for: {pinned}")
70
+ artifact = cls.model_validate(json.loads(bash_output(f"crane manifest {shlex.quote(pinned)}")))
71
+
72
+ # Strip the tag, treating a colon as a tag only when no slash follows it.
73
+ repository = pinned.rpartition("@")[0]
74
+ slash = repository.rfind("/")
75
+ colon = repository.rfind(":")
76
+ repository = repository[:colon] if colon > slash else repository
77
+ compose_texts = [
78
+ base64.b64decode(layer.data).decode("utf-8")
79
+ if layer.data is not None
80
+ else bash_output(f"crane blob {shlex.quote(repository + '@' + layer.digest)}")
81
+ for layer in artifact.layers
82
+ if layer.media_type == COMPOSE_FILE_MEDIA_TYPE
83
+ ]
84
+
85
+ # Refuse a bundle that carries no compose file layer.
86
+ if not compose_texts:
87
+ raise ValueError(f"{pinned}: the compose bundle carries no compose file layer")
88
+ return compose_texts
89
+
90
+
91
+ def relock(config: ConfigFile, *, upgrade: bool = False, check: bool = False) -> LockFile:
92
+ # Install the pinned crane and derive the mirror prefix.
93
+ install_crane()
94
+ prefix = config.mirror_prefix
95
+ # Read the committed lock schema-only; drift is relock's input, never refused.
96
+ committed_path = config.root / LOCK_PATH
97
+ committed = load_yaml_model(LockFile, committed_path) if committed_path.is_file() else LockFile()
98
+
99
+ # Resolve first-party bundles direct, recomputing requires from the pinned content and validating declared references against it.
100
+ bundles: dict[str, ConfigLockBundle] = {}
101
+ for name, reference, entry in config.remote_declared_entries():
102
+ key = bundle_lock_key(name)
103
+ committed_entry = committed.bundles.get(key)
104
+
105
+ # Pin the frozen reference or resolve the declaration's fresh digest.
106
+ pinned = (
107
+ committed_entry.reference
108
+ if committed_entry is not None and not upgrade and committed_entry.reference.startswith(reference + "@")
109
+ else f"{reference}@{resolve_remote_digest(reference)}"
110
+ )
111
+
112
+ # Record the pinned reference with its required slots.
113
+ bundles[key] = ConfigLockBundle(
114
+ reference=pinned,
115
+ requires=scan_bundle_texts(
116
+ name, entry.routes, entry.ports, OciArtifactManifest.fetch_compose_texts(pinned)
117
+ ).requires,
118
+ )
119
+
120
+ images: dict[str, ConfigLockEntry] = {}
121
+ for name, reference in config.base_images.items():
122
+ base = mirrored_ref(reference, prefix)
123
+ committed_entry = committed.images.get(name)
124
+
125
+ # Record a digest-pinned declaration as-is under the mirror prefix.
126
+ if DIGEST_TAIL_PATTERN.search(reference):
127
+ images[name] = ConfigLockEntry(index=base, platforms=dict.fromkeys(config.platforms, base))
128
+ continue
129
+
130
+ # Freeze the committed entry forward while its pinned tag and platform set hold.
131
+ if (
132
+ committed_entry is not None
133
+ and not upgrade
134
+ and committed_entry.index.startswith(base + "@")
135
+ and set(committed_entry.platforms) == set(config.platforms)
136
+ ):
137
+ images[name] = committed_entry
138
+ continue
139
+
140
+ # Resolve the reference into an index digest and its child manifests.
141
+ print(f"Resolving digests for: {reference}")
142
+ index_digest = resolve_remote_digest(reference)
143
+ children = OciIndex.model_validate(
144
+ json.loads(bash_output(f"crane manifest {shlex.quote(reference)}"))
145
+ ).manifests
146
+
147
+ if not children:
148
+ # Treat a manifest answer as every platform's sole manifest.
149
+ matches = {platform: [OciDescriptor(digest=index_digest)] for platform in config.platforms}
150
+ else:
151
+ # Match each declared platform against its concrete, non-attestation children.
152
+ matches = {
153
+ platform: [
154
+ child
155
+ for child in children
156
+ if child.annotations.get("vnd.docker.reference.type") != "attestation-manifest"
157
+ if child.platform is not None and f"{child.platform.os}/{child.platform.architecture}" == platform
158
+ ]
159
+ for platform in config.platforms
160
+ }
161
+
162
+ # Refuse any platform without exactly one match and record the survivor's digest.
163
+ selected: dict[str, str] = {}
164
+ for platform in config.platforms:
165
+ if len(matches[platform]) != 1:
166
+ raise ValueError(
167
+ f"base_images entry {name}: platform {platform} "
168
+ f"matches {len(matches[platform])} upstream manifests, expected exactly one"
169
+ )
170
+ child = matches[platform][0]
171
+ if not child.digest.startswith("sha256:"):
172
+ raise ValueError(f"base_images entry {name}: upstream index child must carry a digest ({child!r})")
173
+ selected[platform] = child.digest
174
+
175
+ # Record the mirror-prefixed index and one reference per platform.
176
+ images[name] = ConfigLockEntry(
177
+ index=f"{base}@{index_digest}",
178
+ platforms={platform: f"{base}@{digest}" for platform, digest in selected.items()},
179
+ )
180
+
181
+ # Freeze unchanged charts and resolve fresh tags to mirror-pinned digests.
182
+ charts: dict[str, str] = {}
183
+ for name, reference in config.declared_charts.items():
184
+ key = chart_lock_key(name)
185
+ reference = reference.removeprefix(OCI_REFERENCE_PREFIX)
186
+ base = mirrored_ref(reference, prefix)
187
+ committed_entry = committed.charts.get(key)
188
+
189
+ # Record a digest-pinned declaration as-is under the mirror prefix.
190
+ if DIGEST_TAIL_PATTERN.search(reference):
191
+ charts[key] = base
192
+ continue
193
+
194
+ # Freeze the committed entry forward while its pinned tag holds.
195
+ if committed_entry is not None and not upgrade and committed_entry.startswith(base + "@"):
196
+ charts[key] = committed_entry
197
+ continue
198
+
199
+ charts[key] = f"{base}@{resolve_remote_digest(reference)}"
200
+
201
+ # Assemble the lock and render its canonical form: sections in field order, nested keys sorted.
202
+ lock = LockFile(platforms=list(config.platforms), images=images, charts=charts, bundles=bundles)
203
+ document = {key: _canonical(value) for key, value in lock.model_dump(exclude_defaults=True).items()}
204
+ rendered = (
205
+ yaml.safe_dump(document, sort_keys=False, default_flow_style=False, width=10_000_000) if document else "{}\n"
206
+ )
207
+ # Write the rendered lock over the committed file.
208
+ if not check:
209
+ committed_path.write_text(rendered, encoding="utf-8")
210
+ return lock
211
+ # Refuse staleness when the committed lock is not the computed canonical bytes.
212
+ committed_text = committed_path.read_text(encoding="utf-8") if committed_path.is_file() else ""
213
+ if committed_text != rendered:
214
+ raise SystemExit("image lock is stale. Run 'boatyard lock' and commit the result.")
215
+ return lock
216
+
217
+
218
+ def resolve_remote_digest(reference: str) -> str:
219
+ # Refuse crane digest output that is not one sha256 digest.
220
+ print(f"Resolving digest for: {reference}")
221
+ output = bash_output(f"crane digest {shlex.quote(reference)}").strip()
222
+ if re.fullmatch(SHA256_DIGEST_REGEX, output) is None:
223
+ raise ValueError(f"crane digest output for {reference} must be one sha256 digest")
224
+ return output
225
+
226
+
227
+ def _canonical(value: Yaml) -> Yaml:
228
+ # Return the value with every nested dict's keys sorted.
229
+ if is_mapping(value):
230
+ return {key: _canonical(item) for key, item in sorted(value.items())}
231
+ if is_list(value):
232
+ return [_canonical(item) for item in value]
233
+ return value
boatyard/markers.py ADDED
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Literal, NamedTuple
6
+
7
+ import yaml
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from boatyard.config.config import Yaml, is_mapping
11
+
12
+ TEXT_MARKER = "include"
13
+ YAML_MARKER = "include-yaml"
14
+ LOCK_MARKER = "lock"
15
+ LOCK_IMAGE_MARKER = "lock-image"
16
+ LOCK_MARKERS = (LOCK_MARKER, LOCK_IMAGE_MARKER)
17
+ IMAGE_FIELD = "image"
18
+ IMAGE_SLOT_PATTERN = re.compile(r"\$\{|{{")
19
+
20
+
21
+ class LockImageSpec(BaseModel):
22
+ model_config = ConfigDict(extra="forbid")
23
+
24
+ key: str
25
+ digest_field: Literal["digest", "sha"] = "digest"
26
+
27
+
28
+ class MarkerContext(NamedTuple):
29
+ root: Path
30
+ images: dict[str, str]
31
+ source: str
32
+ ancestors: tuple[Path, ...] = ()
33
+
34
+
35
+ class ImageReference(NamedTuple):
36
+ registry: str
37
+ repository: str
38
+ tag: str | None
39
+ digest: str
40
+
41
+
42
+ def resolve_markers(node: Yaml, context: MarkerContext) -> Yaml:
43
+ # Expand includes before locks because spliced content may carry lock markers
44
+ return resolve_locks(expand_includes(node, context), context)
45
+
46
+
47
+ def expand_includes(node: Yaml, context: MarkerContext) -> Yaml:
48
+ match node:
49
+ case list():
50
+ # Resolve every list element
51
+ expanded = [expand_includes(item, context) for item in node]
52
+ case dict():
53
+ match tuple(node.items()):
54
+ # Splice in the included bytes
55
+ case [(marker, value)] if marker == TEXT_MARKER:
56
+ expanded = include_text(value, context)
57
+ # Splice in the included structure
58
+ case [(marker, value)] if marker == YAML_MARKER:
59
+ expanded = include_yaml(value, context)
60
+ # Leave lock markers whole for the lock pass to consume or refuse
61
+ case [(marker, _)] if marker in LOCK_MARKERS:
62
+ expanded = node
63
+ # Resolve plain mappings child by child
64
+ case entries:
65
+ expanded = {key: expand_includes(child, context) for key, child in entries}
66
+ case _:
67
+ # Pass scalars through untouched
68
+ expanded = node
69
+
70
+ return expanded
71
+
72
+
73
+ def include_text(value: Yaml, context: MarkerContext) -> str:
74
+ path = included_path(value, TEXT_MARKER, context)
75
+ return path.read_text(encoding="utf-8")
76
+
77
+
78
+ def include_yaml(value: Yaml, context: MarkerContext) -> Yaml:
79
+ path = included_path(value, YAML_MARKER, context)
80
+
81
+ try:
82
+ parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
83
+ except yaml.YAMLError as error:
84
+ raise ValueError(f"{context.source}: include {value!r} is not valid YAML: {error}") from error
85
+
86
+ if parsed is None:
87
+ raise ValueError(f"{context.source}: include {value!r} is empty")
88
+
89
+ return expand_includes(parsed, context._replace(ancestors=(*context.ancestors, path)))
90
+
91
+
92
+ def included_path(value: Yaml, marker: str, context: MarkerContext) -> Path:
93
+ source = context.source
94
+
95
+ match value:
96
+ case str():
97
+ relative = Path(value)
98
+ case _:
99
+ raise ValueError(f"{source}: {marker} takes a repo-root-relative path string, not {type(value).__name__}")
100
+
101
+ # Anchor include paths at the repo root
102
+ if relative.is_absolute() or ".." in relative.parts:
103
+ raise ValueError(f"{source}: include path {value!r} must anchor at the repo root")
104
+
105
+ path = context.root / relative
106
+
107
+ # Refuse cycles and missing targets
108
+ if path in context.ancestors:
109
+ raise ValueError(f"{source}: include {value!r} participates in a cycle")
110
+ if not path.is_file():
111
+ raise ValueError(f"{source}: include {value!r} does not exist at the repo root")
112
+
113
+ return path
114
+
115
+
116
+ def resolve_locks(node: Yaml, context: MarkerContext) -> Yaml:
117
+ match node:
118
+ case list():
119
+ # Resolve every list element
120
+ resolved = [resolve_locks(item, context) for item in node]
121
+ case dict():
122
+ match tuple(node.items()):
123
+ # Substitute the full locked reference as one string
124
+ case [(marker, value)] if marker == LOCK_MARKER:
125
+ resolved = locked_reference(value, context)
126
+ # Split the locked reference into chart fields
127
+ case [(marker, value)] if marker == LOCK_IMAGE_MARKER:
128
+ resolved = lock_image_fields(value, context.images, context.source)
129
+ # Refuse literal images, then resolve plain mappings child by child
130
+ case entries:
131
+ _check_literal_image(node, context.source)
132
+ resolved = {key: resolve_locks(child, context) for key, child in entries}
133
+ case _:
134
+ # Pass scalars through untouched
135
+ resolved = node
136
+
137
+ return resolved
138
+
139
+
140
+ def locked_reference(value: Yaml, context: MarkerContext) -> str:
141
+ match value:
142
+ case str() if value in context.images:
143
+ reference = context.images[value]
144
+ case str():
145
+ raise ValueError(f"{context.source}: lock entry {value!r} does not exist in the image lock")
146
+ case _:
147
+ raise ValueError(f"{context.source}: lock takes a lock-entry name string, not {type(value).__name__}")
148
+
149
+ return reference
150
+
151
+
152
+ def lock_image_fields(value: Yaml, images: dict[str, str], source: str) -> dict[str, Yaml]:
153
+ # Accept a bare lock-entry name or a key/digest_field mapping
154
+ if isinstance(value, str):
155
+ image_spec = LockImageSpec(key=value)
156
+ elif is_mapping(value):
157
+ image_spec = LockImageSpec.model_validate(value)
158
+ else:
159
+ raise ValueError(
160
+ f"{source}: lock-image takes a lock-entry name or a key/digest_field mapping, not {type(value).__name__}"
161
+ )
162
+
163
+ if image_spec.key not in images:
164
+ raise ValueError(f"{source}: lock entry {image_spec.key!r} does not exist in the image lock")
165
+
166
+ reference = split_image_reference(images[image_spec.key], source)
167
+
168
+ # Assemble the chart fields in chart-convention key order under the digest spelling
169
+ fields: dict[str, Yaml] = {"registry": reference.registry, "repository": reference.repository}
170
+ if reference.tag is not None:
171
+ fields["tag"] = reference.tag
172
+
173
+ digest = reference.digest
174
+ fields[image_spec.digest_field] = digest.removeprefix("sha256:") if image_spec.digest_field == "sha" else digest
175
+
176
+ return fields
177
+
178
+
179
+ def _check_literal_image(node: dict[str, Yaml], source: str) -> None:
180
+ image = node.get(IMAGE_FIELD)
181
+ if isinstance(image, str) and IMAGE_SLOT_PATTERN.search(image) is None:
182
+ raise ValueError(f"{source}: image {image!r} must consume the lock (lock: NAME / lock-image: NAME)")
183
+
184
+
185
+ def split_image_reference(reference: str, source: str) -> ImageReference:
186
+ # Peel the trailing digest
187
+ path_part, _, digest = reference.rpartition("@")
188
+
189
+ # Treat a colon as a tag only when no slash follows so registry ports stay whole
190
+ tagged = path_part.rpartition(":")
191
+ tag: str | None = None
192
+ registry_part = path_part
193
+ if tagged[1] and "/" not in tagged[2]:
194
+ registry_part, tag = tagged[0], tagged[2]
195
+
196
+ # Split the registry from the repository at the last slash
197
+ registry, separator, repository = registry_part.rpartition("/")
198
+ if not separator or not registry or not repository:
199
+ raise ValueError(f"{source}: lock reference {reference!r} has no registry/repository split")
200
+
201
+ return ImageReference(registry, repository, tag, digest)
boatyard/paths.py ADDED
@@ -0,0 +1,5 @@
1
+ from pathlib import Path
2
+
3
+ DEFAULT_CONFIG_PATH = Path("boatyard.yml")
4
+ VALUES_FILE = Path("boatyard.values.yaml")
5
+ LOCK_PATH = Path("boatyard.lock.yml")
boatyard/publish.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from functools import partial
5
+ from pathlib import Path
6
+ from typing import NamedTuple
7
+
8
+ import yaml
9
+
10
+ from boatyard.config.config import Yaml, double_dollars
11
+ from boatyard.score_compose import ComposeDocument
12
+ from boatyard.lock import resolve_remote_digest
13
+
14
+ VAR_PATTERN = re.compile(r"\$\{([A-Z][A-Z0-9_]*)(:[?\-+][^}]*)?\}")
15
+ STUB_VALUE = "publish-stub"
16
+
17
+
18
+ class PublishedEntry(NamedTuple):
19
+ artifact: str
20
+ sha_reference: str
21
+ branch_reference: str
22
+ digest: str
23
+
24
+
25
+ def bake_sources(baked_directory: Path, source_name: str, substitutions: dict[str, str]) -> Path:
26
+ print("Bake compose YAML")
27
+ source_path = Path(source_name)
28
+ baked_text = VAR_PATTERN.sub(
29
+ lambda match: substitutions.get(match.group(1), match.group(0)),
30
+ source_path.read_text(encoding="utf-8"),
31
+ )
32
+ baked_path = baked_directory / source_path
33
+ baked_path.parent.mkdir(parents=True, exist_ok=True)
34
+ baked_path.write_text(baked_text, encoding="utf-8")
35
+ return baked_path
36
+
37
+
38
+ def finalize_baked(baked_path: Path, repository: str) -> None:
39
+ print("Inline configs content")
40
+ # Inline file-sourced configs as content entries.
41
+ own_image_pattern = re.compile(rf"{re.escape(repository)}/[a-z0-9][a-z0-9-]*:[^\s\"'@$]+")
42
+ document = yaml.safe_load(baked_path.read_text(encoding="utf-8"))
43
+ parsed = ComposeDocument.model_validate(document)
44
+ if parsed.configs is not None:
45
+ inlined: dict[str, dict[str, Yaml]] = {}
46
+ for config_name, compose_config in parsed.configs.items():
47
+ if compose_config.file is None:
48
+ inlined[config_name] = document["configs"][config_name]
49
+ continue
50
+ entry = compose_config.model_dump(exclude_defaults=True)
51
+ entry.pop("file", None)
52
+ entry["content"] = double_dollars(Path(compose_config.file).read_text(encoding="utf-8"))
53
+ inlined[config_name] = entry
54
+ document["configs"] = inlined
55
+ baked_path.write_text(
56
+ yaml.dump(document, default_flow_style=False, sort_keys=False, width=1_000_000),
57
+ encoding="utf-8",
58
+ )
59
+
60
+ # Pin image refs under the repository to digests.
61
+ digests: dict[str, str] = {}
62
+ baked_path.write_text(
63
+ own_image_pattern.sub(partial(_pinned_reference, digests), baked_path.read_text(encoding="utf-8")),
64
+ encoding="utf-8",
65
+ )
66
+
67
+
68
+ def _pinned_reference(digests: dict[str, str], match: re.Match[str]) -> str:
69
+ reference = match.group(0)
70
+ if reference not in digests:
71
+ digests[reference] = resolve_remote_digest(reference)
72
+ return f"{reference.rsplit(':', 1)[0]}@{digests[reference]}"
boatyard/py.typed ADDED
File without changes