flowyml 1.7.2__py3-none-any.whl → 1.8.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.
Files changed (126) hide show
  1. flowyml/assets/base.py +15 -0
  2. flowyml/assets/metrics.py +5 -0
  3. flowyml/cli/main.py +709 -0
  4. flowyml/cli/stack_cli.py +138 -25
  5. flowyml/core/__init__.py +17 -0
  6. flowyml/core/executor.py +161 -26
  7. flowyml/core/image_builder.py +129 -0
  8. flowyml/core/log_streamer.py +227 -0
  9. flowyml/core/orchestrator.py +22 -2
  10. flowyml/core/pipeline.py +34 -10
  11. flowyml/core/routing.py +558 -0
  12. flowyml/core/step.py +9 -1
  13. flowyml/core/step_grouping.py +49 -35
  14. flowyml/core/types.py +407 -0
  15. flowyml/monitoring/alerts.py +10 -0
  16. flowyml/monitoring/notifications.py +104 -25
  17. flowyml/monitoring/slack_blocks.py +323 -0
  18. flowyml/plugins/__init__.py +251 -0
  19. flowyml/plugins/alerters/__init__.py +1 -0
  20. flowyml/plugins/alerters/slack.py +168 -0
  21. flowyml/plugins/base.py +752 -0
  22. flowyml/plugins/config.py +478 -0
  23. flowyml/plugins/deployers/__init__.py +22 -0
  24. flowyml/plugins/deployers/gcp_cloud_run.py +200 -0
  25. flowyml/plugins/deployers/sagemaker.py +306 -0
  26. flowyml/plugins/deployers/vertex.py +290 -0
  27. flowyml/plugins/integration.py +369 -0
  28. flowyml/plugins/manager.py +510 -0
  29. flowyml/plugins/model_registries/__init__.py +22 -0
  30. flowyml/plugins/model_registries/mlflow.py +159 -0
  31. flowyml/plugins/model_registries/sagemaker.py +489 -0
  32. flowyml/plugins/model_registries/vertex.py +386 -0
  33. flowyml/plugins/orchestrators/__init__.py +13 -0
  34. flowyml/plugins/orchestrators/sagemaker.py +443 -0
  35. flowyml/plugins/orchestrators/vertex_ai.py +461 -0
  36. flowyml/plugins/registries/__init__.py +13 -0
  37. flowyml/plugins/registries/ecr.py +321 -0
  38. flowyml/plugins/registries/gcr.py +313 -0
  39. flowyml/plugins/registry.py +454 -0
  40. flowyml/plugins/stack.py +494 -0
  41. flowyml/plugins/stack_config.py +537 -0
  42. flowyml/plugins/stores/__init__.py +13 -0
  43. flowyml/plugins/stores/gcs.py +460 -0
  44. flowyml/plugins/stores/s3.py +453 -0
  45. flowyml/plugins/trackers/__init__.py +11 -0
  46. flowyml/plugins/trackers/mlflow.py +316 -0
  47. flowyml/plugins/validators/__init__.py +3 -0
  48. flowyml/plugins/validators/deepchecks.py +119 -0
  49. flowyml/registry/__init__.py +2 -1
  50. flowyml/registry/model_environment.py +109 -0
  51. flowyml/registry/model_registry.py +241 -96
  52. flowyml/serving/__init__.py +17 -0
  53. flowyml/serving/model_server.py +628 -0
  54. flowyml/stacks/__init__.py +60 -0
  55. flowyml/stacks/aws.py +93 -0
  56. flowyml/stacks/base.py +62 -0
  57. flowyml/stacks/components.py +12 -0
  58. flowyml/stacks/gcp.py +44 -9
  59. flowyml/stacks/plugins.py +115 -0
  60. flowyml/stacks/registry.py +2 -1
  61. flowyml/storage/sql.py +401 -12
  62. flowyml/tracking/experiment.py +8 -5
  63. flowyml/ui/backend/Dockerfile +87 -16
  64. flowyml/ui/backend/auth.py +12 -2
  65. flowyml/ui/backend/main.py +149 -5
  66. flowyml/ui/backend/routers/ai_context.py +226 -0
  67. flowyml/ui/backend/routers/assets.py +23 -4
  68. flowyml/ui/backend/routers/auth.py +96 -0
  69. flowyml/ui/backend/routers/deployments.py +660 -0
  70. flowyml/ui/backend/routers/model_explorer.py +597 -0
  71. flowyml/ui/backend/routers/plugins.py +103 -51
  72. flowyml/ui/backend/routers/projects.py +91 -8
  73. flowyml/ui/backend/routers/runs.py +20 -1
  74. flowyml/ui/backend/routers/schedules.py +22 -17
  75. flowyml/ui/backend/routers/templates.py +319 -0
  76. flowyml/ui/backend/routers/websocket.py +2 -2
  77. flowyml/ui/frontend/Dockerfile +55 -6
  78. flowyml/ui/frontend/dist/assets/index-B5AsPTSz.css +1 -0
  79. flowyml/ui/frontend/dist/assets/index-dFbZ8wD8.js +753 -0
  80. flowyml/ui/frontend/dist/index.html +2 -2
  81. flowyml/ui/frontend/dist/logo.png +0 -0
  82. flowyml/ui/frontend/nginx.conf +65 -4
  83. flowyml/ui/frontend/package-lock.json +1404 -74
  84. flowyml/ui/frontend/package.json +3 -0
  85. flowyml/ui/frontend/public/logo.png +0 -0
  86. flowyml/ui/frontend/src/App.jsx +10 -7
  87. flowyml/ui/frontend/src/app/auth/Login.jsx +90 -0
  88. flowyml/ui/frontend/src/app/dashboard/page.jsx +8 -8
  89. flowyml/ui/frontend/src/app/deployments/page.jsx +786 -0
  90. flowyml/ui/frontend/src/app/model-explorer/page.jsx +1031 -0
  91. flowyml/ui/frontend/src/app/pipelines/page.jsx +12 -2
  92. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +19 -6
  93. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +36 -24
  94. flowyml/ui/frontend/src/app/runs/page.jsx +8 -2
  95. flowyml/ui/frontend/src/app/settings/page.jsx +267 -253
  96. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +29 -7
  97. flowyml/ui/frontend/src/components/Layout.jsx +6 -0
  98. flowyml/ui/frontend/src/components/PipelineGraph.jsx +79 -29
  99. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +36 -6
  100. flowyml/ui/frontend/src/components/RunMetaPanel.jsx +113 -0
  101. flowyml/ui/frontend/src/components/ai/AIAssistantButton.jsx +71 -0
  102. flowyml/ui/frontend/src/components/ai/AIAssistantPanel.jsx +420 -0
  103. flowyml/ui/frontend/src/components/header/Header.jsx +22 -0
  104. flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +4 -4
  105. flowyml/ui/frontend/src/components/plugins/{ZenMLIntegration.jsx → StackImport.jsx} +38 -12
  106. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +36 -13
  107. flowyml/ui/frontend/src/contexts/AIAssistantContext.jsx +245 -0
  108. flowyml/ui/frontend/src/contexts/AuthContext.jsx +108 -0
  109. flowyml/ui/frontend/src/hooks/useAIContext.js +156 -0
  110. flowyml/ui/frontend/src/hooks/useWebGPU.js +54 -0
  111. flowyml/ui/frontend/src/layouts/MainLayout.jsx +6 -0
  112. flowyml/ui/frontend/src/router/index.jsx +47 -20
  113. flowyml/ui/frontend/src/services/pluginService.js +3 -1
  114. flowyml/ui/server_manager.py +5 -5
  115. flowyml/ui/utils.py +157 -39
  116. flowyml/utils/config.py +37 -15
  117. flowyml/utils/model_introspection.py +123 -0
  118. flowyml/utils/observability.py +30 -0
  119. flowyml-1.8.0.dist-info/METADATA +174 -0
  120. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/RECORD +123 -65
  121. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
  122. flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +0 -1
  123. flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +0 -685
  124. flowyml-1.7.2.dist-info/METADATA +0 -477
  125. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
  126. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,321 @@
1
+ """AWS ECR Container Registry - Native FlowyML Plugin.
2
+
3
+ This is a native FlowyML implementation for AWS Elastic Container Registry,
4
+ without requiring any external framework dependencies.
5
+
6
+ Usage:
7
+ from flowyml.plugins import get_plugin
8
+
9
+ registry = get_plugin("ecr",
10
+ repository="my-ml-images",
11
+ region="us-east-1"
12
+ )
13
+
14
+ # Push an image
15
+ uri = registry.push_image("ml-training", tag="v1.0")
16
+ """
17
+
18
+ import subprocess
19
+ import logging
20
+
21
+ from flowyml.plugins.base import ContainerRegistryPlugin, PluginMetadata, PluginType
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ECRRegistry(ContainerRegistryPlugin):
27
+ """Native AWS ECR container registry for FlowyML.
28
+
29
+ This registry integrates directly with AWS ECR without any
30
+ intermediate framework.
31
+
32
+ Args:
33
+ repository: ECR repository name.
34
+ region: AWS region.
35
+ account_id: AWS account ID (auto-detected if not provided).
36
+ access_key: AWS access key (uses environment/credentials if not provided).
37
+ secret_key: AWS secret key (uses environment/credentials if not provided).
38
+
39
+ Example:
40
+ registry = ECRRegistry(
41
+ repository="ml-images",
42
+ region="us-east-1"
43
+ )
44
+
45
+ uri = registry.push_image("classifier", tag="v1.0")
46
+ """
47
+
48
+ METADATA = PluginMetadata(
49
+ name="ecr",
50
+ description="AWS Elastic Container Registry",
51
+ plugin_type=PluginType.CONTAINER_REGISTRY,
52
+ version="1.0.0",
53
+ author="FlowyML",
54
+ packages=["boto3>=1.28"],
55
+ documentation_url="https://docs.aws.amazon.com/ecr/",
56
+ tags=["container-registry", "aws", "cloud"],
57
+ )
58
+
59
+ def __init__(
60
+ self,
61
+ repository: str,
62
+ region: str = None,
63
+ account_id: str = None,
64
+ access_key: str = None,
65
+ secret_key: str = None,
66
+ **kwargs,
67
+ ):
68
+ """Initialize the ECR registry."""
69
+ super().__init__(
70
+ name=kwargs.pop("name", "ecr"),
71
+ repository=repository,
72
+ region=region,
73
+ account_id=account_id,
74
+ access_key=access_key,
75
+ secret_key=secret_key,
76
+ **kwargs,
77
+ )
78
+
79
+ self._repository = repository
80
+ self._region = region
81
+ self._account_id = account_id
82
+ self._ecr_client = None
83
+
84
+ def initialize(self) -> None:
85
+ """Initialize ECR connection."""
86
+ try:
87
+ import boto3
88
+
89
+ # Build client kwargs
90
+ client_kwargs = {}
91
+
92
+ if self._region:
93
+ client_kwargs["region_name"] = self._region
94
+
95
+ if self._config.get("access_key") and self._config.get("secret_key"):
96
+ client_kwargs["aws_access_key_id"] = self._config["access_key"]
97
+ client_kwargs["aws_secret_access_key"] = self._config["secret_key"]
98
+
99
+ self._ecr_client = boto3.client("ecr", **client_kwargs)
100
+
101
+ # Auto-detect account ID if not provided
102
+ if not self._account_id:
103
+ sts = boto3.client("sts", **client_kwargs)
104
+ self._account_id = sts.get_caller_identity()["Account"]
105
+
106
+ # Get Docker login credentials
107
+ self._authenticate_docker()
108
+
109
+ self._is_initialized = True
110
+ logger.info(f"ECR registry initialized: {self.registry_uri}")
111
+
112
+ except ImportError:
113
+ raise ImportError(
114
+ "boto3 is not installed. Run: flowyml plugin install ecr",
115
+ )
116
+
117
+ def _authenticate_docker(self) -> None:
118
+ """Authenticate Docker with ECR."""
119
+ try:
120
+ # Get authorization token
121
+ response = self._ecr_client.get_authorization_token()
122
+ auth_data = response["authorizationData"][0]
123
+
124
+ # Decode credentials
125
+ import base64
126
+
127
+ token = base64.b64decode(auth_data["authorizationToken"]).decode()
128
+ username, password = token.split(":")
129
+ registry_url = auth_data["proxyEndpoint"]
130
+
131
+ # Login to Docker
132
+ result = subprocess.run(
133
+ ["docker", "login", "--username", username, "--password-stdin", registry_url],
134
+ input=password.encode(),
135
+ capture_output=True,
136
+ )
137
+
138
+ if result.returncode != 0:
139
+ logger.warning(f"Docker login may have failed: {result.stderr.decode()}")
140
+ else:
141
+ logger.debug("Docker authenticated with ECR")
142
+
143
+ except Exception as e:
144
+ logger.warning(f"Failed to authenticate Docker with ECR: {e}")
145
+
146
+ def _ensure_initialized(self) -> None:
147
+ """Ensure the registry is initialized."""
148
+ if not self._is_initialized:
149
+ self.initialize()
150
+
151
+ @property
152
+ def registry_uri(self) -> str:
153
+ """Get the base registry URI."""
154
+ return f"{self._account_id}.dkr.ecr.{self._region}.amazonaws.com/{self._repository}"
155
+
156
+ def get_image_uri(self, image_name: str, tag: str = "latest") -> str:
157
+ """Get the full URI for an image.
158
+
159
+ Args:
160
+ image_name: Name of the image.
161
+ tag: Image tag.
162
+
163
+ Returns:
164
+ Full image URI.
165
+ """
166
+ return f"{self._account_id}.dkr.ecr.{self._region}.amazonaws.com/{self._repository}/{image_name}:{tag}"
167
+
168
+ def push_image(self, image_name: str, tag: str = "latest", local_image: str = None) -> str:
169
+ """Push an image to ECR.
170
+
171
+ Args:
172
+ image_name: Name for the image in the registry.
173
+ tag: Image tag.
174
+ local_image: Local image name to push.
175
+
176
+ Returns:
177
+ Full image URI.
178
+ """
179
+ self._ensure_initialized()
180
+
181
+ remote_uri = self.get_image_uri(image_name, tag)
182
+
183
+ try:
184
+ import docker
185
+
186
+ client = docker.from_env()
187
+
188
+ if local_image:
189
+ # Tag the local image with the remote URI
190
+ image = client.images.get(local_image)
191
+ image.tag(remote_uri)
192
+
193
+ # Push the image
194
+ logger.info(f"Pushing image to {remote_uri}...")
195
+
196
+ for line in client.images.push(remote_uri, stream=True, decode=True):
197
+ if "status" in line:
198
+ logger.debug(line["status"])
199
+ if "error" in line:
200
+ raise RuntimeError(line["error"])
201
+
202
+ logger.info(f"Successfully pushed {remote_uri}")
203
+ return remote_uri
204
+
205
+ except ImportError:
206
+ # Fall back to Docker CLI
207
+ logger.info("docker-py not available, using Docker CLI")
208
+
209
+ if local_image:
210
+ subprocess.run(
211
+ ["docker", "tag", local_image, remote_uri],
212
+ check=True,
213
+ )
214
+
215
+ result = subprocess.run(
216
+ ["docker", "push", remote_uri],
217
+ capture_output=True,
218
+ text=True,
219
+ )
220
+
221
+ if result.returncode != 0:
222
+ raise RuntimeError(f"Failed to push image: {result.stderr}")
223
+
224
+ logger.info(f"Successfully pushed {remote_uri}")
225
+ return remote_uri
226
+
227
+ def pull_image(self, image_name: str, tag: str = "latest") -> None:
228
+ """Pull an image from ECR."""
229
+ self._ensure_initialized()
230
+
231
+ remote_uri = self.get_image_uri(image_name, tag)
232
+
233
+ try:
234
+ import docker
235
+
236
+ client = docker.from_env()
237
+
238
+ logger.info(f"Pulling image {remote_uri}...")
239
+ client.images.pull(remote_uri)
240
+ logger.info(f"Successfully pulled {remote_uri}")
241
+
242
+ except ImportError:
243
+ result = subprocess.run(
244
+ ["docker", "pull", remote_uri],
245
+ capture_output=True,
246
+ text=True,
247
+ )
248
+
249
+ if result.returncode != 0:
250
+ raise RuntimeError(f"Failed to pull image: {result.stderr}")
251
+
252
+ logger.info(f"Successfully pulled {remote_uri}")
253
+
254
+ def list_images(self) -> list[str]:
255
+ """List images in the repository."""
256
+ self._ensure_initialized()
257
+
258
+ try:
259
+ response = self._ecr_client.list_images(
260
+ repositoryName=self._repository,
261
+ )
262
+
263
+ images = []
264
+ for image_id in response.get("imageIds", []):
265
+ if "imageTag" in image_id:
266
+ images.append(image_id["imageTag"])
267
+
268
+ return images
269
+
270
+ except Exception as e:
271
+ logger.error(f"Failed to list images: {e}")
272
+ return []
273
+
274
+ def delete_image(self, image_name: str, tag: str = None, digest: str = None) -> bool:
275
+ """Delete an image from ECR."""
276
+ self._ensure_initialized()
277
+
278
+ try:
279
+ image_ids = []
280
+ if tag:
281
+ image_ids.append({"imageTag": tag})
282
+ elif digest:
283
+ image_ids.append({"imageDigest": digest})
284
+ else:
285
+ raise ValueError("Either tag or digest must be provided")
286
+
287
+ self._ecr_client.batch_delete_image(
288
+ repositoryName=self._repository,
289
+ imageIds=image_ids,
290
+ )
291
+
292
+ logger.info(f"Deleted image {image_name}:{tag or digest}")
293
+ return True
294
+
295
+ except Exception as e:
296
+ logger.error(f"Failed to delete image: {e}")
297
+ return False
298
+
299
+ def create_repository(self, repository_name: str = None) -> bool:
300
+ """Create an ECR repository if it doesn't exist.
301
+
302
+ Args:
303
+ repository_name: Repository name. Uses configured name if not provided.
304
+
305
+ Returns:
306
+ True if created or already exists.
307
+ """
308
+ self._ensure_initialized()
309
+
310
+ repo_name = repository_name or self._repository
311
+
312
+ try:
313
+ self._ecr_client.create_repository(repositoryName=repo_name)
314
+ logger.info(f"Created ECR repository: {repo_name}")
315
+ return True
316
+ except self._ecr_client.exceptions.RepositoryAlreadyExistsException:
317
+ logger.debug(f"Repository already exists: {repo_name}")
318
+ return True
319
+ except Exception as e:
320
+ logger.error(f"Failed to create repository: {e}")
321
+ return False
@@ -0,0 +1,313 @@
1
+ """GCR/Artifact Registry - Native FlowyML Plugin.
2
+
3
+ This is a native FlowyML implementation for Google Container Registry
4
+ and Google Artifact Registry, without requiring any external framework.
5
+
6
+ Usage:
7
+ from flowyml.plugins import get_plugin
8
+
9
+ registry = get_plugin("gcr",
10
+ project="my-gcp-project",
11
+ location="us-central1"
12
+ )
13
+
14
+ # Push an image
15
+ uri = registry.push_image("my-ml-image", tag="v1.0")
16
+
17
+ # Get image URI
18
+ uri = registry.get_image_uri("my-ml-image", tag="latest")
19
+ """
20
+
21
+ import subprocess
22
+ import logging
23
+
24
+ from flowyml.plugins.base import ContainerRegistryPlugin, PluginMetadata, PluginType
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class GCRRegistry(ContainerRegistryPlugin):
30
+ """Native Google Container Registry / Artifact Registry for FlowyML.
31
+
32
+ This registry integrates directly with GCP container services
33
+ without any intermediate framework.
34
+
35
+ Supports both:
36
+ - Google Container Registry (gcr.io)
37
+ - Google Artifact Registry (recommended for new projects)
38
+
39
+ Args:
40
+ project: GCP project ID.
41
+ location: Region for Artifact Registry (e.g., "us-central1").
42
+ If not provided, uses gcr.io.
43
+ repository: Artifact Registry repository name (only for AR).
44
+ use_artifact_registry: If True, use Artifact Registry.
45
+ If False, use classic GCR.
46
+
47
+ Example:
48
+ # Using Artifact Registry (recommended)
49
+ registry = GCRRegistry(
50
+ project="my-gcp-project",
51
+ location="us-central1",
52
+ repository="ml-images",
53
+ use_artifact_registry=True
54
+ )
55
+
56
+ # Using classic GCR
57
+ registry = GCRRegistry(
58
+ project="my-gcp-project",
59
+ use_artifact_registry=False
60
+ )
61
+ """
62
+
63
+ METADATA = PluginMetadata(
64
+ name="gcr",
65
+ description="Google Container Registry / Artifact Registry",
66
+ plugin_type=PluginType.CONTAINER_REGISTRY,
67
+ version="1.0.0",
68
+ author="FlowyML",
69
+ packages=["google-cloud-artifact-registry>=1.0"],
70
+ documentation_url="https://cloud.google.com/artifact-registry/docs",
71
+ tags=["container-registry", "gcp", "cloud"],
72
+ )
73
+
74
+ def __init__(
75
+ self,
76
+ project: str,
77
+ location: str = None,
78
+ repository: str = None,
79
+ use_artifact_registry: bool = True,
80
+ **kwargs,
81
+ ):
82
+ """Initialize the GCR registry."""
83
+ super().__init__(
84
+ name=kwargs.pop("name", "gcr"),
85
+ project=project,
86
+ location=location,
87
+ repository=repository,
88
+ use_artifact_registry=use_artifact_registry,
89
+ **kwargs,
90
+ )
91
+
92
+ self._project = project
93
+ self._location = location
94
+ self._repository = repository
95
+ self._use_ar = use_artifact_registry
96
+
97
+ def initialize(self) -> None:
98
+ """Initialize GCR/Artifact Registry connection."""
99
+ # Configure Docker to authenticate with GCR
100
+ try:
101
+ if self._use_ar:
102
+ # Artifact Registry authentication
103
+ result = subprocess.run(
104
+ ["gcloud", "auth", "configure-docker", f"{self._location}-docker.pkg.dev", "--quiet"],
105
+ capture_output=True,
106
+ text=True,
107
+ )
108
+ else:
109
+ # Classic GCR authentication
110
+ result = subprocess.run(
111
+ ["gcloud", "auth", "configure-docker", "--quiet"],
112
+ capture_output=True,
113
+ text=True,
114
+ )
115
+
116
+ if result.returncode != 0:
117
+ logger.warning(f"Docker auth may not be configured: {result.stderr}")
118
+
119
+ self._is_initialized = True
120
+ logger.info(f"GCR registry initialized for project: {self._project}")
121
+
122
+ except FileNotFoundError:
123
+ logger.warning("gcloud CLI not found. Manual Docker authentication may be required.")
124
+ self._is_initialized = True
125
+
126
+ def _ensure_initialized(self) -> None:
127
+ """Ensure the registry is initialized."""
128
+ if not self._is_initialized:
129
+ self.initialize()
130
+
131
+ @property
132
+ def registry_uri(self) -> str:
133
+ """Get the base registry URI."""
134
+ if self._use_ar:
135
+ return f"{self._location}-docker.pkg.dev/{self._project}/{self._repository}"
136
+ else:
137
+ return f"gcr.io/{self._project}"
138
+
139
+ def get_image_uri(self, image_name: str, tag: str = "latest") -> str:
140
+ """Get the full URI for an image.
141
+
142
+ Args:
143
+ image_name: Name of the image.
144
+ tag: Image tag.
145
+
146
+ Returns:
147
+ Full image URI.
148
+ """
149
+ return f"{self.registry_uri}/{image_name}:{tag}"
150
+
151
+ def push_image(self, image_name: str, tag: str = "latest", local_image: str = None) -> str:
152
+ """Push an image to the registry.
153
+
154
+ Args:
155
+ image_name: Name for the image in the registry.
156
+ tag: Image tag.
157
+ local_image: Local image name to push. If not provided,
158
+ assumes the image is already tagged correctly.
159
+
160
+ Returns:
161
+ Full image URI.
162
+ """
163
+ self._ensure_initialized()
164
+
165
+ remote_uri = self.get_image_uri(image_name, tag)
166
+
167
+ try:
168
+ import docker
169
+
170
+ client = docker.from_env()
171
+
172
+ if local_image:
173
+ # Tag the local image with the remote URI
174
+ image = client.images.get(local_image)
175
+ image.tag(remote_uri)
176
+
177
+ # Push the image
178
+ logger.info(f"Pushing image to {remote_uri}...")
179
+
180
+ for line in client.images.push(remote_uri, stream=True, decode=True):
181
+ if "status" in line:
182
+ logger.debug(line["status"])
183
+ if "error" in line:
184
+ raise RuntimeError(line["error"])
185
+
186
+ logger.info(f"Successfully pushed {remote_uri}")
187
+ return remote_uri
188
+
189
+ except ImportError:
190
+ # Fall back to Docker CLI
191
+ logger.info("docker-py not available, using Docker CLI")
192
+
193
+ if local_image:
194
+ subprocess.run(
195
+ ["docker", "tag", local_image, remote_uri],
196
+ check=True,
197
+ )
198
+
199
+ result = subprocess.run(
200
+ ["docker", "push", remote_uri],
201
+ capture_output=True,
202
+ text=True,
203
+ )
204
+
205
+ if result.returncode != 0:
206
+ raise RuntimeError(f"Failed to push image: {result.stderr}")
207
+
208
+ logger.info(f"Successfully pushed {remote_uri}")
209
+ return remote_uri
210
+
211
+ def pull_image(self, image_name: str, tag: str = "latest") -> None:
212
+ """Pull an image from the registry.
213
+
214
+ Args:
215
+ image_name: Name of the image.
216
+ tag: Image tag.
217
+ """
218
+ self._ensure_initialized()
219
+
220
+ remote_uri = self.get_image_uri(image_name, tag)
221
+
222
+ try:
223
+ import docker
224
+
225
+ client = docker.from_env()
226
+
227
+ logger.info(f"Pulling image {remote_uri}...")
228
+ client.images.pull(remote_uri)
229
+ logger.info(f"Successfully pulled {remote_uri}")
230
+
231
+ except ImportError:
232
+ # Fall back to Docker CLI
233
+ result = subprocess.run(
234
+ ["docker", "pull", remote_uri],
235
+ capture_output=True,
236
+ text=True,
237
+ )
238
+
239
+ if result.returncode != 0:
240
+ raise RuntimeError(f"Failed to pull image: {result.stderr}")
241
+
242
+ logger.info(f"Successfully pulled {remote_uri}")
243
+
244
+ def list_images(self) -> list[str]:
245
+ """List images in the registry.
246
+
247
+ Returns:
248
+ List of image names.
249
+ """
250
+ self._ensure_initialized()
251
+
252
+ if not self._use_ar:
253
+ logger.warning("Image listing requires Artifact Registry")
254
+ return []
255
+
256
+ try:
257
+ from google.cloud import artifactregistry_v1
258
+
259
+ client = artifactregistry_v1.ArtifactRegistryClient()
260
+
261
+ parent = f"projects/{self._project}/locations/{self._location}/repositories/{self._repository}"
262
+
263
+ images = []
264
+ for image in client.list_docker_images(parent=parent):
265
+ # Extract image name from full path
266
+ name = image.name.split("/")[-1]
267
+ images.append(name)
268
+
269
+ return images
270
+
271
+ except ImportError:
272
+ logger.warning("google-cloud-artifact-registry not installed")
273
+ return []
274
+ except Exception as e:
275
+ logger.error(f"Failed to list images: {e}")
276
+ return []
277
+
278
+ def delete_image(self, image_name: str, tag: str = None, digest: str = None) -> bool:
279
+ """Delete an image from the registry.
280
+
281
+ Args:
282
+ image_name: Name of the image.
283
+ tag: Image tag to delete.
284
+ digest: Image digest to delete.
285
+
286
+ Returns:
287
+ True if deletion was successful.
288
+ """
289
+ self._ensure_initialized()
290
+
291
+ if not self._use_ar:
292
+ logger.warning("Image deletion via API requires Artifact Registry")
293
+ return False
294
+
295
+ try:
296
+ from google.cloud import artifactregistry_v1
297
+
298
+ client = artifactregistry_v1.ArtifactRegistryClient()
299
+
300
+ if digest:
301
+ name = f"projects/{self._project}/locations/{self._location}/repositories/{self._repository}/dockerImages/{image_name}@{digest}"
302
+ elif tag:
303
+ name = f"projects/{self._project}/locations/{self._location}/repositories/{self._repository}/dockerImages/{image_name}:{tag}"
304
+ else:
305
+ raise ValueError("Either tag or digest must be provided")
306
+
307
+ client.delete_package(name=name)
308
+ logger.info(f"Deleted image {name}")
309
+ return True
310
+
311
+ except Exception as e:
312
+ logger.error(f"Failed to delete image: {e}")
313
+ return False