azureml-registry-tools 0.1.0a1__py3-none-any.whl → 0.1.0a2__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.
- azureml/registry/_cli/registry_syndication_cli.py +230 -0
- azureml/registry/_rest_client/__init__.py +4 -0
- azureml/registry/_rest_client/arm_client.py +133 -0
- azureml/registry/_rest_client/base_rest_client.py +138 -0
- azureml/registry/_rest_client/registry_management_client.py +114 -0
- azureml/registry/mgmt/__init__.py +4 -0
- azureml/registry/mgmt/create_manifest.py +235 -0
- azureml/registry/mgmt/syndication_manifest.py +150 -0
- {azureml_registry_tools-0.1.0a1.dist-info → azureml_registry_tools-0.1.0a2.dist-info}/METADATA +4 -2
- azureml_registry_tools-0.1.0a2.dist-info/RECORD +23 -0
- {azureml_registry_tools-0.1.0a1.dist-info → azureml_registry_tools-0.1.0a2.dist-info}/WHEEL +1 -1
- {azureml_registry_tools-0.1.0a1.dist-info → azureml_registry_tools-0.1.0a2.dist-info}/entry_points.txt +1 -0
- azureml_registry_tools-0.1.0a1.dist-info/RECORD +0 -15
- {azureml_registry_tools-0.1.0a1.dist-info → azureml_registry_tools-0.1.0a2.dist-info}/licenses/LICENSE.txt +0 -0
- {azureml_registry_tools-0.1.0a1.dist-info → azureml_registry_tools-0.1.0a2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,230 @@
|
|
1
|
+
# ---------------------------------------------------------
|
2
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
3
|
+
# ---------------------------------------------------------
|
4
|
+
import argparse
|
5
|
+
import json
|
6
|
+
from azureml.registry._rest_client.registry_management_client import RegistryManagementClient
|
7
|
+
from azureml.registry._rest_client.arm_client import ArmClient
|
8
|
+
from azureml.registry.mgmt.create_manifest import generate_syndication_manifest
|
9
|
+
from azureml.registry.mgmt.syndication_manifest import SyndicationManifest
|
10
|
+
|
11
|
+
|
12
|
+
def syndication_manifest_show(registry_name: str) -> dict:
|
13
|
+
"""Show the current syndication manifest for the specified registry and region.
|
14
|
+
|
15
|
+
Args:
|
16
|
+
registry_name (str): Name of the AzureML registry.
|
17
|
+
|
18
|
+
Returns:
|
19
|
+
dict: The manifest data.
|
20
|
+
"""
|
21
|
+
return json.dumps(RegistryManagementClient(registry_name=registry_name).get_manifest())
|
22
|
+
|
23
|
+
|
24
|
+
def syndication_manifest_set(manifest_value: str, folder: str, dry_run: bool) -> None:
|
25
|
+
"""Set the syndication manifest for the specified registry and region.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
manifest_value (str): Manifest value as a string (JSON or similar).
|
29
|
+
folder (str): Path to the manifest folder.
|
30
|
+
dry_run (bool): If True, do not perform any changes.
|
31
|
+
"""
|
32
|
+
if manifest_value is not None:
|
33
|
+
# for inline manifest value allow different casing of keys
|
34
|
+
manifest = SyndicationManifest.from_dto(json.loads(manifest_value), normalize_keys=True)
|
35
|
+
else:
|
36
|
+
# Folder structure should have proper casing
|
37
|
+
manifest = generate_syndication_manifest(folder)
|
38
|
+
dto = manifest.to_dto()
|
39
|
+
if dry_run:
|
40
|
+
print(f"Dry run: Would set manifest to {dto}")
|
41
|
+
else:
|
42
|
+
client = RegistryManagementClient(registry_name=manifest.registry_name)
|
43
|
+
client.create_or_update_manifest(dto)
|
44
|
+
|
45
|
+
|
46
|
+
def syndication_manifest_delete(registry_name: str, dry_run: bool) -> None:
|
47
|
+
"""Delete the syndication manifest for the specified registry.
|
48
|
+
|
49
|
+
Args:
|
50
|
+
registry_name (str): Name of the AzureML registry.
|
51
|
+
dry_run (bool): If True, do not perform any changes.
|
52
|
+
"""
|
53
|
+
if dry_run:
|
54
|
+
print(f"Dry run: Would delete manifest for registry {registry_name}")
|
55
|
+
else:
|
56
|
+
RegistryManagementClient(registry_name=registry_name).delete_manifest()
|
57
|
+
|
58
|
+
|
59
|
+
def syndication_target_show(registry_name: str) -> object:
|
60
|
+
"""Show the current syndication target(s) for the specified registry and region.
|
61
|
+
|
62
|
+
Args:
|
63
|
+
registry_name (str): Name of the AzureML registry.
|
64
|
+
|
65
|
+
Returns:
|
66
|
+
list or str: List of syndicated registries or 'None'.
|
67
|
+
"""
|
68
|
+
discovery = RegistryManagementClient(registry_name=registry_name).discovery()
|
69
|
+
arm_resource_id = f"/subscriptions/{discovery.get('subscriptionId')}/resourceGroups/{discovery.get('resourceGroup')}/providers/Microsoft.MachineLearningServices/registries/{discovery.get('registryName')}"
|
70
|
+
return ArmClient().get_resource(resource_id=arm_resource_id).get("properties", {}).get("syndicatedRegistries", "None")
|
71
|
+
|
72
|
+
|
73
|
+
def syndication_target_set(registry_name: str, registry_ids: list, dry_run: bool) -> None:
|
74
|
+
"""Set the syndication target(s) for the specified registry and region.
|
75
|
+
|
76
|
+
Args:
|
77
|
+
registry_name (str): Name of the AzureML registry.
|
78
|
+
registry_ids (list): List of registry IDs to set as syndicated targets.
|
79
|
+
dry_run (bool): If True, do not perform any changes.
|
80
|
+
"""
|
81
|
+
discovery = RegistryManagementClient(registry_name=registry_name).discovery()
|
82
|
+
arm_resource_id = f"/subscriptions/{discovery.get('subscriptionId')}/resourceGroups/{discovery.get('resourceGroup')}/providers/Microsoft.MachineLearningServices/registries/{discovery.get('registryName')}"
|
83
|
+
arm_client = ArmClient()
|
84
|
+
resource = arm_client.get_resource(resource_id=arm_resource_id)
|
85
|
+
resource["properties"]["syndicatedRegistries"] = registry_ids
|
86
|
+
if dry_run:
|
87
|
+
print(f"Dry run: Would set {registry_ids} as SyndicatedRegistries for {registry_name}")
|
88
|
+
else:
|
89
|
+
arm_client.put_resource(resource_id=arm_resource_id, put_body=resource)
|
90
|
+
|
91
|
+
|
92
|
+
def show_command(registry_name: str, as_arm_object: bool) -> object:
|
93
|
+
"""Show registry discovery info or ARM object for the specified registry and region.
|
94
|
+
|
95
|
+
Args:
|
96
|
+
registry_name (str): Name of the AzureML registry.
|
97
|
+
as_arm_object (bool): If True, show as ARM object.
|
98
|
+
|
99
|
+
Returns:
|
100
|
+
dict: Discovery info or ARM resource object.
|
101
|
+
"""
|
102
|
+
discovery = RegistryManagementClient(registry_name=registry_name).discovery()
|
103
|
+
if as_arm_object:
|
104
|
+
arm_resource_id = f"/subscriptions/{discovery.get('subscriptionId')}/resourceGroups/{discovery.get('resourceGroup')}/providers/Microsoft.MachineLearningServices/registries/{discovery.get('registryName')}"
|
105
|
+
return ArmClient().get_resource(resource_id=arm_resource_id)
|
106
|
+
return discovery
|
107
|
+
|
108
|
+
|
109
|
+
def _add_common_args(p, arg_dicts=None):
|
110
|
+
if arg_dicts is None:
|
111
|
+
arg_dicts = []
|
112
|
+
for arg in arg_dicts:
|
113
|
+
p.add_argument(*arg["args"], **arg["kwargs"])
|
114
|
+
|
115
|
+
|
116
|
+
def main() -> None:
|
117
|
+
"""Azureml Registry Syndication CLI Extension.
|
118
|
+
|
119
|
+
Examples:
|
120
|
+
# Show the current manifest
|
121
|
+
registry-mgmt syndication manifest show --registry-name myreg
|
122
|
+
|
123
|
+
# Set manifest from a folder
|
124
|
+
registry-mgmt syndication manifest set --path ./manifest_folder
|
125
|
+
|
126
|
+
# Set manifest from a value
|
127
|
+
registry-mgmt syndication manifest set --value '{"Manifest": "val"}'
|
128
|
+
|
129
|
+
# Delete the current manifest
|
130
|
+
registry-mgmt syndication manifest delete --registry-name myreg
|
131
|
+
|
132
|
+
# Show the current target
|
133
|
+
registry-mgmt syndication target show --registry-name myreg
|
134
|
+
|
135
|
+
# Set target values
|
136
|
+
registry-mgmt syndication target set --registry-name myreg -v reg1Id -v reg2Id
|
137
|
+
|
138
|
+
# Show registry discovery info
|
139
|
+
registry-mgmt show --registry-name myreg
|
140
|
+
|
141
|
+
# Show registry as ARM object
|
142
|
+
registry-mgmt show --registry-name myreg --as-arm-object
|
143
|
+
|
144
|
+
# Dry run for any command
|
145
|
+
registry-mgmt syndication manifest set --registry-name myreg --path ./manifest_folder --dry-run
|
146
|
+
"""
|
147
|
+
parser = argparse.ArgumentParser(prog="registry-mgmt", description="AzureML Registry Syndication CLI Extension")
|
148
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
149
|
+
|
150
|
+
registry_name_arg = {
|
151
|
+
"args": ("-r", "--registry-name"),
|
152
|
+
"kwargs": {
|
153
|
+
"type": str,
|
154
|
+
"required": True,
|
155
|
+
"help": "Name of the AzureML registry."
|
156
|
+
}
|
157
|
+
}
|
158
|
+
|
159
|
+
dry_run_arg = {
|
160
|
+
"args": ("--dry-run",),
|
161
|
+
"kwargs": {
|
162
|
+
"action": "store_true",
|
163
|
+
"help": "Perform a dry run without making changes."
|
164
|
+
}
|
165
|
+
}
|
166
|
+
|
167
|
+
# syndication root command
|
168
|
+
synd_parser = subparsers.add_parser("syndication", help="Syndication operations")
|
169
|
+
synd_subparsers = synd_parser.add_subparsers(dest="synd_subcommand", required=True)
|
170
|
+
|
171
|
+
# syndication manifest
|
172
|
+
manifest_parser = synd_subparsers.add_parser("manifest", help="Manage syndication manifest.")
|
173
|
+
manifest_subparsers = manifest_parser.add_subparsers(dest="manifest_subcommand", required=True)
|
174
|
+
|
175
|
+
manifest_show_parser = manifest_subparsers.add_parser("show", help="Show the current manifest.")
|
176
|
+
_add_common_args(manifest_show_parser, [registry_name_arg, dry_run_arg])
|
177
|
+
|
178
|
+
manifest_set_parser = manifest_subparsers.add_parser("set", help="Set manifest values.")
|
179
|
+
_add_common_args(manifest_set_parser, [dry_run_arg])
|
180
|
+
group = manifest_set_parser.add_mutually_exclusive_group(required=True)
|
181
|
+
group.add_argument("-v", "--value", type=str, help="Manifest value.")
|
182
|
+
group.add_argument("-p", "--path", type=str, help="Path to manifest root folder.")
|
183
|
+
|
184
|
+
manifest_delete_parser = manifest_subparsers.add_parser("delete", help="Delete the current manifest.")
|
185
|
+
_add_common_args(manifest_delete_parser, [registry_name_arg, dry_run_arg])
|
186
|
+
|
187
|
+
# syndication target
|
188
|
+
target_parser = synd_subparsers.add_parser("target", help="Manage syndication target.")
|
189
|
+
target_subparsers = target_parser.add_subparsers(dest="target_subcommand", required=True)
|
190
|
+
|
191
|
+
target_show_parser = target_subparsers.add_parser("show", help="Show the current target.")
|
192
|
+
_add_common_args(target_show_parser, [registry_name_arg, dry_run_arg])
|
193
|
+
|
194
|
+
target_set_parser = target_subparsers.add_parser("set", help="Set target values.")
|
195
|
+
_add_common_args(target_set_parser, [registry_name_arg, dry_run_arg])
|
196
|
+
target_set_parser.add_argument("-v", "--value", type=str, action="append", required=True, help="Target value (can be specified multiple times).")
|
197
|
+
|
198
|
+
# show root command
|
199
|
+
show_parser = subparsers.add_parser("show", help="Show syndication info.")
|
200
|
+
_add_common_args(show_parser, [registry_name_arg, dry_run_arg])
|
201
|
+
show_parser.add_argument("--as-arm-object", action="store_true", help="Show as ARM object.")
|
202
|
+
|
203
|
+
args = parser.parse_args()
|
204
|
+
|
205
|
+
# Command dispatch
|
206
|
+
if args.command == "syndication":
|
207
|
+
if args.synd_subcommand == "manifest":
|
208
|
+
if args.manifest_subcommand == "show":
|
209
|
+
print(syndication_manifest_show(args.registry_name))
|
210
|
+
elif args.manifest_subcommand == "set":
|
211
|
+
print(syndication_manifest_set(args.value, args.path, args.dry_run))
|
212
|
+
elif args.manifest_subcommand == "delete":
|
213
|
+
confirm = input(f"Proceed with manifest deletion for {args.registry_name}? [y/N]: ")
|
214
|
+
if confirm.lower() == "y":
|
215
|
+
syndication_manifest_delete(args.registry_name, args.dry_run)
|
216
|
+
else:
|
217
|
+
print("Manifest deletion cancelled.")
|
218
|
+
elif args.synd_subcommand == "target":
|
219
|
+
if args.target_subcommand == "show":
|
220
|
+
print(syndication_target_show(args.registry_name))
|
221
|
+
elif args.target_subcommand == "set":
|
222
|
+
syndication_target_set(args.registry_name, args.value, args.dry_run)
|
223
|
+
elif args.command == "show":
|
224
|
+
print(show_command(args.registry_name, args.as_arm_object))
|
225
|
+
else:
|
226
|
+
parser.print_help()
|
227
|
+
|
228
|
+
|
229
|
+
if __name__ == "__main__":
|
230
|
+
main()
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# ---------------------------------------------------------
|
2
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
3
|
+
# ---------------------------------------------------------
|
4
|
+
from azure.identity import DefaultAzureCredential
|
5
|
+
from .base_rest_client import BaseRestClient
|
6
|
+
import time
|
7
|
+
from json.decoder import JSONDecodeError
|
8
|
+
|
9
|
+
DEFAULT_API_VERSION = "2025-04-01" # Default API version for Azure Resource Manager
|
10
|
+
|
11
|
+
|
12
|
+
class ArmClient(BaseRestClient):
|
13
|
+
"""Simple Azure Resource Manager (ARM) client leveraging BaseRestClient for GET, PATCH, PUT, and DELETE operations.
|
14
|
+
|
15
|
+
Handles authentication via Bearer token (pass as api_key) and supports standard ARM resource operations.
|
16
|
+
"""
|
17
|
+
|
18
|
+
def __init__(self, api_key=None, max_retries=5, backoff_factor=1):
|
19
|
+
"""
|
20
|
+
Initialize the ArmClient.
|
21
|
+
|
22
|
+
Args:
|
23
|
+
api_key (str, optional): API key or bearer token. If None, uses DefaultAzureCredential.
|
24
|
+
max_retries (int): Maximum number of retries for requests.
|
25
|
+
backoff_factor (int): Backoff factor for retries.
|
26
|
+
"""
|
27
|
+
base_url = "https://management.azure.com"
|
28
|
+
self._credential = None
|
29
|
+
self._token_expires_on = None
|
30
|
+
if api_key is None:
|
31
|
+
# Use DefaultAzureCredential for authentication if no API key is provided
|
32
|
+
self._credential = DefaultAzureCredential()
|
33
|
+
token = self._credential.get_token("https://management.azure.com/.default")
|
34
|
+
api_key = token.token
|
35
|
+
self._token_expires_on = token.expires_on
|
36
|
+
super().__init__(base_url, api_key=api_key, max_retries=max_retries, backoff_factor=backoff_factor)
|
37
|
+
|
38
|
+
def _refresh_api_key_if_needed(self) -> None:
|
39
|
+
"""Refresh the API key if using DefaultAzureCredential and the token is close to expiration."""
|
40
|
+
if self._credential is not None:
|
41
|
+
now = int(time.time())
|
42
|
+
# Refresh if less than 10 minutes (600 seconds) left
|
43
|
+
if not self._token_expires_on or self._token_expires_on - now < 600:
|
44
|
+
token = self._credential.get_token("https://management.azure.com/.default")
|
45
|
+
self.api_key = token.token
|
46
|
+
self.session.headers.update({'Authorization': f'Bearer {self.api_key}'})
|
47
|
+
self._token_expires_on = token.expires_on
|
48
|
+
|
49
|
+
def get_resource(self, resource_id, api_version=DEFAULT_API_VERSION, **kwargs) -> object:
|
50
|
+
"""
|
51
|
+
Get an ARM resource by its resource ID.
|
52
|
+
|
53
|
+
Args:
|
54
|
+
resource_id (str): The ARM resource ID.
|
55
|
+
api_version (str): The API version to use.
|
56
|
+
**kwargs: Additional arguments for the request.
|
57
|
+
|
58
|
+
Returns:
|
59
|
+
object: The resource as a dict if JSON, or text/None otherwise.
|
60
|
+
"""
|
61
|
+
self._refresh_api_key_if_needed()
|
62
|
+
url = f"{self.base_url}{resource_id}?api-version={api_version}"
|
63
|
+
response = self.get(url, **kwargs)
|
64
|
+
try:
|
65
|
+
return response.json()
|
66
|
+
except JSONDecodeError:
|
67
|
+
raise ValueError(f"Failed to decode JSON response from {url}: {response.text.strip()}")
|
68
|
+
|
69
|
+
def patch_resource(self, resource_id, patch_body, api_version=DEFAULT_API_VERSION, **kwargs) -> object:
|
70
|
+
"""
|
71
|
+
Patch an ARM resource.
|
72
|
+
|
73
|
+
Args:
|
74
|
+
resource_id (str): The ARM resource ID.
|
75
|
+
patch_body (dict): The patch body to send.
|
76
|
+
api_version (str): The API version to use.
|
77
|
+
**kwargs: Additional arguments for the request.
|
78
|
+
|
79
|
+
Returns:
|
80
|
+
object: The resource as a dict if JSON, or text/None otherwise.
|
81
|
+
"""
|
82
|
+
self._refresh_api_key_if_needed()
|
83
|
+
url = f"{self.base_url}{resource_id}?api-version={api_version}"
|
84
|
+
headers = kwargs.pop('headers', {})
|
85
|
+
headers['Content-Type'] = 'application/json'
|
86
|
+
response = self.patch(url, json=patch_body, headers=headers, **kwargs)
|
87
|
+
try:
|
88
|
+
return response.json()
|
89
|
+
except JSONDecodeError:
|
90
|
+
return response.text or None
|
91
|
+
|
92
|
+
def put_resource(self, resource_id, put_body, api_version=DEFAULT_API_VERSION, **kwargs) -> object:
|
93
|
+
"""
|
94
|
+
Put (create or update) an ARM resource.
|
95
|
+
|
96
|
+
Args:
|
97
|
+
resource_id (str): The ARM resource ID.
|
98
|
+
put_body (dict): The body to send for the resource.
|
99
|
+
api_version (str): The API version to use.
|
100
|
+
**kwargs: Additional arguments for the request.
|
101
|
+
|
102
|
+
Returns:
|
103
|
+
object: The resource as a dict if JSON, or text/None otherwise.
|
104
|
+
"""
|
105
|
+
self._refresh_api_key_if_needed()
|
106
|
+
url = f"{self.base_url}{resource_id}?api-version={api_version}"
|
107
|
+
headers = kwargs.pop('headers', {})
|
108
|
+
headers['Content-Type'] = 'application/json'
|
109
|
+
response = self.put(url, json=put_body, headers=headers, **kwargs)
|
110
|
+
try:
|
111
|
+
return response.json()
|
112
|
+
except JSONDecodeError:
|
113
|
+
return response.text or None
|
114
|
+
|
115
|
+
def delete_resource(self, resource_id, api_version=DEFAULT_API_VERSION, **kwargs) -> object:
|
116
|
+
"""
|
117
|
+
Delete an ARM resource by its resource ID.
|
118
|
+
|
119
|
+
Args:
|
120
|
+
resource_id (str): The ARM resource ID.
|
121
|
+
api_version (str): The API version to use.
|
122
|
+
**kwargs: Additional arguments for the request.
|
123
|
+
|
124
|
+
Returns:
|
125
|
+
object: The resource as a dict if JSON, or text/None otherwise.
|
126
|
+
"""
|
127
|
+
self._refresh_api_key_if_needed()
|
128
|
+
url = f"{self.base_url}{resource_id}?api-version={api_version}"
|
129
|
+
response = self.delete(url, **kwargs)
|
130
|
+
try:
|
131
|
+
return response.json()
|
132
|
+
except JSONDecodeError:
|
133
|
+
return response.text or None
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# ---------------------------------------------------------
|
2
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
3
|
+
# ---------------------------------------------------------
|
4
|
+
import requests
|
5
|
+
import time
|
6
|
+
import logging
|
7
|
+
|
8
|
+
|
9
|
+
class BaseRestClient:
|
10
|
+
"""Base REST client for making HTTP requests with retry logic and optional API key authentication."""
|
11
|
+
|
12
|
+
def __init__(self, base_url: str, api_key: str = None, max_retries: int = 5, backoff_factor: int = 1) -> None:
|
13
|
+
"""
|
14
|
+
Initialize the BaseRestClient.
|
15
|
+
|
16
|
+
Args:
|
17
|
+
base_url (str): The base URL for the REST API.
|
18
|
+
api_key (str): Bearer token for authentication.
|
19
|
+
max_retries (int): Maximum number of retries for failed requests.
|
20
|
+
backoff_factor (int): Backoff factor for retry delays.
|
21
|
+
"""
|
22
|
+
self.base_url = base_url.rstrip('/')
|
23
|
+
self.session = requests.Session()
|
24
|
+
if api_key:
|
25
|
+
self.session.headers.update({'Authorization': f'Bearer {api_key}'})
|
26
|
+
self.max_retries = max_retries
|
27
|
+
self.backoff_factor = backoff_factor
|
28
|
+
|
29
|
+
def _request_with_retry(self, method: str, url: str, **kwargs) -> requests.Response:
|
30
|
+
"""
|
31
|
+
Make an HTTP request with retry logic for 5xx and 429 errors.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
method (str): HTTP method (GET, POST, etc.).
|
35
|
+
url (str): Full URL for the request.
|
36
|
+
**kwargs: Additional arguments for requests.Session.request.
|
37
|
+
|
38
|
+
Returns:
|
39
|
+
requests.Response: The HTTP response object.
|
40
|
+
"""
|
41
|
+
retries = 0
|
42
|
+
while True:
|
43
|
+
logging.info(f"Attempt {retries + 1}: {method} {url}")
|
44
|
+
response = self.session.request(method, url, **kwargs)
|
45
|
+
if response.status_code < 500 and response.status_code != 429:
|
46
|
+
logging.info(f"Success: {method} {url} (status {response.status_code})")
|
47
|
+
response.raise_for_status()
|
48
|
+
return response
|
49
|
+
# Handle 429 (Too Many Requests)
|
50
|
+
if response.status_code == 429:
|
51
|
+
retry_after = response.headers.get("Retry-After")
|
52
|
+
if retry_after:
|
53
|
+
try:
|
54
|
+
sleep_time = int(retry_after)
|
55
|
+
except ValueError:
|
56
|
+
sleep_time = self.backoff_factor * (2 ** retries)
|
57
|
+
else:
|
58
|
+
sleep_time = self.backoff_factor * (2 ** retries)
|
59
|
+
logging.info(f"Received 429. Retrying after {sleep_time} seconds...")
|
60
|
+
else:
|
61
|
+
# For all other 4xx errors, raise with details
|
62
|
+
error_msg = (
|
63
|
+
f"HTTP {response.status_code} error for {method} {url}: "
|
64
|
+
f"{response.text.strip()}"
|
65
|
+
)
|
66
|
+
logging.error(f"Error: {error_msg}")
|
67
|
+
raise Exception(error_msg)
|
68
|
+
retries += 1
|
69
|
+
if retries > self.max_retries:
|
70
|
+
logging.error(f"Max retries exceeded for {method} {url}")
|
71
|
+
response.raise_for_status()
|
72
|
+
logging.info(f"Sleeping for {sleep_time} seconds before retry...")
|
73
|
+
time.sleep(sleep_time)
|
74
|
+
|
75
|
+
def get(self, url: str, **kwargs) -> requests.Response:
|
76
|
+
"""
|
77
|
+
Make a GET request with retry logic.
|
78
|
+
|
79
|
+
Args:
|
80
|
+
url (str): The URL to request.
|
81
|
+
**kwargs: Additional arguments for requests.Session.request.
|
82
|
+
|
83
|
+
Returns:
|
84
|
+
requests.Response: The HTTP response object.
|
85
|
+
"""
|
86
|
+
return self._request_with_retry('GET', url, **kwargs)
|
87
|
+
|
88
|
+
def post(self, url: str, **kwargs) -> requests.Response:
|
89
|
+
"""
|
90
|
+
Make a POST request with retry logic.
|
91
|
+
|
92
|
+
Args:
|
93
|
+
url (str): The URL to request.
|
94
|
+
**kwargs: Additional arguments for requests.Session.request.
|
95
|
+
|
96
|
+
Returns:
|
97
|
+
requests.Response: The HTTP response object.
|
98
|
+
"""
|
99
|
+
return self._request_with_retry('POST', url, **kwargs)
|
100
|
+
|
101
|
+
def put(self, url: str, **kwargs) -> requests.Response:
|
102
|
+
"""
|
103
|
+
Make a PUT request with retry logic.
|
104
|
+
|
105
|
+
Args:
|
106
|
+
url (str): The URL to request.
|
107
|
+
**kwargs: Additional arguments for requests.Session.request.
|
108
|
+
|
109
|
+
Returns:
|
110
|
+
requests.Response: The HTTP response object.
|
111
|
+
"""
|
112
|
+
return self._request_with_retry('PUT', url, **kwargs)
|
113
|
+
|
114
|
+
def patch(self, url: str, **kwargs) -> requests.Response:
|
115
|
+
"""
|
116
|
+
Make a PATCH request with retry logic.
|
117
|
+
|
118
|
+
Args:
|
119
|
+
url (str): The URL to request.
|
120
|
+
**kwargs: Additional arguments for requests.Session.request.
|
121
|
+
|
122
|
+
Returns:
|
123
|
+
requests.Response: The HTTP response object.
|
124
|
+
"""
|
125
|
+
return self._request_with_retry('PATCH', url, **kwargs)
|
126
|
+
|
127
|
+
def delete(self, url: str, **kwargs) -> requests.Response:
|
128
|
+
"""
|
129
|
+
Make a DELETE request with retry logic.
|
130
|
+
|
131
|
+
Args:
|
132
|
+
url (str): The URL to request.
|
133
|
+
**kwargs: Additional arguments for requests.Session.request.
|
134
|
+
|
135
|
+
Returns:
|
136
|
+
requests.Response: The HTTP response object.
|
137
|
+
"""
|
138
|
+
return self._request_with_retry('DELETE', url, **kwargs)
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# ---------------------------------------------------------
|
2
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
3
|
+
# ---------------------------------------------------------
|
4
|
+
from azure.identity import DefaultAzureCredential
|
5
|
+
from .base_rest_client import BaseRestClient
|
6
|
+
import time
|
7
|
+
|
8
|
+
|
9
|
+
class RegistryManagementClient(BaseRestClient):
|
10
|
+
"""Python client for RegistrySyndicationManifestController (excluding S2S APIs).
|
11
|
+
|
12
|
+
Handles authentication, token refresh, and provides methods for manifest and registry management.
|
13
|
+
"""
|
14
|
+
|
15
|
+
def __init__(self, registry_name: str, primary_region: str = "eastus2euap", api_key: str = None, max_retries: int = 5, backoff_factor: int = 1) -> None:
|
16
|
+
"""
|
17
|
+
Initialize the RegistryManagementClient.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
primary_region (str): The Azure region for the registry.
|
21
|
+
registry_name (str): The name of the AzureML registry.
|
22
|
+
api_key (str, optional): Bearer token for authentication. If None, uses DefaultAzureCredential.
|
23
|
+
max_retries (int): Maximum number of retries for failed requests.
|
24
|
+
backoff_factor (int): Backoff factor for retry delays.
|
25
|
+
"""
|
26
|
+
base_url = f"https://{primary_region}.api.azureml.ms"
|
27
|
+
self._credential = None
|
28
|
+
self._token_expires_on = None
|
29
|
+
if api_key is None:
|
30
|
+
# Use DefaultAzureCredential for authentication if no API key is provided
|
31
|
+
self._credential = DefaultAzureCredential()
|
32
|
+
token = self._credential.get_token("https://management.azure.com/.default")
|
33
|
+
api_key = token.token
|
34
|
+
self._token_expires_on = token.expires_on
|
35
|
+
super().__init__(base_url, api_key=api_key, max_retries=max_retries, backoff_factor=backoff_factor)
|
36
|
+
self.registry_name = registry_name
|
37
|
+
|
38
|
+
def _refresh_api_key_if_needed(self) -> None:
|
39
|
+
"""Refresh the API key if using DefaultAzureCredential and the token is close to expiration."""
|
40
|
+
# Only refresh if using DefaultAzureCredential
|
41
|
+
if self._credential is not None:
|
42
|
+
now = int(time.time())
|
43
|
+
# Refresh if less than 10 minutes (600 seconds) left
|
44
|
+
if not self._token_expires_on or self._token_expires_on - now < 600:
|
45
|
+
token = self._credential.get_token("https://management.azure.com/.default")
|
46
|
+
self.api_key = token.token
|
47
|
+
self.session.headers.update({'Authorization': f'Bearer {self.api_key}'})
|
48
|
+
self._token_expires_on = token.expires_on
|
49
|
+
|
50
|
+
def create_or_update_manifest(self, manifest_dto: dict) -> dict:
|
51
|
+
"""
|
52
|
+
Create or update the syndication manifest for the registry.
|
53
|
+
|
54
|
+
Args:
|
55
|
+
manifest_dto (dict): The manifest data transfer object.
|
56
|
+
|
57
|
+
Returns:
|
58
|
+
dict: The response from the service.
|
59
|
+
"""
|
60
|
+
self._refresh_api_key_if_needed()
|
61
|
+
url = f"{self.base_url}/registrymanagement/v1.0/registrySyndication/{self.registry_name}/createOrUpdateManifest"
|
62
|
+
response = self.post(url, json=manifest_dto)
|
63
|
+
return response.json()
|
64
|
+
|
65
|
+
def resync_assets_in_manifest(self, resync_assets_dto: dict) -> dict:
|
66
|
+
"""
|
67
|
+
Resynchronize assets in the syndication manifest.
|
68
|
+
|
69
|
+
Args:
|
70
|
+
resync_assets_dto (dict): The DTO specifying which assets to resync.
|
71
|
+
|
72
|
+
Returns:
|
73
|
+
dict: The response from the service.
|
74
|
+
"""
|
75
|
+
self._refresh_api_key_if_needed()
|
76
|
+
url = f"{self.base_url}/registrymanagement/v1.0/registrySyndication/{self.registry_name}/resyncAssetsInManifest"
|
77
|
+
response = self.post(url, json=resync_assets_dto)
|
78
|
+
return response.json()
|
79
|
+
|
80
|
+
def delete_manifest(self) -> dict:
|
81
|
+
"""
|
82
|
+
Delete the syndication manifest for the registry.
|
83
|
+
|
84
|
+
Returns:
|
85
|
+
dict: The response from the service.
|
86
|
+
"""
|
87
|
+
self._refresh_api_key_if_needed()
|
88
|
+
url = f"{self.base_url}/registrymanagement/v1.0/registrySyndication/{self.registry_name}/deleteManifest"
|
89
|
+
response = self.post(url)
|
90
|
+
return response.json()
|
91
|
+
|
92
|
+
def get_manifest(self) -> dict:
|
93
|
+
"""
|
94
|
+
Get the syndication manifest for the registry.
|
95
|
+
|
96
|
+
Returns:
|
97
|
+
dict: The manifest data.
|
98
|
+
"""
|
99
|
+
self._refresh_api_key_if_needed()
|
100
|
+
url = f"{self.base_url}/registrymanagement/v1.0/registrySyndication/{self.registry_name}/getManifest"
|
101
|
+
response = self.get(url)
|
102
|
+
return response.json()
|
103
|
+
|
104
|
+
def discovery(self) -> dict:
|
105
|
+
"""
|
106
|
+
Get discovery information for the registry.
|
107
|
+
|
108
|
+
Returns:
|
109
|
+
dict: The discovery information for the registry.
|
110
|
+
"""
|
111
|
+
self._refresh_api_key_if_needed()
|
112
|
+
url = f"{self.base_url}/registrymanagement/v1.0/registries/{self.registry_name}/discovery"
|
113
|
+
response = self.get(url)
|
114
|
+
return response.json()
|
@@ -0,0 +1,235 @@
|
|
1
|
+
# ---------------------------------------------------------
|
2
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
3
|
+
# ---------------------------------------------------------
|
4
|
+
"""Syndication manifest generator for AzureML registries.
|
5
|
+
|
6
|
+
This script parses YAML files describing source registries and assets, validates them, and generates a syndication
|
7
|
+
manifest for AzureML registries.
|
8
|
+
"""
|
9
|
+
|
10
|
+
import os
|
11
|
+
import glob
|
12
|
+
import argparse
|
13
|
+
import json
|
14
|
+
from ruamel.yaml import YAML
|
15
|
+
from .syndication_manifest import Asset, SourceRegistry, SyndicationManifest
|
16
|
+
|
17
|
+
REQUIRED_SOURCE_FIELDS = ['name', 'tenant_id', 'is_ipp', 'assets']
|
18
|
+
REQUIRED_REGISTRY_FIELDS = ['tenant_id', 'name']
|
19
|
+
ALLOWED_ASSET_TYPES = {
|
20
|
+
'environments', 'models', 'deployment-templates', 'components', 'datasets'
|
21
|
+
}
|
22
|
+
|
23
|
+
ASSET_TYPE_MAP = {
|
24
|
+
'models': 'Models',
|
25
|
+
'environments': 'Environments',
|
26
|
+
'deployment-templates': 'DeploymentTemplates',
|
27
|
+
'components': 'Components',
|
28
|
+
'datasets': 'Datasets',
|
29
|
+
}
|
30
|
+
|
31
|
+
|
32
|
+
def validate_manifest_data(data: dict, file_path: str) -> None:
|
33
|
+
"""Validate required root-level fields and allowed values for nested fields under 'assets'.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
data (dict): Parsed YAML content.
|
37
|
+
file_path (str): Path to the YAML file (for error reporting).
|
38
|
+
|
39
|
+
Raises:
|
40
|
+
ValueError: If required fields are missing or invalid values are found.
|
41
|
+
"""
|
42
|
+
missing = [field for field in REQUIRED_SOURCE_FIELDS if field not in data]
|
43
|
+
if missing:
|
44
|
+
raise ValueError(
|
45
|
+
f"Missing required field(s) in {file_path}: {', '.join(missing)}"
|
46
|
+
)
|
47
|
+
assets = data.get('assets', {})
|
48
|
+
if not isinstance(assets, dict):
|
49
|
+
raise ValueError(f"'assets' field in {file_path} must be a dictionary.")
|
50
|
+
for asset_type in assets:
|
51
|
+
if asset_type not in ALLOWED_ASSET_TYPES:
|
52
|
+
raise ValueError(
|
53
|
+
f"Invalid asset type '{asset_type}' in 'assets' of {file_path}. Allowed: "
|
54
|
+
f"{', '.join(ALLOWED_ASSET_TYPES)}"
|
55
|
+
)
|
56
|
+
|
57
|
+
|
58
|
+
def parse_asset_objs(asset_type: str, asset_dict: dict) -> list:
|
59
|
+
"""Parse asset objects from the asset dictionary for a given asset type.
|
60
|
+
|
61
|
+
Args:
|
62
|
+
asset_type (str): The asset type (e.g., 'models').
|
63
|
+
asset_dict (dict or str): Dictionary of asset names to version lists or dicts, or a wildcard string.
|
64
|
+
|
65
|
+
Returns:
|
66
|
+
list: List of Asset objects.
|
67
|
+
"""
|
68
|
+
assets = []
|
69
|
+
# Handle wildcard string case: environments: "*"
|
70
|
+
if asset_dict == "*":
|
71
|
+
assets.append(Asset(name=".*", version=".*"))
|
72
|
+
return assets
|
73
|
+
if not asset_dict or not isinstance(asset_dict, dict):
|
74
|
+
return assets
|
75
|
+
for asset_name, asset_info in asset_dict.items():
|
76
|
+
if isinstance(asset_info, dict) and 'versions' in asset_info:
|
77
|
+
versions = asset_info['versions']
|
78
|
+
elif isinstance(asset_info, list):
|
79
|
+
versions = asset_info
|
80
|
+
else:
|
81
|
+
versions = [asset_info]
|
82
|
+
for version in versions:
|
83
|
+
assets.append(Asset(name=asset_name, version=version))
|
84
|
+
return assets
|
85
|
+
|
86
|
+
|
87
|
+
def read_manifest_files(folder: str) -> dict:
|
88
|
+
"""Recursively read all YAML files in the 'sources' subdirectory and validate required root-level fields.
|
89
|
+
|
90
|
+
Args:
|
91
|
+
folder (str): Root folder containing the 'sources' directory.
|
92
|
+
|
93
|
+
Returns:
|
94
|
+
dict: Mapping of file paths to loaded SourceRegistry instances.
|
95
|
+
|
96
|
+
Raises:
|
97
|
+
ValueError: If required fields are missing in any YAML file.
|
98
|
+
"""
|
99
|
+
sources_path = os.path.join(folder, 'sources')
|
100
|
+
yaml_files = glob.glob(os.path.join(sources_path, '**', '*.yaml'), recursive=True)
|
101
|
+
contents = {}
|
102
|
+
yaml = YAML(typ='safe')
|
103
|
+
for file_path in yaml_files:
|
104
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
105
|
+
data = yaml.load(f)
|
106
|
+
validate_manifest_data(data, file_path)
|
107
|
+
assets = {}
|
108
|
+
for yaml_key in ALLOWED_ASSET_TYPES:
|
109
|
+
asset_objs = parse_asset_objs(yaml_key, data.get('assets', {}).get(yaml_key))
|
110
|
+
if asset_objs:
|
111
|
+
assets[yaml_key] = asset_objs
|
112
|
+
source_registry = SourceRegistry(
|
113
|
+
registry_name=data['name'],
|
114
|
+
tenant_id=data['tenant_id'],
|
115
|
+
assets=assets
|
116
|
+
)
|
117
|
+
contents[file_path] = source_registry
|
118
|
+
return contents
|
119
|
+
|
120
|
+
|
121
|
+
def _load_registry_yaml(folder: str) -> SyndicationManifest:
|
122
|
+
"""Load 'registry.yaml' from the specified folder into a SyndicationManifest instance (without SourceRegistries).
|
123
|
+
|
124
|
+
Args:
|
125
|
+
folder (str): Folder containing the 'registry.yaml' file.
|
126
|
+
|
127
|
+
Returns:
|
128
|
+
SyndicationManifest: Instance populated from the YAML file (SourceRegistries is an empty list), with
|
129
|
+
_allow_wildcards set.
|
130
|
+
|
131
|
+
Raises:
|
132
|
+
FileNotFoundError: If 'registry.yaml' is not found in the folder.
|
133
|
+
ValueError: If required fields are missing.
|
134
|
+
"""
|
135
|
+
registry_path = os.path.join(folder, 'registry.yaml')
|
136
|
+
if not os.path.isfile(registry_path):
|
137
|
+
raise FileNotFoundError(f"{registry_path} not found.")
|
138
|
+
yaml = YAML(typ='safe')
|
139
|
+
with open(registry_path, 'r', encoding='utf-8') as f:
|
140
|
+
data = yaml.load(f)
|
141
|
+
missing = [field for field in REQUIRED_REGISTRY_FIELDS if field not in data]
|
142
|
+
if missing:
|
143
|
+
raise ValueError(
|
144
|
+
f"Missing required field(s) in registry.yaml: {', '.join(missing)}"
|
145
|
+
)
|
146
|
+
# Read allow_wildcards flag, default to False if not present
|
147
|
+
allow_wildcards = data.get('allow_wildcards', False)
|
148
|
+
return SyndicationManifest(
|
149
|
+
registry_name=data['name'],
|
150
|
+
tenant_id=data['tenant_id'],
|
151
|
+
source_registries=[],
|
152
|
+
_allow_wildcards=allow_wildcards
|
153
|
+
)
|
154
|
+
|
155
|
+
|
156
|
+
def generate_syndication_manifest(folder: str) -> SyndicationManifest:
|
157
|
+
"""Load the destination registry and all source registries manifests from the specified folder.
|
158
|
+
|
159
|
+
This function loads the destination registry's configuration, all source registries, validates asset uniqueness
|
160
|
+
and wildcard usage, and returns a complete SyndicationManifest object.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
folder (str): Root folder containing the manifest for the sources.
|
164
|
+
|
165
|
+
Returns:
|
166
|
+
SyndicationManifest: The complete syndication manifest with destination and source registries.
|
167
|
+
|
168
|
+
Raises:
|
169
|
+
ValueError: If asset names are not unique across all SourceRegistry instances, or if wildcards are not allowed
|
170
|
+
but used.
|
171
|
+
"""
|
172
|
+
# Load the destination registry manifest and allow_wildcards flag
|
173
|
+
manifest = _load_registry_yaml(folder)
|
174
|
+
allow_wildcards = manifest._allow_wildcards
|
175
|
+
# Read all source registry manifests from the sources directory
|
176
|
+
source_registries_dict = read_manifest_files(folder)
|
177
|
+
source_registries = list(source_registries_dict.values())
|
178
|
+
|
179
|
+
# Validate uniqueness of asset names within the same asset type across all SourceRegistry instances
|
180
|
+
asset_types = [
|
181
|
+
('Models', 'models'),
|
182
|
+
('Environments', 'environments'),
|
183
|
+
('DeploymentTemplates', 'deployment-templates'),
|
184
|
+
('Components', 'components'),
|
185
|
+
('Datasets', 'datasets'),
|
186
|
+
]
|
187
|
+
for class_attr, yaml_key in asset_types:
|
188
|
+
registry_asset_map = dict() # asset_name -> set of (registry_name, uri)
|
189
|
+
duplicates = dict() # asset_name -> set of uris
|
190
|
+
for sr in source_registries:
|
191
|
+
asset_list = sr.assets.get(yaml_key, [])
|
192
|
+
for asset in asset_list:
|
193
|
+
uri = f"azureml://registries/{sr.registry_name}/{yaml_key}/name/{asset.name}/versions/{asset.version}"
|
194
|
+
# If allow_wildcards is False, disallow wildcard versions in asset URIs
|
195
|
+
if not allow_wildcards:
|
196
|
+
if asset.version == ".*" or asset.version == "*":
|
197
|
+
raise ValueError(
|
198
|
+
f"Wildcard asset version not allowed (allow_wildcards is False): {uri}"
|
199
|
+
)
|
200
|
+
# Extract asset name from URI for uniqueness validation
|
201
|
+
asset_name = asset.name
|
202
|
+
registry_name = sr.registry_name
|
203
|
+
if asset_name not in registry_asset_map:
|
204
|
+
registry_asset_map[asset_name] = set()
|
205
|
+
registry_asset_map[asset_name].add((registry_name, uri))
|
206
|
+
# Check for duplicate asset names across registries for this asset type
|
207
|
+
for asset_name, reg_uris in registry_asset_map.items():
|
208
|
+
registries = {reg for reg, _ in reg_uris}
|
209
|
+
if len(registries) > 1:
|
210
|
+
uris = {uri for _, uri in reg_uris}
|
211
|
+
duplicates[asset_name] = uris
|
212
|
+
if duplicates:
|
213
|
+
error_lines = []
|
214
|
+
for asset_name, uris in sorted(duplicates.items()):
|
215
|
+
error_lines.append(
|
216
|
+
f"Asset name '{asset_name}' for asset type '{yaml_key}' is duplicated across registries in URIs: "
|
217
|
+
+ ", ".join(sorted(uris))
|
218
|
+
)
|
219
|
+
raise ValueError("\n".join(error_lines))
|
220
|
+
|
221
|
+
# Populate SourceRegistries in the SyndicationManifest instance
|
222
|
+
manifest.source_registries = source_registries
|
223
|
+
return manifest
|
224
|
+
|
225
|
+
|
226
|
+
if __name__ == "__main__":
|
227
|
+
# Parse command-line arguments for the manifest folder
|
228
|
+
parser = argparse.ArgumentParser(description="Syndication manifest structure.")
|
229
|
+
parser.add_argument(
|
230
|
+
'-f', '--folder', type=str, help='Registry syndication manifest folder', required=True
|
231
|
+
)
|
232
|
+
args = parser.parse_args()
|
233
|
+
# Generate the syndication manifest and print as formatted JSON
|
234
|
+
manifest = generate_syndication_manifest(args.folder)
|
235
|
+
print(json.dumps(manifest.to_dto(), indent=2))
|
@@ -0,0 +1,150 @@
|
|
1
|
+
# ---------------------------------------------------------
|
2
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
3
|
+
# ---------------------------------------------------------
|
4
|
+
"""Syndication manifest dataclasses and validation for AzureML registry asset syndication."""
|
5
|
+
|
6
|
+
from dataclasses import dataclass, field
|
7
|
+
from typing import List
|
8
|
+
from uuid import UUID
|
9
|
+
|
10
|
+
|
11
|
+
def _norm_key(k):
|
12
|
+
return k.replace('_', '').lower()
|
13
|
+
|
14
|
+
|
15
|
+
def _get_key(dct, *keys, normalize_keys=False):
|
16
|
+
"""Normalize and retrieve a value from a dict by trying multiple key casings.
|
17
|
+
|
18
|
+
If normalize_keys is True, normalize the dict keys before searching.
|
19
|
+
"""
|
20
|
+
if normalize_keys:
|
21
|
+
dct_norm = {_norm_key(k): v for k, v in dct.items()}
|
22
|
+
for k in keys:
|
23
|
+
nk = _norm_key(k)
|
24
|
+
if nk in dct_norm:
|
25
|
+
return dct_norm[nk]
|
26
|
+
return None
|
27
|
+
else:
|
28
|
+
for k in keys:
|
29
|
+
if k in dct:
|
30
|
+
return dct[k]
|
31
|
+
return None
|
32
|
+
|
33
|
+
|
34
|
+
@dataclass
|
35
|
+
class Asset:
|
36
|
+
"""Represents a single asset with a name and version."""
|
37
|
+
|
38
|
+
name: str
|
39
|
+
version: str
|
40
|
+
|
41
|
+
def to_dict(self) -> dict:
|
42
|
+
"""Convert the Asset instance to a dictionary with serialized field names."""
|
43
|
+
return {"Name": self.name, "Version": self.version}
|
44
|
+
|
45
|
+
@staticmethod
|
46
|
+
def from_dict(d: dict, normalize_keys=False) -> "Asset":
|
47
|
+
"""Create an Asset instance from a dictionary with serialized field names."""
|
48
|
+
return Asset(
|
49
|
+
name=_get_key(d, "Name", normalize_keys=normalize_keys),
|
50
|
+
version=_get_key(d, "Version", normalize_keys=normalize_keys) or ".*"
|
51
|
+
)
|
52
|
+
|
53
|
+
|
54
|
+
@dataclass
|
55
|
+
class SourceRegistry:
|
56
|
+
"""Represents a source registry in the syndication manifest.
|
57
|
+
|
58
|
+
Attributes:
|
59
|
+
registry_name (str): The name of the source registry.
|
60
|
+
tenant_id (UUID): The Azure tenant ID for the source registry.
|
61
|
+
assets (dict): Dictionary mapping asset type to list of Asset objects.
|
62
|
+
"""
|
63
|
+
|
64
|
+
registry_name: str
|
65
|
+
tenant_id: UUID
|
66
|
+
assets: dict # asset_type (str) -> List[Asset]
|
67
|
+
|
68
|
+
def to_dict(self) -> dict:
|
69
|
+
"""Convert the SourceRegistry instance to a dictionary with serialized field names."""
|
70
|
+
asset_dict = {}
|
71
|
+
for asset_type, asset_list in self.assets.items():
|
72
|
+
if asset_list:
|
73
|
+
asset_dict[asset_type] = [
|
74
|
+
{"Name": a.name} if a.name == ".*" else {"Name": a.name, "Version": a.version}
|
75
|
+
for a in asset_list
|
76
|
+
]
|
77
|
+
return {
|
78
|
+
"RegistryName": self.registry_name,
|
79
|
+
"TenantId": str(self.tenant_id),
|
80
|
+
"Assets": asset_dict
|
81
|
+
}
|
82
|
+
|
83
|
+
@staticmethod
|
84
|
+
def from_dict(d: dict, normalize_keys=False) -> "SourceRegistry":
|
85
|
+
"""Create a SourceRegistry instance from a dictionary with any key casing (snake, camel, Pascal)."""
|
86
|
+
assets = {}
|
87
|
+
assets_dict = _get_key(d, "Assets", "assets", normalize_keys=normalize_keys)
|
88
|
+
for asset_type, asset_list in assets_dict.items():
|
89
|
+
assets[asset_type] = [Asset.from_dict(a, normalize_keys=normalize_keys) for a in asset_list]
|
90
|
+
return SourceRegistry(
|
91
|
+
registry_name=_get_key(d, "RegistryName", "registry_name", normalize_keys=normalize_keys),
|
92
|
+
tenant_id=UUID(str(_get_key(d, "TenantId", "tenant_id", normalize_keys=normalize_keys))),
|
93
|
+
assets=assets
|
94
|
+
)
|
95
|
+
|
96
|
+
|
97
|
+
@dataclass
|
98
|
+
class SyndicationManifest:
|
99
|
+
"""Represents the root syndication manifest for a destination registry.
|
100
|
+
|
101
|
+
Attributes:
|
102
|
+
registry_name (str): The name of the destination registry.
|
103
|
+
tenant_id (UUID): The Azure tenant ID for the destination registry.
|
104
|
+
source_registries (List[SourceRegistry]): All source registries.
|
105
|
+
_allow_wildcards (bool): Internal flag for wildcard version validation (not serialized).
|
106
|
+
"""
|
107
|
+
|
108
|
+
registry_name: str
|
109
|
+
tenant_id: UUID
|
110
|
+
source_registries: List[SourceRegistry]
|
111
|
+
_allow_wildcards: bool = field(default=False, repr=False, compare=False)
|
112
|
+
|
113
|
+
def to_dict(self) -> dict:
|
114
|
+
"""Convert the SyndicationManifest instance to a dictionary with serialized field names."""
|
115
|
+
return {
|
116
|
+
"RegistryName": self.registry_name,
|
117
|
+
"TenantId": str(self.tenant_id),
|
118
|
+
"SourceRegistries": [sr.to_dict() for sr in self.source_registries]
|
119
|
+
}
|
120
|
+
|
121
|
+
def to_dto(self) -> dict:
|
122
|
+
"""Serialize the manifest as a value of {"Manifest": SyndicationManifest} for external consumers."""
|
123
|
+
return {"Manifest": self.to_dict()}
|
124
|
+
|
125
|
+
@staticmethod
|
126
|
+
def from_dto(dto: dict, normalize_keys=False) -> "SyndicationManifest":
|
127
|
+
"""Deserialize a SyndicationManifest from a dictionary produced by to_dto, with validation. Handles any key casing."""
|
128
|
+
if not isinstance(dto, dict) or _get_key(dto, "Manifest", normalize_keys=normalize_keys) is None:
|
129
|
+
raise ValueError("Input must be a dict with a 'Manifest' key.")
|
130
|
+
manifest = _get_key(dto, "Manifest", normalize_keys=normalize_keys)
|
131
|
+
if not isinstance(manifest, dict):
|
132
|
+
raise ValueError("'Manifest' value must be a dict.")
|
133
|
+
for field_name in ("RegistryName", "TenantId", "SourceRegistries"):
|
134
|
+
if _get_key(manifest, field_name, normalize_keys=normalize_keys) is None:
|
135
|
+
raise ValueError(f"Missing required field '{field_name}' in Manifest.")
|
136
|
+
registry_name = _get_key(manifest, "RegistryName", "registry_name", normalize_keys=normalize_keys)
|
137
|
+
tenant_id = _get_key(manifest, "TenantId", "tenant_id", normalize_keys=normalize_keys)
|
138
|
+
try:
|
139
|
+
tenant_id = UUID(str(tenant_id))
|
140
|
+
except Exception:
|
141
|
+
raise ValueError("TenantId must be a valid UUID string.")
|
142
|
+
sr_list = _get_key(manifest, "SourceRegistries", "source_registries", normalize_keys=normalize_keys)
|
143
|
+
if not isinstance(sr_list, list):
|
144
|
+
raise ValueError("SourceRegistries must be a list.")
|
145
|
+
source_registries = [SourceRegistry.from_dict(sr, normalize_keys=normalize_keys) for sr in sr_list]
|
146
|
+
return SyndicationManifest(
|
147
|
+
registry_name=registry_name,
|
148
|
+
tenant_id=tenant_id,
|
149
|
+
source_registries=source_registries
|
150
|
+
)
|
{azureml_registry_tools-0.1.0a1.dist-info → azureml_registry_tools-0.1.0a2.dist-info}/METADATA
RENAMED
@@ -1,11 +1,13 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: azureml-registry-tools
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.0a2
|
4
4
|
Summary: AzureML Registry tools and CLI
|
5
5
|
Author: Microsoft Corp
|
6
6
|
License: https://aka.ms/azureml-sdk-license
|
7
|
-
Requires-Python: >=3.9,<3.
|
7
|
+
Requires-Python: >=3.9,<3.14
|
8
8
|
License-File: LICENSE.txt
|
9
|
+
Requires-Dist: azure-identity<2.0
|
10
|
+
Requires-Dist: ruamel-yaml<0.19,>=0.17.21
|
9
11
|
Requires-Dist: azure-ai-ml<2.0
|
10
12
|
Requires-Dist: azureml-assets<2.0
|
11
13
|
Dynamic: author
|
@@ -0,0 +1,23 @@
|
|
1
|
+
azureml/__init__.py,sha256=_gYz_vABVrp9EyOZEYn4Ya8XotJC2rZn-b9kFTEj1Dk,266
|
2
|
+
azureml/registry/__init__.py,sha256=qjP86n1Yz6hdZb3az-wjfi7LNndsFjMQI6lXmKfXapM,266
|
3
|
+
azureml/registry/_cli/__init__.py,sha256=dJ020ynrregpUaNGu8xVLLmX3cC9DAjaBMP1qnljLEE,208
|
4
|
+
azureml/registry/_cli/registry_syndication_cli.py,sha256=87hxSu4iwFOdwfukSGqc0fjJEpSpQhDRlthiCGCDwQ8,10003
|
5
|
+
azureml/registry/_cli/repo2registry_cli.py,sha256=4CDv4tYWLTjVgdXIcoKA9iu_gOv_Z_xn05wSANkdmdg,5378
|
6
|
+
azureml/registry/_rest_client/__init__.py,sha256=Eh0vB8AVlwkZlHLO4DCGHyyfRYYgeTeVjiUAX_WujG0,219
|
7
|
+
azureml/registry/_rest_client/arm_client.py,sha256=dgm9nDnmGI1NIZaopXCIXmViM8sSAipZETfuOWnQRa0,5638
|
8
|
+
azureml/registry/_rest_client/base_rest_client.py,sha256=cXFYvYgSYSveEeY511VIlDpmXLOY9Jat_E-Zs1ehMyA,5164
|
9
|
+
azureml/registry/_rest_client/registry_management_client.py,sha256=JGBohCVpL8XR4rNuScOKt7tNQnjw203pqlTSLKVjYgM,4833
|
10
|
+
azureml/registry/mgmt/__init__.py,sha256=LMhqcEC8ItmmpKZljElGXH-6olHlT3SLl0dJU01OvuM,226
|
11
|
+
azureml/registry/mgmt/create_manifest.py,sha256=N9wRmjAKO09A3utN_lCUsM_Ufpj7PL0SJz-XHPHWuyM,9528
|
12
|
+
azureml/registry/mgmt/syndication_manifest.py,sha256=ma1_vUEHHtkRcgiFMQanlXZajvKodynylxfblTHuleE,6195
|
13
|
+
azureml/registry/tools/__init__.py,sha256=IAuWWpGfZm__pAkBIxmpJz84QskpkxBr0yDk1TUSnkE,223
|
14
|
+
azureml/registry/tools/config.py,sha256=tjPaoBsWtPXBL8Ww1hcJtsr2SuIjPKt79dR8iovcebg,3639
|
15
|
+
azureml/registry/tools/create_or_update_assets.py,sha256=NexSY22bpN2eHPqZZ21eDIMerwq3SzVk_4fBV5_GTBY,14787
|
16
|
+
azureml/registry/tools/registry_utils.py,sha256=zgYlCiOONtQJ4yZ9wg8tKVoE8dh6rrjB8hYBGhpV9-0,1403
|
17
|
+
azureml/registry/tools/repo2registry_config.py,sha256=eXp_tU8Jyi30g8xGf7wbpLgKEPpieohBANKxMSLzq7s,4873
|
18
|
+
azureml_registry_tools-0.1.0a2.dist-info/licenses/LICENSE.txt,sha256=n20rxwp7_NGrrShv9Qvcs90sjI1l3Pkt3m-5OPCWzgs,845
|
19
|
+
azureml_registry_tools-0.1.0a2.dist-info/METADATA,sha256=SJpiD2SsYXqJi_DUSLk-mquFZgbnb54Sd_mrP6H-GBI,491
|
20
|
+
azureml_registry_tools-0.1.0a2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
21
|
+
azureml_registry_tools-0.1.0a2.dist-info/entry_points.txt,sha256=iRUkAeQidMnO6RQzpLqMUBTcyYtNzAfSin9WnSdVGLw,147
|
22
|
+
azureml_registry_tools-0.1.0a2.dist-info/top_level.txt,sha256=ZOeEa0TAXo6i5wOjwBoqfIGEuxOcKuscGgNSpizqREY,8
|
23
|
+
azureml_registry_tools-0.1.0a2.dist-info/RECORD,,
|
@@ -1,15 +0,0 @@
|
|
1
|
-
azureml/__init__.py,sha256=_gYz_vABVrp9EyOZEYn4Ya8XotJC2rZn-b9kFTEj1Dk,266
|
2
|
-
azureml/registry/__init__.py,sha256=qjP86n1Yz6hdZb3az-wjfi7LNndsFjMQI6lXmKfXapM,266
|
3
|
-
azureml/registry/_cli/__init__.py,sha256=dJ020ynrregpUaNGu8xVLLmX3cC9DAjaBMP1qnljLEE,208
|
4
|
-
azureml/registry/_cli/repo2registry_cli.py,sha256=4CDv4tYWLTjVgdXIcoKA9iu_gOv_Z_xn05wSANkdmdg,5378
|
5
|
-
azureml/registry/tools/__init__.py,sha256=IAuWWpGfZm__pAkBIxmpJz84QskpkxBr0yDk1TUSnkE,223
|
6
|
-
azureml/registry/tools/config.py,sha256=tjPaoBsWtPXBL8Ww1hcJtsr2SuIjPKt79dR8iovcebg,3639
|
7
|
-
azureml/registry/tools/create_or_update_assets.py,sha256=NexSY22bpN2eHPqZZ21eDIMerwq3SzVk_4fBV5_GTBY,14787
|
8
|
-
azureml/registry/tools/registry_utils.py,sha256=zgYlCiOONtQJ4yZ9wg8tKVoE8dh6rrjB8hYBGhpV9-0,1403
|
9
|
-
azureml/registry/tools/repo2registry_config.py,sha256=eXp_tU8Jyi30g8xGf7wbpLgKEPpieohBANKxMSLzq7s,4873
|
10
|
-
azureml_registry_tools-0.1.0a1.dist-info/licenses/LICENSE.txt,sha256=n20rxwp7_NGrrShv9Qvcs90sjI1l3Pkt3m-5OPCWzgs,845
|
11
|
-
azureml_registry_tools-0.1.0a1.dist-info/METADATA,sha256=o-QaSt2Mmc9w9okaay-Rv3tnWaHxNJszkP44iN9dl7M,415
|
12
|
-
azureml_registry_tools-0.1.0a1.dist-info/WHEEL,sha256=L0N565qmK-3nM2eBoMNFszYJ_MTx03_tQ0CQu1bHLYo,91
|
13
|
-
azureml_registry_tools-0.1.0a1.dist-info/entry_points.txt,sha256=tiXL5l4P1PsN9pEkj0SiSgzB_zi0MFz8FqMw1ixzqKo,79
|
14
|
-
azureml_registry_tools-0.1.0a1.dist-info/top_level.txt,sha256=ZOeEa0TAXo6i5wOjwBoqfIGEuxOcKuscGgNSpizqREY,8
|
15
|
-
azureml_registry_tools-0.1.0a1.dist-info/RECORD,,
|
File without changes
|
{azureml_registry_tools-0.1.0a1.dist-info → azureml_registry_tools-0.1.0a2.dist-info}/top_level.txt
RENAMED
File without changes
|