llama-deploy-core 0.2.7a1__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.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: llama-deploy-core
3
+ Version: 0.2.7a1
4
+ Summary: Core models and schemas for LlamaDeploy
5
+ License: MIT
6
+ Requires-Dist: pydantic>=2.0.0
7
+ Requires-Dist: pyyaml>=6.0.2
8
+ Requires-Python: >=3.12, <4
9
+ Description-Content-Type: text/markdown
10
+
11
+ > [!WARNING]
12
+ > This repository contains pre-release software. It is unstable, incomplete, and subject to breaking changes. Not recommended for use.
@@ -0,0 +1,2 @@
1
+ > [!WARNING]
2
+ > This repository contains pre-release software. It is unstable, incomplete, and subject to breaking changes. Not recommended for use.
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "llama-deploy-core"
3
+ version = "0.2.7a1"
4
+ description = "Core models and schemas for LlamaDeploy"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.12, <4"
8
+ dependencies = [
9
+ "pydantic>=2.0.0",
10
+ "pyyaml>=6.0.2",
11
+ ]
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.7.20,<0.8.0"]
15
+ build-backend = "uv_build"
16
+
17
+ [tool.uv.build-backend]
18
+ module-name = "llama_deploy.core"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "pytest>=8.4.1",
23
+ ]
@@ -0,0 +1,3 @@
1
+ from . import schema
2
+
3
+ __all__ = ["schema"]
@@ -0,0 +1 @@
1
+ DEFAULT_DEPLOYMENT_FILE_PATH = "llama_deploy.yaml"
@@ -0,0 +1,224 @@
1
+ """
2
+ Git utilities for the purpose of exploring, cloning, and parsing llama-deploy repositories.
3
+ Responsibilities are lower level git access, as well as some application specific config parsing.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+ import tempfile
11
+
12
+ import yaml
13
+
14
+
15
+ def parse_github_repo_url(repo_url: str) -> tuple[str, str]:
16
+ """
17
+ Parse GitHub repository URL to extract owner and repo name.
18
+
19
+ Args:
20
+ repo_url: GitHub repository URL (various formats supported)
21
+
22
+ Returns:
23
+ Tuple of (owner, repo_name)
24
+
25
+ Raises:
26
+ ValueError: If URL format is not recognized
27
+ """
28
+ # Remove .git suffix if present
29
+ url = repo_url.rstrip("/").removesuffix(".git")
30
+
31
+ # Handle different GitHub URL formats
32
+ patterns = [
33
+ r"https://github\.com/([^/]+)/([^/]+)",
34
+ r"git@github\.com:([^/]+)/([^/]+)",
35
+ r"github\.com/([^/]+)/([^/]+)",
36
+ ]
37
+
38
+ for pattern in patterns:
39
+ match = re.match(pattern, url)
40
+ if match:
41
+ return match.group(1), match.group(2)
42
+
43
+ raise ValueError(f"Could not parse GitHub repository URL: {repo_url}")
44
+
45
+
46
+ def inject_basic_auth(url: str, basic_auth: str | None = None) -> str:
47
+ """Inject basic auth into a URL if provided"""
48
+ if basic_auth and "://" in url and "@" not in url:
49
+ url = url.replace("https://", f"https://{basic_auth}@")
50
+ return url
51
+
52
+
53
+ def _run_process(args: list[str], cwd: str | None = None) -> str:
54
+ """Run a process and raise an exception if it fails"""
55
+ result = subprocess.run(
56
+ args, cwd=cwd, capture_output=True, text=True, check=True, timeout=30
57
+ )
58
+ if result.returncode != 0:
59
+ raise subprocess.CalledProcessError(
60
+ result.returncode, args, result.stdout, result.stderr
61
+ )
62
+ return result.stdout.strip()
63
+
64
+
65
+ class GitAccessError(Exception):
66
+ """Error raised when a user reportable git error occurs, e.g connection fails, cannot access repository, timeout, ref not found, etc."""
67
+
68
+ def __init__(self, message: str):
69
+ self.message = message
70
+ super().__init__(message)
71
+
72
+
73
+ @dataclass
74
+ class GitCloneResult:
75
+ git_sha: str
76
+ git_ref: str | None = None
77
+
78
+
79
+ def clone_repo(
80
+ repository_url: str,
81
+ git_ref: str | None = None,
82
+ basic_auth: str | None = None,
83
+ dest_dir: Path | str | None = None,
84
+ ) -> GitCloneResult:
85
+ """
86
+ Clone a repository and checkout a specific ref, if provided. If user reportable access errors occur, raises a GitAccessError.
87
+
88
+ Args:
89
+ repository_url: The URL of the repository to clone
90
+ git_ref: The git reference to checkout, if provided
91
+ basic_auth: The basic auth to use to clone the repository
92
+ dest_dir: The directory to clone the repository to, if provided
93
+
94
+ Returns:
95
+ GitCloneResult: A dataclass containing the git SHA and resolved git ref (e.g. main if None was provided)
96
+ """
97
+ try:
98
+ with tempfile.TemporaryDirectory() as temp_dir:
99
+ dest_dir = Path(temp_dir) if dest_dir is None else Path(dest_dir)
100
+ authenticated_url = inject_basic_auth(repository_url, basic_auth)
101
+ did_exist = (
102
+ dest_dir.exists() and dest_dir.is_dir() and list(dest_dir.iterdir())
103
+ )
104
+ if not did_exist:
105
+ # need to do a full clone to resolve any kind of ref without exploding in
106
+ # complexity (tag, branch, commit, short commit)
107
+ clone_args = [
108
+ "git",
109
+ "clone",
110
+ authenticated_url,
111
+ str(dest_dir.absolute()),
112
+ ]
113
+ _run_process(clone_args)
114
+
115
+ if not git_ref:
116
+ resolved_branch = _run_process(
117
+ ["git", "branch", "--show-current"],
118
+ cwd=str(dest_dir.absolute()),
119
+ )
120
+ if resolved_branch:
121
+ git_ref = resolved_branch
122
+ else:
123
+ try:
124
+ resolved_tag = _run_process(
125
+ ["git", "describe", "--tags", "--exact-match"],
126
+ cwd=str(dest_dir.absolute()),
127
+ )
128
+ if resolved_tag:
129
+ git_ref = resolved_tag
130
+ except subprocess.CalledProcessError:
131
+ pass
132
+ else: # Checkout the ref
133
+ if did_exist:
134
+ try:
135
+ _run_process(
136
+ ["git", "fetch", "origin"], cwd=str(dest_dir.absolute())
137
+ )
138
+ except subprocess.CalledProcessError:
139
+ raise GitAccessError("Failed to resolve git reference")
140
+ try:
141
+ _run_process(
142
+ ["git", "checkout", git_ref], cwd=str(dest_dir.absolute())
143
+ )
144
+ except subprocess.CalledProcessError as e:
145
+ # Check error message to determine if it's a network issue or ref not found
146
+ if "unable to access" in str(
147
+ e.stderr
148
+ ) or "fatal: unable to access repository" in str(e.stderr):
149
+ raise GitAccessError("Failed to resolve git reference")
150
+ else:
151
+ raise GitAccessError(f"Commit SHA '{git_ref}' not found")
152
+ # if no ref, stay on whatever the clone gave us/current commit
153
+ # return the resolved sha
154
+ resolved_sha = _run_process(
155
+ ["git", "rev-parse", "HEAD"], cwd=str(dest_dir.absolute())
156
+ ).strip()
157
+ return GitCloneResult(git_sha=resolved_sha, git_ref=git_ref)
158
+ except subprocess.TimeoutExpired:
159
+ raise GitAccessError("Timeout while cloning repository")
160
+
161
+
162
+ def validate_deployment_file(repo_dir: Path, deployment_file_path: str) -> bool:
163
+ """
164
+ Validate that the deployment file exists in the repository.
165
+
166
+ Args:
167
+ repo_dir: The directory of the repository
168
+ deployment_file_path: The path to the deployment file, relative to the repository root
169
+
170
+ Returns:
171
+ True if the deployment file exists and appears to be valid, False otherwise
172
+ """
173
+ deployment_file = repo_dir / deployment_file_path
174
+ if not deployment_file.exists():
175
+ return False
176
+ with open(deployment_file, "r") as f:
177
+ try:
178
+ loaded = yaml.safe_load(f)
179
+ if not isinstance(loaded, dict):
180
+ return False
181
+ if "name" not in loaded:
182
+ return False
183
+ if not isinstance(loaded["name"], str):
184
+ return False
185
+ if "services" not in loaded:
186
+ return False
187
+ if not isinstance(loaded["services"], dict):
188
+ return False
189
+ return True # good nuff for now. Eventually this should parse it into a model validated format
190
+ except yaml.YAMLError:
191
+ return False
192
+
193
+
194
+ async def validate_git_public_access(repository_url: str) -> bool:
195
+ """Check if a git repository is publicly accessible using git ls-remote."""
196
+
197
+ try:
198
+ result = subprocess.run(
199
+ ["git", "ls-remote", "--heads", repository_url],
200
+ capture_output=True,
201
+ text=True,
202
+ timeout=30,
203
+ check=False, # Don't raise on non-zero exit
204
+ )
205
+ return result.returncode == 0
206
+ except (subprocess.TimeoutExpired, Exception):
207
+ return False
208
+
209
+
210
+ async def validate_git_credential_access(repository_url: str, basic_auth: str) -> bool:
211
+ """Check if a credential provides access to a git repository."""
212
+
213
+ auth_url = inject_basic_auth(repository_url, basic_auth)
214
+ try:
215
+ result = subprocess.run(
216
+ ["git", "ls-remote", "--heads", auth_url],
217
+ capture_output=True,
218
+ text=True,
219
+ timeout=30,
220
+ check=False,
221
+ )
222
+ return result.returncode == 0
223
+ except (subprocess.TimeoutExpired, Exception):
224
+ return False
@@ -0,0 +1,27 @@
1
+ from .base import Base
2
+ from .deployments import (
3
+ DeploymentCreate,
4
+ DeploymentResponse,
5
+ DeploymentUpdate,
6
+ DeploymentsListResponse,
7
+ LlamaDeploymentSpec,
8
+ apply_deployment_update,
9
+ LlamaDeploymentPhase,
10
+ )
11
+ from .git_validation import RepositoryValidationResponse, RepositoryValidationRequest
12
+ from .projects import ProjectSummary, ProjectsListResponse
13
+
14
+ __all__ = [
15
+ "Base",
16
+ "DeploymentCreate",
17
+ "DeploymentResponse",
18
+ "DeploymentUpdate",
19
+ "DeploymentsListResponse",
20
+ "LlamaDeploymentSpec",
21
+ "apply_deployment_update",
22
+ "LlamaDeploymentPhase",
23
+ "RepositoryValidationResponse",
24
+ "RepositoryValidationRequest",
25
+ "ProjectSummary",
26
+ "ProjectsListResponse",
27
+ ]
@@ -0,0 +1,20 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+ base_config = ConfigDict(
4
+ from_attributes=True,
5
+ arbitrary_types_allowed=True,
6
+ use_enum_values=False,
7
+ # ===== timedelta serialization =====
8
+ # Serialize timedelta as float
9
+ # This was the default behavior in pydantic v1, but was changed in v2 to be "iso8601"
10
+ # We want to keep the same behavior as v1 for now
11
+ # ================================
12
+ ser_json_timedelta="float",
13
+ # NOTE: we often use data model with fields that have "model_" prefix,
14
+ # so we need to set protected_namespaces=() to avoid conflict
15
+ protected_namespaces=(),
16
+ )
17
+
18
+
19
+ class Base(BaseModel):
20
+ model_config = base_config
@@ -0,0 +1,188 @@
1
+ from typing import Literal
2
+ from datetime import datetime
3
+
4
+ from pydantic import HttpUrl
5
+
6
+ from .base import Base
7
+
8
+
9
+ # K8s CRD phase values
10
+ LlamaDeploymentPhase = Literal[
11
+ "Syncing", # Initial reconciliation phase - controller is processing the deployment
12
+ "Pending", # Waiting for deployment resources to be ready (pods starting up)
13
+ "Running", # Deployment is healthy and serving traffic
14
+ "Failed", # Complete deployment failure - no pods available
15
+ "Succeeded", # Deployment completed successfully (for one-time jobs)
16
+ "RollingOut", # Rolling update in progress - new pods being created while old ones still serve traffic
17
+ "RolloutFailed", # New deployment failed but old pods are still available and serving traffic
18
+ ]
19
+
20
+
21
+ class DeploymentResponse(Base):
22
+ id: str
23
+ name: str
24
+ repo_url: str
25
+ deployment_file_path: str
26
+ git_ref: str | None = None
27
+ git_sha: str | None = None
28
+ has_personal_access_token: bool = False
29
+ project_id: str
30
+ secret_names: list[str] | None = None
31
+ apiserver_url: HttpUrl | None
32
+ status: LlamaDeploymentPhase
33
+
34
+
35
+ class DeploymentsListResponse(Base):
36
+ deployments: list[DeploymentResponse]
37
+
38
+
39
+ class DeploymentCreate(Base):
40
+ name: str
41
+ repo_url: str
42
+ deployment_file_path: str | None = None
43
+ git_ref: str | None = None
44
+ personal_access_token: str | None = None
45
+ secrets: dict[str, str] | None = None
46
+
47
+
48
+ class LlamaDeploymentMetadata(Base):
49
+ name: str
50
+ namespace: str
51
+ uid: str | None = None
52
+ resourceVersion: str | None = None
53
+ creationTimestamp: datetime | None = None
54
+ annotations: dict[str, str] | None = None
55
+ labels: dict[str, str] | None = None
56
+
57
+
58
+ class LlamaDeploymentSpec(Base):
59
+ """
60
+ LlamaDeployment spec fields as defined in the Kubernetes CRD.
61
+
62
+ Maps to the spec section of the LlamaDeployment custom resource.
63
+ Field names match exactly with the K8s CRD for direct conversion.
64
+ """
65
+
66
+ projectId: str
67
+ repoUrl: str
68
+ deploymentFilePath: str = "llama_deploy.yaml"
69
+ gitRef: str | None = None
70
+ gitSha: str | None = None
71
+ name: str
72
+ secretName: str | None = None
73
+
74
+
75
+ class LlamaDeploymentStatus(Base):
76
+ """
77
+ LlamaDeployment status fields as defined in the Kubernetes CRD.
78
+
79
+ Maps to the status section of the LlamaDeployment custom resource.
80
+ """
81
+
82
+ phase: LlamaDeploymentPhase | None = None
83
+ message: str | None = None
84
+ lastUpdated: datetime | None = None
85
+ authToken: str | None = None
86
+
87
+
88
+ class LlamaDeploymentCRD(Base):
89
+ metadata: LlamaDeploymentMetadata
90
+ spec: LlamaDeploymentSpec
91
+ status: LlamaDeploymentStatus
92
+
93
+
94
+ class DeploymentUpdate(Base):
95
+ """
96
+ Patch-style update model for deployments.
97
+
98
+ Fields not included in the request will remain unchanged.
99
+ Fields explicitly set to None will clear/delete the field value.
100
+
101
+ For secrets: provide a dict where string values add/update secrets
102
+ and null values remove secrets.
103
+ """
104
+
105
+ repo_url: str | None = None
106
+ deployment_file_path: str | None = None
107
+ git_ref: str | None = None
108
+ git_sha: str | None = None
109
+ personal_access_token: str | None = None
110
+ secrets: dict[str, str | None] | None = None
111
+
112
+
113
+ class DeploymentUpdateResult(Base):
114
+ """
115
+ Result of applying a DeploymentUpdate to a LlamaDeploymentSpec.
116
+
117
+ Contains the updated spec and lists of secret changes to apply.
118
+ """
119
+
120
+ updated_spec: LlamaDeploymentSpec
121
+ secret_adds: dict[str, str]
122
+ secret_removes: list[str]
123
+
124
+
125
+ def apply_deployment_update(
126
+ update: DeploymentUpdate,
127
+ existing_spec: LlamaDeploymentSpec,
128
+ ) -> DeploymentUpdateResult:
129
+ """
130
+ Apply a DeploymentUpdate to an existing LlamaDeploymentSpec.
131
+
132
+ Returns the updated spec and lists of secret changes.
133
+
134
+ Args:
135
+ update: The update to apply (snake_case fields from API)
136
+ existing_spec: The current LlamaDeploymentSpec (camelCase fields)
137
+ git_sha: The resolved git SHA to set
138
+
139
+ Returns:
140
+ DeploymentUpdateResult with updated spec and secret changes
141
+ """
142
+ # Start with a copy of the existing spec
143
+ updated_spec = existing_spec.model_copy()
144
+
145
+ # Apply direct field updates (only if not None)
146
+ # Convert from snake_case API fields to camelCase spec fields
147
+ if update.repo_url is not None:
148
+ updated_spec.repoUrl = update.repo_url
149
+
150
+ if update.deployment_file_path is not None:
151
+ updated_spec.deploymentFilePath = update.deployment_file_path
152
+
153
+ if update.git_ref is not None:
154
+ updated_spec.gitRef = update.git_ref
155
+
156
+ # Update gitSha if provided
157
+ if update.git_sha is not None:
158
+ updated_spec.gitSha = None if update.git_sha == "" else update.git_sha
159
+
160
+ # Track secret changes
161
+ secret_adds: dict[str, str] = {}
162
+ secret_removes: list[str] = []
163
+
164
+ # Handle personal access token (stored as GITHUB_PAT secret)
165
+ if update.personal_access_token is not None:
166
+ if update.personal_access_token == "":
167
+ # Empty string means remove the PAT
168
+ secret_removes.append("GITHUB_PAT")
169
+ else:
170
+ # Non-empty string means add/update the PAT
171
+ secret_adds["GITHUB_PAT"] = update.personal_access_token
172
+
173
+ # Handle explicit secret updates
174
+ secrets = update.secrets
175
+ if secrets is not None:
176
+ for key, value in secrets.items():
177
+ if value is None:
178
+ # None means remove this secret
179
+ secret_removes.append(key)
180
+ else:
181
+ # String value means add/update this secret
182
+ secret_adds[key] = value
183
+
184
+ return DeploymentUpdateResult(
185
+ updated_spec=updated_spec,
186
+ secret_adds=secret_adds,
187
+ secret_removes=secret_removes,
188
+ )
@@ -0,0 +1,46 @@
1
+ from typing import Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class RepositoryValidationResponse(BaseModel):
6
+ """
7
+ Unified response for repository validation that works for any git repository.
8
+ This is the primary schema that should be used for the /validate-repository endpoint.
9
+ """
10
+
11
+ accessible: bool = Field(
12
+ ...,
13
+ description="Whether the repository can be accessed by any means available to the server",
14
+ )
15
+ message: str = Field(..., description="Human-readable string explaining the status")
16
+ pat_is_obsolete: bool = Field(
17
+ default=False,
18
+ description="True if validation succeeded via GitHub App for a deployment that previously used a PAT",
19
+ )
20
+ github_app_name: Optional[str] = Field(
21
+ default=None,
22
+ description="Name of the GitHub App if repository is a private GitHub repo and server has GitHub App configured",
23
+ )
24
+ github_app_installation_url: Optional[str] = Field(
25
+ default=None,
26
+ description="GitHub App installation URL if repository is a private GitHub repo and server has GitHub App configured",
27
+ )
28
+
29
+
30
+ class RepositoryValidationRequest(BaseModel):
31
+ repository_url: str
32
+ deployment_id: Optional[str] = None
33
+ pat: Optional[str] = None
34
+
35
+
36
+ class GitApplicationValidationResponse(BaseModel):
37
+ """
38
+ After general repository validation, a model that describes further validation of configuration, such as the
39
+ git reference, it's resolved SHA (if resolveable), and whether the deployment file is valid.
40
+ """
41
+
42
+ is_valid: bool
43
+ error_message: str | None = None
44
+ git_ref: str | None = None
45
+ git_sha: str | None = None
46
+ valid_deployment_file_path: str | None = None
@@ -0,0 +1,14 @@
1
+ from .base import Base
2
+
3
+
4
+ class ProjectSummary(Base):
5
+ """Summary of a project with deployment count"""
6
+
7
+ project_id: str
8
+ deployment_count: int
9
+
10
+
11
+ class ProjectsListResponse(Base):
12
+ """Response model for listing projects with deployment counts"""
13
+
14
+ projects: list[ProjectSummary]