From 471649f8e999029ee44b243c3df221fe768cca85 Mon Sep 17 00:00:00 2001 From: Dulaj Disanayaka Date: Wed, 11 Feb 2026 12:45:56 +0100 Subject: [PATCH 1/3] fix(jig): Use pydantic dataclasses for config validation --- src/together/lib/cli/api/beta/jig/_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/together/lib/cli/api/beta/jig/_config.py b/src/together/lib/cli/api/beta/jig/_config.py index 2db5309d..cd6bb1f3 100644 --- a/src/together/lib/cli/api/beta/jig/_config.py +++ b/src/together/lib/cli/api/beta/jig/_config.py @@ -7,9 +7,11 @@ import json from typing import TYPE_CHECKING, Any, Optional from pathlib import Path -from dataclasses import field, asdict, dataclass +from dataclasses import field, asdict import click +from pydantic import ValidationError +from pydantic.dataclasses import dataclass if TYPE_CHECKING: import tomli as tomllib From 1f005de2408306675e274117f2924f59c71cda72 Mon Sep 17 00:00:00 2001 From: Dulaj Disanayaka Date: Wed, 11 Feb 2026 12:46:21 +0100 Subject: [PATCH 2/3] fix(jig): Improve config validation error reporting --- src/together/lib/cli/api/beta/jig/_config.py | 43 +++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/together/lib/cli/api/beta/jig/_config.py b/src/together/lib/cli/api/beta/jig/_config.py index cd6bb1f3..9e5b1914 100644 --- a/src/together/lib/cli/api/beta/jig/_config.py +++ b/src/together/lib/cli/api/beta/jig/_config.py @@ -38,6 +38,25 @@ # --- Configuration Dataclasses --- +def _format_validation_errors( + exc: ValidationError, + prefix: str, + section: str, + path: Path, +) -> str: + """Format a pydantic ValidationError with file context.""" + header = f"Configuration error in {path}" + if prefix: + header += f" [{prefix}.{section}]" if section else f" [{prefix}]" + elif section: + header += f" [{section}]" + lines = [header + ":"] + for e in exc.errors(): + loc = " -> ".join(str(part) for part in e["loc"]) + lines.append(f" - {loc}: {e['msg']}") + return "\n".join(lines) + + @dataclass class ImageConfig: """Container image configuration from pyproject.toml""" @@ -163,9 +182,29 @@ def load(cls, data: dict[str, Any], path: Path) -> Config: # Support volume_mounts at jig level (merge into deploy config) jig_config["deploy"]["volume_mounts"] = jig_config.get("volume_mounts", []) + prefix = "tool.jig" if is_pyproject else "" + errors: list[str] = [] + + try: + image = ImageConfig.from_dict(jig_config.get("image", {})) + except ValidationError as exc: + errors.append(_format_validation_errors(exc, prefix, "image", path)) + image = None + + try: + deploy = DeployConfig.from_dict(jig_config.get("deploy", {})) + except ValidationError as exc: + errors.append(_format_validation_errors(exc, prefix, "deploy", path)) + deploy = None + + if errors: + click.echo("\n\n".join(errors), err=True) + sys.exit(1) + + assert image is not None and deploy is not None return cls( - image=ImageConfig.from_dict(jig_config.get("image", {})), - deploy=DeployConfig.from_dict(jig_config.get("deploy", {})), + image=image, + deploy=deploy, dockerfile=jig_config.get("dockerfile", "Dockerfile"), model_name=name, _path=path, From 38badd16553f353f37a251d9c65effe93a437d5c Mon Sep 17 00:00:00 2001 From: Dulaj Disanayaka Date: Wed, 11 Feb 2026 15:04:36 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(jig):=20backward=20compatibility=20with?= =?UTF-8?q?=20Pydantic=20v1=20-=20ImageConfig.copy=20=E2=86=92=20copy=5Ffi?= =?UTF-8?q?les=20(shadowed=20BaseModel.copy=20in=20Pydantic=20v1)=20-=20Co?= =?UTF-8?q?nfig.=5Fpath=20=E2=86=92=20config=5Fpath,=20Config.=5Funique=5F?= =?UTF-8?q?name=5Ftip=20=E2=86=92=20unique=5Fname=5Ftip=20(Pydantic=20v1?= =?UTF-8?q?=20rejects=20underscore-prefixed=20fields)=20-=20State.=5Fconfi?= =?UTF-8?q?g=5Fdir=20=E2=86=92=20config=5Fdir,=20State.=5Fproject=5Fname?= =?UTF-8?q?=20=E2=86=92=20project=5Fname=20(same=20reason)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/together/lib/cli/api/beta/jig/_config.py | 35 +++++++++++--------- src/together/lib/cli/api/beta/jig/jig.py | 16 +++++---- src/together/lib/cli/api/beta/jig/secrets.py | 6 ++-- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/together/lib/cli/api/beta/jig/_config.py b/src/together/lib/cli/api/beta/jig/_config.py index 9e5b1914..d8868a27 100644 --- a/src/together/lib/cli/api/beta/jig/_config.py +++ b/src/together/lib/cli/api/beta/jig/_config.py @@ -66,12 +66,15 @@ class ImageConfig: environment: dict[str, str] = field(default_factory=dict[str, str]) run: list[str] = field(default_factory=list[str]) cmd: str = "python app.py" - copy: list[str] = field(default_factory=list[str]) + copy_files: list[str] = field(default_factory=list[str]) auto_include_git: bool = False @classmethod def from_dict(cls, data: dict[str, Any]) -> ImageConfig: - return cls(**{k: v for k, v in data.items() if k in cls.__annotations__}) + mapped = {k: v for k, v in data.items() if k in cls.__annotations__} + if "copy" in data: + mapped["copy_files"] = data["copy"] + return cls(**mapped) @dataclass @@ -122,8 +125,8 @@ class Config: dockerfile: str = "Dockerfile" image: ImageConfig = field(default_factory=ImageConfig) deploy: DeployConfig = field(default_factory=DeployConfig) - _path: Path = field(default_factory=lambda: Path("pyproject.toml")) - _unique_name_tip: str = "Update project.name in pyproject.toml" + config_path: Path = field(default_factory=lambda: Path("pyproject.toml")) + unique_name_tip: str = "Update project.name in pyproject.toml" @classmethod def find(cls, config_path: Optional[str] = None, init: bool = False) -> Config: @@ -207,8 +210,8 @@ def load(cls, data: dict[str, Any], path: Path) -> Config: deploy=deploy, dockerfile=jig_config.get("dockerfile", "Dockerfile"), model_name=name, - _path=path, - _unique_name_tip=tip, + config_path=path, + unique_name_tip=tip, ) @@ -219,16 +222,18 @@ def load(cls, data: dict[str, Any], path: Path) -> Config: class State: """Persistent state stored in .jig.json""" - _config_dir: Path - _project_name: str + config_dir: Path + project_name: str registry_base_path: str = "" secrets: dict[str, str] = field(default_factory=dict[str, str]) volumes: dict[str, str] = field(default_factory=dict[str, str]) @classmethod def from_dict(cls, config_dir: Path, project_name: str, **data: Any) -> State: - filtered = {k: v for k, v in data.items() if k in cls.__annotations__ and not k.startswith("_")} - return cls(_config_dir=config_dir, _project_name=project_name, **filtered) + filtered = { + k: v for k, v in data.items() if k in cls.__annotations__ and k not in ("config_dir", "project_name") + } + return cls(config_dir=config_dir, project_name=project_name, **filtered) @classmethod def load(cls, config_dir: Path, project_name: str) -> State: @@ -259,16 +264,16 @@ def load(cls, config_dir: Path, project_name: str) -> State: if "secrets" in all_data or "volumes" in all_data: return cls.from_dict(config_dir, project_name, **all_data) # File exists but this project isn't in it yet - return cls(_config_dir=config_dir, _project_name=project_name) + return cls(config_dir=config_dir, project_name=project_name) except FileNotFoundError: - return cls(_config_dir=config_dir, _project_name=project_name) + return cls(config_dir=config_dir, project_name=project_name) def save(self) -> None: """Save state for this project to .jig.json. Preserves other projects' state in the same file. """ - path = self._config_dir / ".jig.json" + path = self.config_dir / ".jig.json" # Load existing file to preserve other projects try: @@ -278,8 +283,8 @@ def save(self) -> None: all_data = {} # Update this project's state - project_data = {k: v for k, v in asdict(self).items() if not k.startswith("_")} - all_data[self._project_name] = project_data + project_data = {k: v for k, v in asdict(self).items() if k not in ("config_dir", "project_name")} + all_data[self.project_name] = project_data # Save back to file with open(path, "w") as f: diff --git a/src/together/lib/cli/api/beta/jig/jig.py b/src/together/lib/cli/api/beta/jig/jig.py index 1b64bfb2..4587d3ea 100644 --- a/src/together/lib/cli/api/beta/jig/jig.py +++ b/src/together/lib/cli/api/beta/jig/jig.py @@ -110,7 +110,7 @@ def _generate_dockerfile(config: Config) -> str: def _get_files_to_copy(config: Config) -> list[str]: """Get list of files to copy""" - files = set(config.image.copy) + files = set(config.image.copy_files) if config.image.auto_include_git: try: if _run(["git", "status", "--porcelain"]).stdout.strip(): @@ -147,7 +147,11 @@ def _dockerfile(config: Config) -> bool: return False # Skip regeneration if config hasn't changed - if config._path and config._path.exists() and dockerfile_path.stat().st_mtime >= config._path.stat().st_mtime: + if ( + config.config_path + and config.config_path.exists() + and dockerfile_path.stat().st_mtime >= config.config_path.stat().st_mtime + ): return True with open(dockerfile_path, "w") as f: @@ -373,7 +377,7 @@ def build( client: Together = ctx.obj config = Config.find(config_path) - state = State.load(config._path.parent, config.model_name) + state = State.load(config.config_path.parent, config.model_name) _ensure_registry_base_path(client, state) image = _get_image(state, config, tag) @@ -407,7 +411,7 @@ def push(ctx: click.Context, tag: str, config_path: str | None) -> None: """Push image to registry""" client: Together = ctx.obj config = Config.find(config_path) - state = State.load(config._path.parent, config.model_name) + state = State.load(config.config_path.parent, config.model_name) _ensure_registry_base_path(client, state) image = _get_image(state, config, tag) @@ -441,7 +445,7 @@ def deploy( """Deploy model""" client: Together = ctx.obj config = Config.find(config_path) - state = State.load(config._path.parent, config.model_name) + state = State.load(config.config_path.parent, config.model_name) _ensure_registry_base_path(client, state) if existing_image: @@ -528,7 +532,7 @@ def handle_create() -> dict[str, Any]: error_body: Any = getattr(e, "body", None) error_message = error_body.get("error", "") if isinstance(error_body, dict) else "" # pyright: ignore if "already exists" in error_message or "must be unique" in error_message: - raise RuntimeError(f"Deployment name must be unique. Tip: {config._unique_name_tip}") from None + raise RuntimeError(f"Deployment name must be unique. Tip: {config.unique_name_tip}") from None # TODO: helpful tips for more error cases raise diff --git a/src/together/lib/cli/api/beta/jig/secrets.py b/src/together/lib/cli/api/beta/jig/secrets.py index 993b588e..48c815e9 100644 --- a/src/together/lib/cli/api/beta/jig/secrets.py +++ b/src/together/lib/cli/api/beta/jig/secrets.py @@ -34,7 +34,7 @@ def secrets_set( """Set a secret (create or update)""" client: Together = ctx.obj config = Config.find(config_path) - state = State.load(config._path.parent, config.model_name) + state = State.load(config.config_path.parent, config.model_name) deployment_secret_name = f"{config.model_name}-{name}" @@ -76,7 +76,7 @@ def secrets_unset( ) -> None: """Remove a secret from both remote and local state""" config = Config.find(config_path) - state = State.load(config._path.parent, config.model_name) + state = State.load(config.config_path.parent, config.model_name) if state.secrets.pop(name, ""): state.save() @@ -96,7 +96,7 @@ def secrets_list( """List all secrets with sync status""" client: Together = ctx.obj config = Config.find(config_path) - state = State.load(config._path.parent, config.model_name) + state = State.load(config.config_path.parent, config.model_name) prefix = f"{config.model_name}-"