cu-cli 0.1.0b1__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 (56) hide show
  1. cu_cli/__init__.py +17 -0
  2. cu_cli/__main__.py +11 -0
  3. cu_cli/apiversion.py +124 -0
  4. cu_cli/cli.py +138 -0
  5. cu_cli/client.py +138 -0
  6. cu_cli/commands/__init__.py +4 -0
  7. cu_cli/commands/_command_spec.py +94 -0
  8. cu_cli/commands/_help.py +33 -0
  9. cu_cli/commands/_infra_models.py +184 -0
  10. cu_cli/commands/_infra_wizard.py +630 -0
  11. cu_cli/commands/_model_setup.py +46 -0
  12. cu_cli/commands/_options.py +112 -0
  13. cu_cli/commands/analyze.py +631 -0
  14. cu_cli/commands/analyzer.py +1462 -0
  15. cu_cli/commands/defaults.py +172 -0
  16. cu_cli/commands/doctor.py +166 -0
  17. cu_cli/commands/env_var.py +67 -0
  18. cu_cli/commands/infra.py +302 -0
  19. cu_cli/commands/profile_cmd.py +525 -0
  20. cu_cli/commands/upgrade.py +120 -0
  21. cu_cli/core/__init__.py +17 -0
  22. cu_cli/core/analyze.py +44 -0
  23. cu_cli/core/analyzers.py +30 -0
  24. cu_cli/core/azure_resources.py +486 -0
  25. cu_cli/core/defaults.py +18 -0
  26. cu_cli/core/doctor.py +42 -0
  27. cu_cli/core/foundry.py +68 -0
  28. cu_cli/core/infra_models.py +367 -0
  29. cu_cli/core/inputs.py +209 -0
  30. cu_cli/core/schema.py +24 -0
  31. cu_cli/errors.py +174 -0
  32. cu_cli/exit_codes.py +20 -0
  33. cu_cli/modality.py +24 -0
  34. cu_cli/output.py +179 -0
  35. cu_cli/profile.py +30 -0
  36. cu_cli/py.typed +0 -0
  37. cu_cli/resources/__init__.py +4 -0
  38. cu_cli/resources/azd_template/README.md +187 -0
  39. cu_cli/resources/azd_template/azure.yaml +27 -0
  40. cu_cli/resources/azd_template/hooks/postprovision.ps1 +320 -0
  41. cu_cli/resources/azd_template/hooks/postprovision.sh +299 -0
  42. cu_cli/resources/azd_template/infra/main.bicep +115 -0
  43. cu_cli/resources/azd_template/infra/main.parameters.json +30 -0
  44. cu_cli/resources/azd_template/infra/models.json +1 -0
  45. cu_cli/resources/azd_template/infra/modules/foundry.bicep +122 -0
  46. cu_cli/schema_validate.py +28 -0
  47. cu_cli/spec_validate.py +18 -0
  48. cu_cli/telemetry.py +42 -0
  49. cu_cli/update_check.py +154 -0
  50. cu_cli/update_provider.py +92 -0
  51. cu_cli/windows_self_upgrade.py +245 -0
  52. cu_cli-0.1.0b1.dist-info/METADATA +345 -0
  53. cu_cli-0.1.0b1.dist-info/RECORD +56 -0
  54. cu_cli-0.1.0b1.dist-info/WHEEL +5 -0
  55. cu_cli-0.1.0b1.dist-info/entry_points.txt +3 -0
  56. cu_cli-0.1.0b1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,115 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT license.
3
+
4
+ targetScope = 'subscription'
5
+
6
+ @minLength(1)
7
+ @maxLength(64)
8
+ @description('Name of the environment. Used to derive resource names including resource group (rg-<name>), Foundry project (proj-<name>), and a unique suffix for the Foundry resource name.')
9
+ param environmentName string
10
+
11
+ @minLength(1)
12
+ @allowed([
13
+ 'australiaeast'
14
+ 'eastus'
15
+ 'eastus2'
16
+ 'japaneast'
17
+ 'northeurope'
18
+ 'southcentralus'
19
+ 'southeastasia'
20
+ 'swedencentral'
21
+ 'uksouth'
22
+ 'westeurope'
23
+ 'westus'
24
+ 'westus3'
25
+ ])
26
+ @description('Primary Azure region for the Foundry resource. Must be a supported region for Azure Content Understanding (https://learn.microsoft.com/azure/ai-services/content-understanding/language-region-support)')
27
+ param location string
28
+
29
+ @description('Optional prefix for the Foundry resource name. The final resource name becomes <prefix>-<unique-suffix>. Leave empty to use the default aif- prefix.')
30
+ param foundryResourcePrefix string = ''
31
+
32
+ @description('Optional existing Foundry endpoint. When set, this template skips creating Foundry account/project and deploys models to this existing account.')
33
+ param existingFoundryEndpoint string = ''
34
+
35
+ @description('Resource group of the existing Foundry account when existingFoundryEndpoint is set.')
36
+ param existingFoundryResourceGroup string = ''
37
+
38
+ @description('Object ID of the user or service principal that should receive Cognitive Services User on the Foundry resource. azd injects AZURE_PRINCIPAL_ID automatically.')
39
+ param principalId string
40
+
41
+ @description('Type of principal for the role assignments.')
42
+ @allowed([ 'User', 'ServicePrincipal' ])
43
+ param principalType string = 'User'
44
+
45
+ @description('If "true", assign Cognitive Services User to principalId for Entra-authenticated CU operations. Requires Owner, User Access Administrator, or Role Based Access Control Administrator. Set to "false" when you only have Contributor.')
46
+ param assignRolesToPrincipal string = 'true'
47
+
48
+ // Model deployments are loaded from infra/models.json so the file is
49
+ // hand-editable and round-trippable through other tooling (e.g. `cu infra generate`).
50
+ var modelDeployments = loadJsonContent('models.json')
51
+
52
+ var foundryUniqueSuffix = toLower(uniqueString(subscription().id, environmentName, location))
53
+ var rgName = 'rg-${environmentName}'
54
+ var useExistingFoundry = !empty(trim(existingFoundryEndpoint))
55
+ var existingFoundryAccountName = useExistingFoundry
56
+ ? split(replace(replace(toLower(trim(existingFoundryEndpoint)), 'https://', ''), 'http://', ''), '.')[0]
57
+ : ''
58
+ var normalizedResourcePrefix = toLower(trim(foundryResourcePrefix))
59
+ var resourceNamePrefix = empty(normalizedResourcePrefix) ? 'aif' : normalizedResourcePrefix
60
+ var foundryResourceName = useExistingFoundry ? existingFoundryAccountName : '${resourceNamePrefix}-${foundryUniqueSuffix}'
61
+ var projectName = 'proj-${environmentName}'
62
+
63
+ resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = if (!useExistingFoundry) {
64
+ name: rgName
65
+ location: location
66
+ tags: {
67
+ 'azd-env-name': environmentName
68
+ }
69
+ }
70
+
71
+ resource existingRg 'Microsoft.Resources/resourceGroups@2024-03-01' existing = if (useExistingFoundry) {
72
+ name: existingFoundryResourceGroup
73
+ }
74
+
75
+ module foundryNew 'modules/foundry.bicep' = if (!useExistingFoundry) {
76
+ name: 'foundry-new'
77
+ scope: rg
78
+ params: {
79
+ useExistingFoundry: useExistingFoundry
80
+ accountName: foundryResourceName
81
+ projectName: projectName
82
+ location: location
83
+ principalId: principalId
84
+ principalType: principalType
85
+ assignRolesToPrincipal: toLower(assignRolesToPrincipal) == 'true'
86
+ modelDeployments: modelDeployments
87
+ }
88
+ }
89
+
90
+ module foundryExisting 'modules/foundry.bicep' = if (useExistingFoundry) {
91
+ name: 'foundry-existing'
92
+ scope: existingRg
93
+ params: {
94
+ useExistingFoundry: useExistingFoundry
95
+ accountName: foundryResourceName
96
+ projectName: projectName
97
+ location: location
98
+ principalId: principalId
99
+ principalType: principalType
100
+ assignRolesToPrincipal: toLower(assignRolesToPrincipal) == 'true'
101
+ modelDeployments: modelDeployments
102
+ }
103
+ }
104
+
105
+ output AZURE_LOCATION string = location
106
+ output AZURE_RESOURCE_GROUP string = useExistingFoundry ? existingRg.name : rg.name
107
+ output AZURE_TENANT_ID string = tenant().tenantId
108
+ output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId
109
+
110
+ output FOUNDRY_RESOURCE_NAME string = useExistingFoundry ? foundryExisting!.outputs.accountName : foundryNew!.outputs.accountName
111
+ output FOUNDRY_PROJECT_NAME string = useExistingFoundry ? foundryExisting!.outputs.projectName : foundryNew!.outputs.projectName
112
+ output FOUNDRY_ENDPOINT string = useExistingFoundry ? existingFoundryEndpoint : foundryNew!.outputs.accountEndpoint
113
+ output FOUNDRY_PROJECT_ENDPOINT string = useExistingFoundry ? foundryExisting!.outputs.projectEndpoint : foundryNew!.outputs.projectEndpoint
114
+ output CU_ENDPOINT string = useExistingFoundry ? existingFoundryEndpoint : foundryNew!.outputs.accountEndpoint
115
+ output MODEL_DEPLOYMENTS array = useExistingFoundry ? foundryExisting!.outputs.modelDeployments : foundryNew!.outputs.modelDeployments
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
3
+ "contentVersion": "1.0.0.0",
4
+ "parameters": {
5
+ "environmentName": {
6
+ "value": "${AZURE_ENV_NAME}"
7
+ },
8
+ "location": {
9
+ "value": "${AZURE_LOCATION}"
10
+ },
11
+ "foundryResourcePrefix": {
12
+ "value": "${FOUNDRY_RESOURCE_PREFIX=}"
13
+ },
14
+ "existingFoundryEndpoint": {
15
+ "value": "${FOUNDRY_EXISTING_ENDPOINT=}"
16
+ },
17
+ "existingFoundryResourceGroup": {
18
+ "value": "${FOUNDRY_EXISTING_RESOURCE_GROUP=}"
19
+ },
20
+ "principalId": {
21
+ "value": "${AZURE_PRINCIPAL_ID}"
22
+ },
23
+ "principalType": {
24
+ "value": "${AZURE_PRINCIPAL_TYPE=User}"
25
+ },
26
+ "assignRolesToPrincipal": {
27
+ "value": "${AZD_ASSIGN_ROLES=true}"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,122 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT license.
3
+
4
+ @description('Name of the Microsoft Foundry (AIServices) account.')
5
+ param accountName string
6
+
7
+ @description('When true, treat accountName as an existing Foundry account and skip account/project creation.')
8
+ param useExistingFoundry bool = false
9
+
10
+ @description('Name of the Foundry project (child of the account).')
11
+ param projectName string
12
+
13
+ @description('Region for the account and project.')
14
+ param location string
15
+
16
+ @description('Principal ID receiving data-plane access.')
17
+ param principalId string
18
+
19
+ @allowed([ 'User', 'ServicePrincipal' ])
20
+ param principalType string
21
+
22
+ @description('If true, create role assignments for principalId on the account.')
23
+ param assignRolesToPrincipal bool = true
24
+
25
+ @description('Model deployments to create on the account.')
26
+ param modelDeployments array
27
+
28
+ resource accountNew 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = if (!useExistingFoundry) {
29
+ name: accountName
30
+ location: location
31
+ kind: 'AIServices'
32
+ sku: {
33
+ name: 'S0'
34
+ }
35
+ identity: {
36
+ type: 'SystemAssigned'
37
+ }
38
+ properties: {
39
+ allowProjectManagement: true
40
+ customSubDomainName: accountName
41
+ publicNetworkAccess: 'Enabled'
42
+ disableLocalAuth: false
43
+ }
44
+ }
45
+
46
+ resource accountExisting 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (useExistingFoundry) {
47
+ name: accountName
48
+ }
49
+
50
+ resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = if (!useExistingFoundry) {
51
+ parent: accountNew
52
+ name: projectName
53
+ location: location
54
+ identity: {
55
+ type: 'SystemAssigned'
56
+ }
57
+ properties: {}
58
+ }
59
+
60
+ // Model deployments — created sequentially to avoid Cognitive Services
61
+ // 'another operation in progress' conflicts.
62
+ @batchSize(1)
63
+ resource deploymentsOnNew 'Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview' = [for d in modelDeployments: if (!useExistingFoundry) {
64
+ parent: accountNew
65
+ name: d.name
66
+ sku: {
67
+ name: d.skuName
68
+ capacity: d.skuCapacity
69
+ }
70
+ properties: {
71
+ model: {
72
+ format: d.format
73
+ name: d.model
74
+ version: d.version
75
+ }
76
+ }
77
+ }]
78
+
79
+ @batchSize(1)
80
+ resource deploymentsOnExisting 'Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview' = [for d in modelDeployments: if (useExistingFoundry) {
81
+ parent: accountExisting
82
+ name: d.name
83
+ sku: {
84
+ name: d.skuName
85
+ capacity: d.skuCapacity
86
+ }
87
+ properties: {
88
+ model: {
89
+ format: d.format
90
+ name: d.model
91
+ version: d.version
92
+ }
93
+ }
94
+ }]
95
+
96
+ // Built-in Azure RBAC role GUIDs.
97
+ // https://learn.microsoft.com/azure/role-based-access-control/built-in-roles
98
+ var roleDefinitions = {
99
+ cognitiveServicesUser: 'a97b65f3-24c7-4388-baec-2e87135dc908'
100
+ }
101
+
102
+ resource roleCogUserOnNew 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!useExistingFoundry && assignRolesToPrincipal && !empty(principalId)) {
103
+ name: guid(accountNew.id, principalId, roleDefinitions.cognitiveServicesUser)
104
+ scope: accountNew
105
+ properties: {
106
+ roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitions.cognitiveServicesUser)
107
+ principalId: principalId
108
+ principalType: principalType
109
+ }
110
+ }
111
+
112
+ output accountName string = accountName
113
+ output projectName string = useExistingFoundry ? '' : project.name
114
+ output accountEndpoint string = 'https://${accountName}.services.ai.azure.com/'
115
+ output projectEndpoint string = useExistingFoundry ? '' : 'https://${accountName}.services.ai.azure.com/api/projects/${project.name}'
116
+ output modelDeployments array = [for (d, i) in modelDeployments: {
117
+ name: d.name
118
+ model: d.model
119
+ version: d.version
120
+ sku: d.skuName
121
+ capacity: d.skuCapacity
122
+ }]
@@ -0,0 +1,28 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Compatibility exports for schema validation now owned by ``cu-cli-core``."""
5
+
6
+ from cu_cli_core.schema_validation import (
7
+ ALLOWED_METHODS,
8
+ ALLOWED_TYPES,
9
+ Finding,
10
+ ValidationResult,
11
+ custom_analyzer_id_error,
12
+ first_error_line,
13
+ parse_and_validate,
14
+ schema_pinned_version,
15
+ validate_schema,
16
+ )
17
+
18
+ __all__ = [
19
+ "ALLOWED_METHODS",
20
+ "ALLOWED_TYPES",
21
+ "Finding",
22
+ "ValidationResult",
23
+ "custom_analyzer_id_error",
24
+ "first_error_line",
25
+ "parse_and_validate",
26
+ "schema_pinned_version",
27
+ "validate_schema",
28
+ ]
@@ -0,0 +1,18 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Compatibility exports for spec validation now owned by ``cu-cli-core``."""
5
+
6
+ from cu_cli_core.spec_validation import (
7
+ spec_allowed_methods,
8
+ spec_allowed_types,
9
+ spec_available,
10
+ validate_against_spec,
11
+ )
12
+
13
+ __all__ = [
14
+ "spec_allowed_methods",
15
+ "spec_allowed_types",
16
+ "spec_available",
17
+ "validate_against_spec",
18
+ ]
cu_cli/telemetry.py ADDED
@@ -0,0 +1,42 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Telemetry.
5
+
6
+ The CLI's only telemetry is the Azure-SDK ``User-Agent`` header on CU service
7
+ calls. The CLI stamps an application-id prefix ``cu-cli/<version>`` for adoption
8
+ attribution; the Azure SDK (azure-core) then appends its standard
9
+ ``azsdk-python-<package>/<version> Python/<version> (<platform>)`` identifier.
10
+ **No customer content, and no usage/analytics data, is collected.**
11
+
12
+ ``CU_TELEMETRY=off`` (or ``0``/``false``/``no``) drops only the ``cu-cli``
13
+ prefix. It does **not** (and cannot) remove the Azure SDK's own
14
+ ``azsdk-python-...`` User-Agent — azure-core always sends one, per the Azure SDK
15
+ telemetry policy (https://azure.github.io/azure-sdk/general_azurecore.html). To
16
+ customize the header further, use the standard azure-core ``AZURE_HTTP_USER_AGENT``
17
+ environment variable, which the SDK appends.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+
24
+ from . import __version__
25
+
26
+ USER_AGENT = f"cu-cli/{__version__}"
27
+ _OPT_OUT_VALUES = {"off", "0", "false", "no"}
28
+
29
+
30
+ def telemetry_enabled() -> bool:
31
+ return os.getenv("CU_TELEMETRY", "on").strip().lower() not in _OPT_OUT_VALUES
32
+
33
+
34
+ def user_agent() -> str:
35
+ """The ``User-Agent`` prefix stamped on CU API calls (respecting opt-out).
36
+
37
+ When opted out we return an empty prefix. azure-core never sends an empty
38
+ header — it falls back to its standard ``azsdk-python-...`` User-Agent, so
39
+ the request carries no ``cu-cli`` marker and is stable across cu-cli
40
+ versions.
41
+ """
42
+ return USER_AGENT if telemetry_enabled() else ""
cu_cli/update_check.py ADDED
@@ -0,0 +1,154 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Package update check.
5
+
6
+ Follows the pip convention: compare the installed version against the latest
7
+ version from the installed update provider and, when a newer one exists, prompt
8
+ the user to upgrade with the exact command and a release-notes pointer. The
9
+ public default provider uses PyPI. **Never auto-updates.** The check is cached
10
+ and also runs implicitly at the end of every command.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Optional, Tuple
19
+
20
+ from packaging.version import InvalidVersion, Version
21
+
22
+ from . import __version__
23
+ from .update_provider import (
24
+ RELEASE_NOTES_URL,
25
+ get_update_provider,
26
+ )
27
+ _CACHE_PATH = Path.home() / ".cu" / ".update-check.json"
28
+ _CACHE_TTL_SECONDS = 24 * 60 * 60
29
+ _NETWORK_TIMEOUT = 1.5
30
+
31
+
32
+ def _checks_disabled() -> bool:
33
+ return os.getenv("CU_NO_UPDATE_CHECK", "").strip().lower() in {"1", "true", "yes", "on"}
34
+
35
+
36
+ def _read_cache(provider_name: str) -> tuple[bool, Optional[str]]:
37
+ try:
38
+ import json
39
+
40
+ data = json.loads(_CACHE_PATH.read_text(encoding="utf-8"))
41
+ if data.get("provider") != provider_name:
42
+ return False, None
43
+
44
+ last_attempt = float(data.get("last_attempt_timestamp", 0))
45
+ if time.time() - last_attempt >= _CACHE_TTL_SECONDS:
46
+ return False, None
47
+
48
+ latest = data.get("latest")
49
+ if isinstance(latest, str) and latest:
50
+ return True, latest
51
+ return True, None
52
+ except Exception:
53
+ return False, None
54
+
55
+
56
+ def _write_cache(provider_name: str, latest: Optional[str]) -> None:
57
+ try:
58
+ import json
59
+
60
+ now = time.time()
61
+ data: dict[str, object] = {}
62
+ try:
63
+ existing = json.loads(_CACHE_PATH.read_text(encoding="utf-8"))
64
+ if isinstance(existing, dict) and existing.get("provider") == provider_name:
65
+ data = existing
66
+ except (OSError, ValueError):
67
+ pass
68
+
69
+ data.update(
70
+ {
71
+ "provider": provider_name,
72
+ "last_attempt_timestamp": now,
73
+ }
74
+ )
75
+ if latest:
76
+ data["latest"] = latest
77
+ data["last_success_timestamp"] = now
78
+
79
+ _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
80
+ _CACHE_PATH.write_text(
81
+ json.dumps(data),
82
+ encoding="utf-8",
83
+ )
84
+ except Exception:
85
+ pass
86
+
87
+
88
+ def fetch_latest_version(*, use_cache: bool = True) -> Optional[str]:
89
+ """Return the latest ``cu-cli`` version, or ``None`` on any failure.
90
+
91
+ Network failures are swallowed so an update check never breaks a command.
92
+ """
93
+ return fetch_latest_version_detailed(use_cache=use_cache)[0]
94
+
95
+
96
+ def fetch_latest_version_detailed(*, use_cache: bool = True) -> Tuple[Optional[str], str]:
97
+ """Like :func:`fetch_latest_version` but also return a reason code.
98
+
99
+ Reason is one of ``"ok"``, ``"disabled"``, ``"not_published"``, or
100
+ ``"network_error"`` so callers can give accurate, non-misleading advice.
101
+ """
102
+ if _checks_disabled():
103
+ return None, "disabled"
104
+ provider = get_update_provider()
105
+ if use_cache:
106
+ is_recent, latest = _read_cache(provider.name)
107
+ if is_recent:
108
+ return latest, "ok" if latest else "network_error"
109
+ latest, reason = provider.fetch_latest_version()
110
+ _write_cache(provider.name, latest)
111
+ return latest, reason
112
+
113
+
114
+ def _parse(version: str) -> tuple:
115
+ parts = []
116
+ for chunk in version.split(".")[:3]:
117
+ num = "".join(ch for ch in chunk if ch.isdigit())
118
+ parts.append(int(num) if num else 0)
119
+ while len(parts) < 3:
120
+ parts.append(0)
121
+ return tuple(parts)
122
+
123
+
124
+ def is_newer(latest: Optional[str], current: str = __version__) -> bool:
125
+ if not latest:
126
+ return False
127
+ try:
128
+ return Version(latest) > Version(current)
129
+ except InvalidVersion:
130
+ # Fallback for any unexpected non-PEP440 version strings.
131
+ return _parse(latest) > _parse(current)
132
+ except Exception:
133
+ return False
134
+
135
+
136
+ def upgrade_hint(latest: str, release_notes_url: str = RELEASE_NOTES_URL) -> str:
137
+ return (
138
+ f"A new release of cu-cli is available: {__version__} -> {latest}.\n"
139
+ f" Upgrade with: cu upgrade\n"
140
+ f" Release notes: {release_notes_url}"
141
+ )
142
+
143
+
144
+ def maybe_notify(*, stream=None) -> None:
145
+ """Implicit end-of-command check. Cached + time-limited; never blocks long."""
146
+ if _checks_disabled():
147
+ return
148
+ latest = fetch_latest_version(use_cache=True)
149
+ if latest and is_newer(latest):
150
+ from .output import console
151
+ provider = get_update_provider()
152
+ console.print(
153
+ f"[dim]{upgrade_hint(latest, provider.release_notes_url)}[/dim]"
154
+ )
@@ -0,0 +1,92 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Update source abstraction used by the shared upgrade workflow."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from importlib.metadata import entry_points
11
+ from typing import Mapping, Protocol, Tuple
12
+ from urllib.error import HTTPError
13
+ from urllib.request import Request, urlopen
14
+
15
+
16
+ PYPI_URL = "https://pypi.org/pypi/cu-cli/json"
17
+ RELEASE_NOTES_URL = "https://github.com/Azure/content-understanding-toolkit/releases"
18
+ SOURCE_INSTALL_HINT = (
19
+ "git clone https://github.com/Azure/content-understanding-toolkit && "
20
+ "pip install -e content-understanding-toolkit/cu-cli"
21
+ )
22
+ _NETWORK_TIMEOUT = 1.5
23
+
24
+
25
+ class UpdateProvider(Protocol):
26
+ """Supply release discovery and pip configuration to the upgrade engine."""
27
+
28
+ name: str
29
+ release_notes_url: str
30
+ source_install_hint: str
31
+
32
+ def fetch_latest_version(self) -> Tuple[str | None, str]: ...
33
+
34
+ def pip_environment(self) -> Mapping[str, str]: ...
35
+
36
+
37
+ class PyPIUpdateProvider:
38
+ """Default update provider for public releases."""
39
+
40
+ name = "PyPI"
41
+ release_notes_url = RELEASE_NOTES_URL
42
+ source_install_hint = SOURCE_INSTALL_HINT
43
+
44
+ def fetch_latest_version(self) -> Tuple[str | None, str]:
45
+ try:
46
+ req = Request(PYPI_URL, headers={"Accept": "application/json"})
47
+ with urlopen(req, timeout=_NETWORK_TIMEOUT) as resp: # noqa: S310
48
+ payload = json.loads(resp.read().decode("utf-8"))
49
+ latest = payload.get("info", {}).get("version")
50
+ return (latest, "ok") if latest else (None, "network_error")
51
+ except HTTPError as exc:
52
+ return None, "not_published" if exc.code == 404 else "network_error"
53
+ except Exception:
54
+ return None, "network_error"
55
+
56
+ def pip_environment(self) -> Mapping[str, str]:
57
+ return {}
58
+
59
+
60
+ def get_update_provider() -> UpdateProvider:
61
+ """Load one installed update-provider extension, or use public PyPI."""
62
+ providers = list(entry_points(group="cu_cli.update_providers"))
63
+ if not providers:
64
+ return PyPIUpdateProvider()
65
+ if len(providers) != 1:
66
+ names = ", ".join(sorted(provider.name for provider in providers))
67
+ raise RuntimeError(f"Expected one cu-cli update provider, found: {names}")
68
+ provider = providers[0].load()()
69
+ if not all(
70
+ hasattr(provider, attribute)
71
+ for attribute in (
72
+ "name",
73
+ "release_notes_url",
74
+ "source_install_hint",
75
+ "fetch_latest_version",
76
+ "pip_environment",
77
+ )
78
+ ):
79
+ raise RuntimeError(f"Invalid cu-cli update provider: {providers[0].name}")
80
+ return provider
81
+
82
+
83
+ def pip_install_args(latest: str) -> list[str]:
84
+ """Return the shared, exact-version pip upgrade arguments."""
85
+ return [
86
+ sys.executable,
87
+ "-m",
88
+ "pip",
89
+ "install",
90
+ "--upgrade",
91
+ f"cu-cli=={latest}",
92
+ ]