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.
- cu_cli/__init__.py +17 -0
- cu_cli/__main__.py +11 -0
- cu_cli/apiversion.py +124 -0
- cu_cli/cli.py +138 -0
- cu_cli/client.py +138 -0
- cu_cli/commands/__init__.py +4 -0
- cu_cli/commands/_command_spec.py +94 -0
- cu_cli/commands/_help.py +33 -0
- cu_cli/commands/_infra_models.py +184 -0
- cu_cli/commands/_infra_wizard.py +630 -0
- cu_cli/commands/_model_setup.py +46 -0
- cu_cli/commands/_options.py +112 -0
- cu_cli/commands/analyze.py +631 -0
- cu_cli/commands/analyzer.py +1462 -0
- cu_cli/commands/defaults.py +172 -0
- cu_cli/commands/doctor.py +166 -0
- cu_cli/commands/env_var.py +67 -0
- cu_cli/commands/infra.py +302 -0
- cu_cli/commands/profile_cmd.py +525 -0
- cu_cli/commands/upgrade.py +120 -0
- cu_cli/core/__init__.py +17 -0
- cu_cli/core/analyze.py +44 -0
- cu_cli/core/analyzers.py +30 -0
- cu_cli/core/azure_resources.py +486 -0
- cu_cli/core/defaults.py +18 -0
- cu_cli/core/doctor.py +42 -0
- cu_cli/core/foundry.py +68 -0
- cu_cli/core/infra_models.py +367 -0
- cu_cli/core/inputs.py +209 -0
- cu_cli/core/schema.py +24 -0
- cu_cli/errors.py +174 -0
- cu_cli/exit_codes.py +20 -0
- cu_cli/modality.py +24 -0
- cu_cli/output.py +179 -0
- cu_cli/profile.py +30 -0
- cu_cli/py.typed +0 -0
- cu_cli/resources/__init__.py +4 -0
- cu_cli/resources/azd_template/README.md +187 -0
- cu_cli/resources/azd_template/azure.yaml +27 -0
- cu_cli/resources/azd_template/hooks/postprovision.ps1 +320 -0
- cu_cli/resources/azd_template/hooks/postprovision.sh +299 -0
- cu_cli/resources/azd_template/infra/main.bicep +115 -0
- cu_cli/resources/azd_template/infra/main.parameters.json +30 -0
- cu_cli/resources/azd_template/infra/models.json +1 -0
- cu_cli/resources/azd_template/infra/modules/foundry.bicep +122 -0
- cu_cli/schema_validate.py +28 -0
- cu_cli/spec_validate.py +18 -0
- cu_cli/telemetry.py +42 -0
- cu_cli/update_check.py +154 -0
- cu_cli/update_provider.py +92 -0
- cu_cli/windows_self_upgrade.py +245 -0
- cu_cli-0.1.0b1.dist-info/METADATA +345 -0
- cu_cli-0.1.0b1.dist-info/RECORD +56 -0
- cu_cli-0.1.0b1.dist-info/WHEEL +5 -0
- cu_cli-0.1.0b1.dist-info/entry_points.txt +3 -0
- 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 @@
|
|
|
1
|
+
[]
|
|
@@ -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
|
+
]
|
cu_cli/spec_validate.py
ADDED
|
@@ -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
|
+
]
|