modelcar-maker 0.4.0__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.
- modelcar_maker/__init__.py +154 -0
- modelcar_maker/__version__.py +24 -0
- modelcar_maker/cli/__init__.py +3 -0
- modelcar_maker/cli/cli.py +177 -0
- modelcar_maker/download/__init__.py +3 -0
- modelcar_maker/download/hf_download.py +35 -0
- modelcar_maker/image/__init__.py +17 -0
- modelcar_maker/image/common.py +41 -0
- modelcar_maker/image/olot.py +389 -0
- modelcar_maker/image/podman.py +188 -0
- modelcar_maker/image/template.py +29 -0
- modelcar_maker/image/types.py +57 -0
- modelcar_maker/templates/Containerfile.j2 +20 -0
- modelcar_maker/util/__init__.py +7 -0
- modelcar_maker/util/config.py +34 -0
- modelcar_maker/util/defaults.toml +17 -0
- modelcar_maker/util/helpers.py +18 -0
- modelcar_maker/util/logging.py +33 -0
- modelcar_maker-0.4.0.dist-info/METADATA +150 -0
- modelcar_maker-0.4.0.dist-info/RECORD +26 -0
- modelcar_maker-0.4.0.dist-info/WHEEL +5 -0
- modelcar_maker-0.4.0.dist-info/entry_points.txt +2 -0
- modelcar_maker-0.4.0.dist-info/licenses/LICENSE +14 -0
- modelcar_maker-0.4.0.dist-info/scm_file_list.json +42 -0
- modelcar_maker-0.4.0.dist-info/scm_version.json +8 -0
- modelcar_maker-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .download import hf_download
|
|
6
|
+
from .image.types import Backend
|
|
7
|
+
from .util import normalize
|
|
8
|
+
from .util import settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ProcessResult:
|
|
13
|
+
"""Represents the state of the process workflow."""
|
|
14
|
+
|
|
15
|
+
skipped: bool = False
|
|
16
|
+
downloaded_to: Optional[Path] = None
|
|
17
|
+
image: Optional[str] = None
|
|
18
|
+
image_built: bool = False
|
|
19
|
+
image_pushed: bool = False
|
|
20
|
+
image_cleaned_up: bool = False
|
|
21
|
+
model_cleaned_up: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cleanup(path: Path, skip: list[str] = ["Containerfile"]) -> bool:
|
|
25
|
+
"""Remove all children of the provided path, except for top-level files provided in skip."""
|
|
26
|
+
changed = False
|
|
27
|
+
for subpath in path.iterdir():
|
|
28
|
+
if subpath.name in skip:
|
|
29
|
+
continue
|
|
30
|
+
if subpath.is_dir():
|
|
31
|
+
_ = cleanup(subpath, skip=[])
|
|
32
|
+
subpath.rmdir()
|
|
33
|
+
changed = True
|
|
34
|
+
else:
|
|
35
|
+
subpath.unlink()
|
|
36
|
+
changed = True
|
|
37
|
+
return changed
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def process(
|
|
41
|
+
model: str,
|
|
42
|
+
image_repo: str = f"{settings.image.registry}/{settings.image.repository}",
|
|
43
|
+
backend: Backend | str = settings.image.backend,
|
|
44
|
+
base_image: str = settings.image.base_image,
|
|
45
|
+
architectures: list[str] = settings.image.architectures,
|
|
46
|
+
authfile: Path | None = settings.image.get("authfile"),
|
|
47
|
+
push: bool = settings.image.push,
|
|
48
|
+
image_cleanup: bool = settings.image.cleanup,
|
|
49
|
+
model_cleanup: bool = settings.models.cleanup,
|
|
50
|
+
skip_if_exists: bool = settings.image.skip_if_exists,
|
|
51
|
+
pull: bool = settings.image.pull,
|
|
52
|
+
) -> ProcessResult:
|
|
53
|
+
"""Run through the entire process of downloading, packaging, and publishing a Model Car image."""
|
|
54
|
+
result = ProcessResult()
|
|
55
|
+
|
|
56
|
+
if isinstance(backend, str):
|
|
57
|
+
try:
|
|
58
|
+
backend = Backend(backend)
|
|
59
|
+
except ValueError:
|
|
60
|
+
raise NotImplementedError(f"Backend {backend!r} is not supported") from None
|
|
61
|
+
|
|
62
|
+
from .image.types import BuildArgs
|
|
63
|
+
from .image.types import PushArgs
|
|
64
|
+
from .image.types import RmArgs
|
|
65
|
+
|
|
66
|
+
if backend is Backend.PODMAN:
|
|
67
|
+
from .image.podman import do_build
|
|
68
|
+
from .image.podman import do_image_rm
|
|
69
|
+
from .image.podman import do_push
|
|
70
|
+
from .image.podman import image_exists
|
|
71
|
+
else:
|
|
72
|
+
from .image.olot import do_build
|
|
73
|
+
from .image.olot import do_image_rm
|
|
74
|
+
from .image.olot import do_push
|
|
75
|
+
from .image.olot import image_exists
|
|
76
|
+
|
|
77
|
+
if skip_if_exists:
|
|
78
|
+
if image_exists(model, image_repo):
|
|
79
|
+
# It was requested that we skip the build, and the image exists.
|
|
80
|
+
# We should still check if a cleanup is called for.
|
|
81
|
+
result.skipped = True
|
|
82
|
+
if image_cleanup:
|
|
83
|
+
oci_layout_dir = Path("tmp").joinpath(normalize(model)) if backend is not Backend.PODMAN else None
|
|
84
|
+
if do_image_rm(
|
|
85
|
+
RmArgs(model=model, repo=image_repo, oci_layout_dir=oci_layout_dir, architectures=architectures)
|
|
86
|
+
):
|
|
87
|
+
result.image_cleaned_up = True
|
|
88
|
+
if model_cleanup:
|
|
89
|
+
download_dir = Path("models").joinpath(normalize(model))
|
|
90
|
+
result.model_cleaned_up = cleanup(download_dir)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
# Ensure that the model is downloaded before the build
|
|
94
|
+
download_dir, commit = hf_download(model)
|
|
95
|
+
result.downloaded_to = download_dir
|
|
96
|
+
|
|
97
|
+
# Build the image
|
|
98
|
+
build_result = do_build(
|
|
99
|
+
BuildArgs(
|
|
100
|
+
model=model,
|
|
101
|
+
repo=image_repo,
|
|
102
|
+
model_dir=download_dir,
|
|
103
|
+
base_image=base_image,
|
|
104
|
+
commit=commit,
|
|
105
|
+
pull=pull,
|
|
106
|
+
architectures=architectures,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
result.image = build_result.image
|
|
110
|
+
result.image_built = True
|
|
111
|
+
|
|
112
|
+
if push:
|
|
113
|
+
do_push(
|
|
114
|
+
PushArgs(
|
|
115
|
+
model=model,
|
|
116
|
+
repo=image_repo,
|
|
117
|
+
authfile=authfile,
|
|
118
|
+
oci_layout_dir=build_result.oci_layout_dir,
|
|
119
|
+
architectures=architectures,
|
|
120
|
+
manifest_list=build_result.manifest_list,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
result.image_pushed = True
|
|
124
|
+
|
|
125
|
+
if image_cleanup:
|
|
126
|
+
if backend is Backend.PODMAN:
|
|
127
|
+
if image_exists(model, image_repo):
|
|
128
|
+
if do_image_rm(
|
|
129
|
+
RmArgs(
|
|
130
|
+
model=model,
|
|
131
|
+
repo=image_repo,
|
|
132
|
+
oci_layout_dir=build_result.oci_layout_dir,
|
|
133
|
+
architectures=architectures,
|
|
134
|
+
manifest_list=build_result.manifest_list,
|
|
135
|
+
)
|
|
136
|
+
):
|
|
137
|
+
result.image_cleaned_up = True
|
|
138
|
+
else:
|
|
139
|
+
if do_image_rm(
|
|
140
|
+
RmArgs(
|
|
141
|
+
model=model,
|
|
142
|
+
repo=image_repo,
|
|
143
|
+
oci_layout_dir=build_result.oci_layout_dir,
|
|
144
|
+
architectures=architectures,
|
|
145
|
+
manifest_list=build_result.manifest_list,
|
|
146
|
+
)
|
|
147
|
+
):
|
|
148
|
+
result.image_cleaned_up = True
|
|
149
|
+
|
|
150
|
+
if model_cleanup and result.image_built:
|
|
151
|
+
# Only clean up the files if it was skipped (above) or an image definitely got built
|
|
152
|
+
result.model_cleaned_up = cleanup(download_dir)
|
|
153
|
+
|
|
154
|
+
return result
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
from typing_extensions import Annotated
|
|
7
|
+
|
|
8
|
+
from .. import process
|
|
9
|
+
from ..image.types import Backend
|
|
10
|
+
from ..util import make_logger
|
|
11
|
+
from ..util import settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def version_callback(value: bool):
|
|
15
|
+
if value:
|
|
16
|
+
from ..__version__ import version
|
|
17
|
+
|
|
18
|
+
rprint(version)
|
|
19
|
+
raise typer.Exit()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
cli = typer.Typer(
|
|
23
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
24
|
+
pretty_exceptions_show_locals=False,
|
|
25
|
+
add_completion=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@cli.command()
|
|
30
|
+
def build(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
model: Annotated[
|
|
33
|
+
Optional[str],
|
|
34
|
+
typer.Argument(help="The model to download (otherwise ensure all defaults are downloaded)"),
|
|
35
|
+
] = None,
|
|
36
|
+
registry: Annotated[
|
|
37
|
+
str,
|
|
38
|
+
typer.Option(
|
|
39
|
+
"--registry",
|
|
40
|
+
help="The registry to include in the image reference",
|
|
41
|
+
),
|
|
42
|
+
] = settings.image.registry,
|
|
43
|
+
repository: Annotated[
|
|
44
|
+
str,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"-r",
|
|
47
|
+
"--repository",
|
|
48
|
+
help="The repository, within the registry, to include in the image reference",
|
|
49
|
+
),
|
|
50
|
+
] = settings.image.repository,
|
|
51
|
+
backend: Annotated[
|
|
52
|
+
Backend,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--backend",
|
|
55
|
+
help="Build backend to use: olot (default) or podman",
|
|
56
|
+
),
|
|
57
|
+
] = Backend(settings.image.backend),
|
|
58
|
+
base_image: Annotated[
|
|
59
|
+
str,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--base-image",
|
|
62
|
+
help="Base image to use for the modelcar (applies to both backends)",
|
|
63
|
+
),
|
|
64
|
+
] = settings.image.base_image,
|
|
65
|
+
arch: Annotated[
|
|
66
|
+
list[str],
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--arch",
|
|
69
|
+
help="Target architecture(s) to build for (repeat for multiple)",
|
|
70
|
+
),
|
|
71
|
+
] = settings.image.architectures,
|
|
72
|
+
pull: Annotated[
|
|
73
|
+
bool,
|
|
74
|
+
typer.Option(
|
|
75
|
+
"--pull/--no-pull",
|
|
76
|
+
help="Pull the base image before building if a newer version is available",
|
|
77
|
+
),
|
|
78
|
+
] = settings.image.pull,
|
|
79
|
+
push: Annotated[
|
|
80
|
+
bool,
|
|
81
|
+
typer.Option(
|
|
82
|
+
"--push/--no-push",
|
|
83
|
+
help="Push the image(s) after building",
|
|
84
|
+
),
|
|
85
|
+
] = settings.image.push,
|
|
86
|
+
authfile: Annotated[
|
|
87
|
+
Optional[Path],
|
|
88
|
+
typer.Option(
|
|
89
|
+
"-a",
|
|
90
|
+
"--authfile",
|
|
91
|
+
help="The authfile to use for the push",
|
|
92
|
+
),
|
|
93
|
+
] = settings.image.get("authfile"),
|
|
94
|
+
image_cleanup: Annotated[
|
|
95
|
+
bool,
|
|
96
|
+
typer.Option(
|
|
97
|
+
"--image-clean-up/--no-image-clean-up",
|
|
98
|
+
help="Clean up the container image after push to free up space",
|
|
99
|
+
),
|
|
100
|
+
] = settings.image.cleanup,
|
|
101
|
+
model_cleanup: Annotated[
|
|
102
|
+
bool,
|
|
103
|
+
typer.Option(
|
|
104
|
+
"--model-clean-up/--no-model-clean-up",
|
|
105
|
+
help="Clean up the downloaded model images after build to free up space",
|
|
106
|
+
),
|
|
107
|
+
] = settings.models.cleanup,
|
|
108
|
+
skip_if_exists: Annotated[
|
|
109
|
+
bool,
|
|
110
|
+
typer.Option(
|
|
111
|
+
"--skip-if-exists/--no-skip-if-exists",
|
|
112
|
+
help="Skip downloading and publishing the image if it already exists",
|
|
113
|
+
),
|
|
114
|
+
] = settings.image.skip_if_exists,
|
|
115
|
+
verbose: Annotated[
|
|
116
|
+
int,
|
|
117
|
+
typer.Option(
|
|
118
|
+
"--verbose",
|
|
119
|
+
"-v",
|
|
120
|
+
count=True,
|
|
121
|
+
help="Increase logging verbosity (repeat for more)",
|
|
122
|
+
show_default=False,
|
|
123
|
+
metavar="",
|
|
124
|
+
),
|
|
125
|
+
] = settings.meta.verbosity,
|
|
126
|
+
_: Annotated[
|
|
127
|
+
bool,
|
|
128
|
+
typer.Option(
|
|
129
|
+
"--version",
|
|
130
|
+
"-V",
|
|
131
|
+
callback=version_callback,
|
|
132
|
+
help="Print the version and exit",
|
|
133
|
+
is_eager=True,
|
|
134
|
+
show_default=False,
|
|
135
|
+
),
|
|
136
|
+
] = False,
|
|
137
|
+
):
|
|
138
|
+
"""Modelcar Maker model download, build, and push"""
|
|
139
|
+
|
|
140
|
+
logger = make_logger(verbose)
|
|
141
|
+
image_repo = f"{registry}/{repository}"
|
|
142
|
+
logger.debug(f"Backend: {backend}, Architectures: {arch}, Push {image_repo}: {push}")
|
|
143
|
+
if model is None:
|
|
144
|
+
if not isinstance(settings.models.default, list):
|
|
145
|
+
ctx.fail(f"Default models should be a list, not {type(settings.models.default)}")
|
|
146
|
+
if len(settings.models.default) < 1:
|
|
147
|
+
ctx.fail("No model provided, default models list is empty")
|
|
148
|
+
models = settings.models.default
|
|
149
|
+
else:
|
|
150
|
+
models = [model]
|
|
151
|
+
|
|
152
|
+
for model in models:
|
|
153
|
+
result = process(
|
|
154
|
+
str(model),
|
|
155
|
+
image_repo,
|
|
156
|
+
backend=backend,
|
|
157
|
+
base_image=base_image,
|
|
158
|
+
architectures=arch,
|
|
159
|
+
authfile=authfile,
|
|
160
|
+
push=push,
|
|
161
|
+
image_cleanup=image_cleanup,
|
|
162
|
+
model_cleanup=model_cleanup,
|
|
163
|
+
skip_if_exists=skip_if_exists,
|
|
164
|
+
pull=pull,
|
|
165
|
+
)
|
|
166
|
+
cleanup_str = f"Cleanup: {'✅' if result.image_cleaned_up else '❌'} Image, {'✅' if result.model_cleaned_up else '❌'} Model"
|
|
167
|
+
|
|
168
|
+
if result.skipped:
|
|
169
|
+
rprint(f"{model} was skipped as it already exists at {image_repo} - {cleanup_str}.")
|
|
170
|
+
if result.image_pushed:
|
|
171
|
+
rprint(
|
|
172
|
+
f"{model} was downloaded to {result.downloaded_to}, built as {result.image}, and pushed - {cleanup_str}."
|
|
173
|
+
)
|
|
174
|
+
elif result.image_built:
|
|
175
|
+
rprint(
|
|
176
|
+
f"{model} was downloaded to {result.downloaded_to} and built as {result.image}, but not pushed - {cleanup_str}."
|
|
177
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from huggingface_hub import snapshot_download
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
from tqdm import TqdmExperimentalWarning
|
|
7
|
+
from tqdm.rich import tqdm_rich
|
|
8
|
+
|
|
9
|
+
from ..util import logger
|
|
10
|
+
from ..util import normalize
|
|
11
|
+
|
|
12
|
+
warnings.filterwarnings("ignore", category=TqdmExperimentalWarning)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hf_download(repo_id: str) -> tuple[Path, str]:
|
|
16
|
+
"""Download the given model repo_id into the models/ directory, returning the path."""
|
|
17
|
+
from huggingface_hub import HfApi
|
|
18
|
+
|
|
19
|
+
normalized = normalize(repo_id)
|
|
20
|
+
download_dir = Path(f"models/{normalized}")
|
|
21
|
+
logger.info(f"Downloading {repo_id} to {download_dir}")
|
|
22
|
+
if not download_dir.is_dir():
|
|
23
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
rprint(f"Downloading {repo_id} to {download_dir}")
|
|
26
|
+
snapshot_download(repo_id, local_dir=download_dir, tqdm_class=tqdm_rich) # type: ignore[arg-type]
|
|
27
|
+
|
|
28
|
+
# Get the commit hash from the public API instead of internal cache metadata.
|
|
29
|
+
sha = HfApi().model_info(repo_id).sha
|
|
30
|
+
assert sha is not None
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
download_dir,
|
|
34
|
+
sha,
|
|
35
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .common import _image
|
|
2
|
+
from .common import list_model_files
|
|
3
|
+
from .template import render
|
|
4
|
+
from .types import BuildArgs
|
|
5
|
+
from .types import BuildResult
|
|
6
|
+
from .types import PushArgs
|
|
7
|
+
from .types import RmArgs
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"_image",
|
|
11
|
+
"list_model_files",
|
|
12
|
+
"render",
|
|
13
|
+
"BuildArgs",
|
|
14
|
+
"BuildResult",
|
|
15
|
+
"PushArgs",
|
|
16
|
+
"RmArgs",
|
|
17
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ..util import normalize
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _image(model: str, repo: str) -> str:
|
|
7
|
+
"""Given a model repo id and container image repository, render the full image name."""
|
|
8
|
+
tag = normalize(model) + "-modelcar"
|
|
9
|
+
return f"{repo}:{tag}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def should_include(file: str | Path) -> bool:
|
|
13
|
+
"""Simple filter function to determine if a file should be included in an image definition."""
|
|
14
|
+
if isinstance(file, Path):
|
|
15
|
+
file = file.name
|
|
16
|
+
|
|
17
|
+
if file.startswith("."):
|
|
18
|
+
return False
|
|
19
|
+
if file == "Containerfile":
|
|
20
|
+
return False
|
|
21
|
+
if file == "original":
|
|
22
|
+
return False
|
|
23
|
+
if file == "consolidated.safetensors":
|
|
24
|
+
return False
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def list_model_files(model_dir: Path) -> tuple[list[str], str | None]:
|
|
29
|
+
"""List files to include in the modelcar and identify the modelcard source.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A tuple of (sorted list of included filenames, modelcard_source or None).
|
|
33
|
+
"""
|
|
34
|
+
model_files = [file.name for file in model_dir.iterdir() if should_include(file)]
|
|
35
|
+
model_files.sort()
|
|
36
|
+
modelcard_source = None
|
|
37
|
+
for file in model_files:
|
|
38
|
+
if file.startswith("README"):
|
|
39
|
+
modelcard_source = file
|
|
40
|
+
break
|
|
41
|
+
return model_files, modelcard_source
|