refactor: optimize duplicate model validation and improve error handling (#219)
* feat: prevent duplicate model names under same provider Implement case-insensitive validation to prevent users from creating duplicate model names under the same provider. This validation is implemented both in the backend API and the frontend UI. Changes: - Backend: Add duplicate check in create_model endpoint (case-insensitive) - Frontend: Add client-side validation in AddModelForm - Frontend: Improve error message display from backend - Tests: Add unit tests for duplicate model validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: optimize duplicate model validation and improve error handling - Replace O(n) model iteration with efficient SurrealDB query for duplicate check - Improve error message to include model name and provider for better UX - Remove frontend duplicate validation (backend-only enforcement) - Fix test authentication by setting OPEN_NOTEBOOK_PASSWORD before imports - Update test mocking to use repo_query instead of Model.get_all() - Add pytest fixture for TestClient to ensure proper test isolation All 11 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * remove unnecessary package * fix: replace any with unknown type in error handler - Change error type from 'any' to 'unknown' to satisfy ESLint - Add proper type assertion for error object structure - Maintains same runtime behavior with better type safety --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a0a2282bfa
commit
a287d3b248
7 changed files with 130 additions and 40 deletions
|
|
@ -67,17 +67,29 @@ async def create_model(model_data: ModelCreate):
|
|||
valid_types = ["language", "embedding", "text_to_speech", "speech_to_text"]
|
||||
if model_data.type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=400,
|
||||
detail=f"Invalid model type. Must be one of: {valid_types}"
|
||||
)
|
||||
|
||||
|
||||
# Check for duplicate model name under the same provider (case-insensitive)
|
||||
from open_notebook.database.repository import repo_query
|
||||
existing = await repo_query(
|
||||
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND string::lowercase(name) = $name LIMIT 1",
|
||||
{"provider": model_data.provider.lower(), "name": model_data.name.lower()}
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Model '{model_data.name}' already exists for provider '{model_data.provider}'"
|
||||
)
|
||||
|
||||
new_model = Model(
|
||||
name=model_data.name,
|
||||
provider=model_data.provider,
|
||||
type=model_data.type,
|
||||
)
|
||||
await new_model.save()
|
||||
|
||||
|
||||
return ModelResponse(
|
||||
id=new_model.id or "",
|
||||
name=new_model.name,
|
||||
|
|
@ -86,6 +98,8 @@ async def create_model(model_data: ModelCreate):
|
|||
created=str(new_model.created),
|
||||
updated=str(new_model.updated),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except InvalidInputError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
|
|||
})
|
||||
|
||||
// Get available providers that support this model type
|
||||
const availableProviders = providers.available.filter(provider =>
|
||||
const availableProviders = providers.available.filter(provider =>
|
||||
providers.supported_types[provider]?.includes(modelType)
|
||||
)
|
||||
|
||||
|
|
@ -63,8 +63,15 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
|
|||
)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (!isOpen) {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
|
|
@ -109,7 +116,7 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
|
|||
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{modelType === 'language' && watch('provider') === 'azure' &&
|
||||
{modelType === 'language' && watch('provider') === 'azure' &&
|
||||
'For Azure, use the deployment name as the model name'}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,10 +38,11 @@ export function useCreateModel() {
|
|||
description: 'Model created successfully',
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Failed to create model'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to create model',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ dependencies = [
|
|||
"groq>=0.12.0",
|
||||
"python-dotenv>=1.0.1",
|
||||
"httpx[socks]>=0.27.0",
|
||||
"nest-asyncio>=1.6.0",
|
||||
"content-core>=1.0.2",
|
||||
"ai-prompter>=0.3",
|
||||
"esperanto>=2.4.1",
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import os
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure password auth is disabled for tests BEFORE any imports
|
||||
# The PasswordAuthMiddleware skips auth when this env var is not set
|
||||
# Set to empty string instead of deleting to prevent it from being reloaded
|
||||
os.environ["OPEN_NOTEBOOK_PASSWORD"] = ""
|
||||
|
||||
# Add the project root to the Python path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# Ensure password auth is disabled for tests
|
||||
# The PasswordAuthMiddleware skips auth when this env var is not set
|
||||
if "OPEN_NOTEBOOK_PASSWORD" in os.environ:
|
||||
del os.environ["OPEN_NOTEBOOK_PASSWORD"]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,84 @@
|
|||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create test client after environment variables have been cleared by conftest."""
|
||||
from api.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestModelCreation:
|
||||
"""Test suite for Model Creation endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("open_notebook.database.repository.repo_query")
|
||||
@patch("api.routers.models.Model.save")
|
||||
async def test_create_duplicate_model_same_case(self, mock_save, mock_repo_query, client):
|
||||
"""Test that creating a duplicate model with same case returns 400."""
|
||||
# Mock repo_query to return a duplicate model
|
||||
mock_repo_query.return_value = [{"id": "model:123", "name": "gpt-4", "provider": "openai", "type": "language"}]
|
||||
|
||||
# Attempt to create duplicate
|
||||
response = client.post(
|
||||
"/api/models",
|
||||
json={
|
||||
"name": "gpt-4",
|
||||
"provider": "openai",
|
||||
"type": "language"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Model 'gpt-4' already exists for provider 'openai'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("open_notebook.database.repository.repo_query")
|
||||
@patch("api.routers.models.Model.save")
|
||||
async def test_create_duplicate_model_different_case(self, mock_save, mock_repo_query, client):
|
||||
"""Test that creating a duplicate model with different case returns 400."""
|
||||
# Mock repo_query to return a duplicate model (case-insensitive match)
|
||||
mock_repo_query.return_value = [{"id": "model:123", "name": "gpt-4", "provider": "openai", "type": "language"}]
|
||||
|
||||
# Attempt to create duplicate with different case
|
||||
response = client.post(
|
||||
"/api/models",
|
||||
json={
|
||||
"name": "GPT-4",
|
||||
"provider": "OpenAI",
|
||||
"type": "language"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Model 'GPT-4' already exists for provider 'OpenAI'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("open_notebook.database.repository.repo_query")
|
||||
async def test_create_same_model_name_different_provider(self, mock_repo_query, client):
|
||||
"""Test that creating a model with same name but different provider is allowed."""
|
||||
from open_notebook.domain.models import Model
|
||||
|
||||
# Mock repo_query to return empty (no duplicate found for different provider)
|
||||
mock_repo_query.return_value = []
|
||||
|
||||
# Patch the save method on the Model class
|
||||
with patch.object(Model, 'save', new_callable=AsyncMock) as mock_save:
|
||||
# Attempt to create same model name with different provider (anthropic)
|
||||
response = client.post(
|
||||
"/api/models",
|
||||
json={
|
||||
"name": "gpt-4",
|
||||
"provider": "anthropic",
|
||||
"type": "language"
|
||||
}
|
||||
)
|
||||
|
||||
# Should succeed because provider is different
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestModelsProviderAvailability:
|
||||
|
|
@ -13,7 +86,7 @@ class TestModelsProviderAvailability:
|
|||
|
||||
@patch("api.routers.models.os.environ.get")
|
||||
@patch("api.routers.models.AIFactory.get_available_providers")
|
||||
def test_generic_env_var_enables_all_modes(self, mock_esperanto, mock_env):
|
||||
def test_generic_env_var_enables_all_modes(self, mock_esperanto, mock_env, client):
|
||||
"""Test that OPENAI_COMPATIBLE_BASE_URL enables all 4 modes."""
|
||||
|
||||
# Mock environment: only generic var is set
|
||||
|
|
@ -51,7 +124,7 @@ class TestModelsProviderAvailability:
|
|||
|
||||
@patch("api.routers.models.os.environ.get")
|
||||
@patch("api.routers.models.AIFactory.get_available_providers")
|
||||
def test_mode_specific_env_vars_llm_embedding(self, mock_esperanto, mock_env):
|
||||
def test_mode_specific_env_vars_llm_embedding(self, mock_esperanto, mock_env, client):
|
||||
"""Test mode-specific env vars (LLM + EMBEDDING) enable only those 2 modes."""
|
||||
|
||||
# Mock environment: only LLM and EMBEDDING specific vars are set
|
||||
|
|
@ -91,7 +164,7 @@ class TestModelsProviderAvailability:
|
|||
|
||||
@patch("api.routers.models.os.environ.get")
|
||||
@patch("api.routers.models.AIFactory.get_available_providers")
|
||||
def test_no_env_vars_set(self, mock_esperanto, mock_env):
|
||||
def test_no_env_vars_set(self, mock_esperanto, mock_env, client):
|
||||
"""Test that openai-compatible is not available when no env vars are set."""
|
||||
|
||||
# Mock environment: no openai-compatible vars are set
|
||||
|
|
@ -120,7 +193,7 @@ class TestModelsProviderAvailability:
|
|||
|
||||
@patch("api.routers.models.os.environ.get")
|
||||
@patch("api.routers.models.AIFactory.get_available_providers")
|
||||
def test_mixed_config_generic_and_mode_specific(self, mock_esperanto, mock_env):
|
||||
def test_mixed_config_generic_and_mode_specific(self, mock_esperanto, mock_env, client):
|
||||
"""Test mixed config: generic + mode-specific (generic should enable all)."""
|
||||
|
||||
# Mock environment: both generic and mode-specific vars are set
|
||||
|
|
@ -160,7 +233,7 @@ class TestModelsProviderAvailability:
|
|||
|
||||
@patch("api.routers.models.os.environ.get")
|
||||
@patch("api.routers.models.AIFactory.get_available_providers")
|
||||
def test_individual_mode_llm_only(self, mock_esperanto, mock_env):
|
||||
def test_individual_mode_llm_only(self, mock_esperanto, mock_env, client):
|
||||
"""Test individual mode-specific var (LLM only)."""
|
||||
|
||||
# Mock environment: only LLM specific var is set
|
||||
|
|
@ -190,7 +263,7 @@ class TestModelsProviderAvailability:
|
|||
|
||||
@patch("api.routers.models.os.environ.get")
|
||||
@patch("api.routers.models.AIFactory.get_available_providers")
|
||||
def test_individual_mode_embedding_only(self, mock_esperanto, mock_env):
|
||||
def test_individual_mode_embedding_only(self, mock_esperanto, mock_env, client):
|
||||
"""Test individual mode-specific var (EMBEDDING only)."""
|
||||
|
||||
# Mock environment: only EMBEDDING specific var is set
|
||||
|
|
@ -220,7 +293,7 @@ class TestModelsProviderAvailability:
|
|||
|
||||
@patch("api.routers.models.os.environ.get")
|
||||
@patch("api.routers.models.AIFactory.get_available_providers")
|
||||
def test_individual_mode_stt_only(self, mock_esperanto, mock_env):
|
||||
def test_individual_mode_stt_only(self, mock_esperanto, mock_env, client):
|
||||
"""Test individual mode-specific var (STT only)."""
|
||||
|
||||
# Mock environment: only STT specific var is set
|
||||
|
|
@ -250,7 +323,7 @@ class TestModelsProviderAvailability:
|
|||
|
||||
@patch("api.routers.models.os.environ.get")
|
||||
@patch("api.routers.models.AIFactory.get_available_providers")
|
||||
def test_individual_mode_tts_only(self, mock_esperanto, mock_env):
|
||||
def test_individual_mode_tts_only(self, mock_esperanto, mock_env, client):
|
||||
"""Test individual mode-specific var (TTS only)."""
|
||||
|
||||
# Mock environment: only TTS specific var is set
|
||||
|
|
|
|||
26
uv.lock
26
uv.lock
|
|
@ -2241,7 +2241,6 @@ dependencies = [
|
|||
{ name = "langgraph" },
|
||||
{ name = "langgraph-checkpoint-sqlite" },
|
||||
{ name = "loguru" },
|
||||
{ name = "nest-asyncio" },
|
||||
{ name = "podcast-creator" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
|
|
@ -2294,7 +2293,6 @@ requires-dist = [
|
|||
{ name = "langgraph-checkpoint-sqlite", specifier = ">=2.0.0" },
|
||||
{ name = "loguru", specifier = ">=0.7.2" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11.1" },
|
||||
{ name = "nest-asyncio", specifier = ">=1.6.0" },
|
||||
{ name = "podcast-creator", specifier = ">=0.7.0" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.1" },
|
||||
{ name = "pydantic", specifier = ">=2.9.2" },
|
||||
|
|
@ -2597,11 +2595,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pip"
|
||||
version = "25.2"
|
||||
version = "25.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/16/650289cd3f43d5a2fadfd98c68bd1e1e7f2550a1a5326768cddfbcedb2c5/pip-25.2.tar.gz", hash = "sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2", size = 1840021, upload-time = "2025-07-30T21:50:15.401Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/3f/945ef7ab14dc4f9d7f40288d2df998d1837ee0888ec3659c813487572faa/pip-25.2-py3-none-any.whl", hash = "sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717", size = 1752557, upload-time = "2025-07-30T21:50:13.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2754,18 +2752,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.1.1"
|
||||
version = "7.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/fc/889242351a932d6183eec5df1fc6539b6f36b6a88444f1e63f18668253aa/psutil-7.1.1.tar.gz", hash = "sha256:092b6350145007389c1cfe5716050f02030a05219d90057ea867d18fe8d372fc", size = 487067, upload-time = "2025-10-19T15:43:59.373Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/ec/7b8e6b9b1d22708138630ef34c53ab2b61032c04f16adfdbb96791c8c70c/psutil-7.1.2.tar.gz", hash = "sha256:aa225cdde1335ff9684708ee8c72650f6598d5ed2114b9a7c5802030b1785018", size = 487424, upload-time = "2025-10-25T10:46:34.931Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/30/f97f8fb1f9ecfbeae4b5ca738dcae66ab28323b5cfbc96cb5565f3754056/psutil-7.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:8fa59d7b1f01f0337f12cd10dbd76e4312a4d3c730a4fedcbdd4e5447a8b8460", size = 244221, upload-time = "2025-10-19T15:44:03.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/98/b8d1f61ebf35f4dbdbaabadf9208282d8adc820562f0257e5e6e79e67bf2/psutil-7.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:2a95104eae85d088891716db676f780c1404fc15d47fde48a46a5d61e8f5ad2c", size = 245660, upload-time = "2025-10-19T15:44:05.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/4a/b8015d7357fefdfe34bc4a3db48a107bae4bad0b94fb6eb0613f09a08ada/psutil-7.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:98629cd8567acefcc45afe2f4ba1e9290f579eacf490a917967decce4b74ee9b", size = 286963, upload-time = "2025-10-19T15:44:08.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/3c/b56076bb35303d0733fc47b110a1c9cce081a05ae2e886575a3587c1ee76/psutil-7.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92ebc58030fb054fa0f26c3206ef01c31c29d67aee1367e3483c16665c25c8d2", size = 290118, upload-time = "2025-10-19T15:44:11.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/af/c13d360c0adc6f6218bf9e2873480393d0f729c8dd0507d171f53061c0d3/psutil-7.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:146a704f224fb2ded2be3da5ac67fc32b9ea90c45b51676f9114a6ac45616967", size = 292587, upload-time = "2025-10-19T15:44:14.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/2d/c933e7071ba60c7862813f2c7108ec4cf8304f1c79660efeefd0de982258/psutil-7.1.1-cp37-abi3-win32.whl", hash = "sha256:295c4025b5cd880f7445e4379e6826f7307e3d488947bf9834e865e7847dc5f7", size = 243772, upload-time = "2025-10-19T15:44:16.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/f3/11fd213fff15427bc2853552138760c720fd65032d99edfb161910d04127/psutil-7.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:9b4f17c5f65e44f69bd3a3406071a47b79df45cf2236d1f717970afcb526bcd3", size = 246936, upload-time = "2025-10-19T15:44:18.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/8d/8a9a45c8b655851f216c1d44f68e3533dc8d2c752ccd0f61f1aa73be4893/psutil-7.1.1-cp37-abi3-win_arm64.whl", hash = "sha256:5457cf741ca13da54624126cd5d333871b454ab133999a9a103fb097a7d7d21a", size = 243944, upload-time = "2025-10-19T15:44:20.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/89/b9f8d47ddbc52d7301fc868e8224e5f44ed3c7f55e6d0f54ecaf5dd9ff5e/psutil-7.1.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c9ba5c19f2d46203ee8c152c7b01df6eec87d883cfd8ee1af2ef2727f6b0f814", size = 237244, upload-time = "2025-10-25T10:47:07.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7a/8628c2f6b240680a67d73d8742bb9ff39b1820a693740e43096d5dcb01e5/psutil-7.1.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:2a486030d2fe81bec023f703d3d155f4823a10a47c36784c84f1cc7f8d39bedb", size = 238101, upload-time = "2025-10-25T10:47:09.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/28/5e27f4d5a0e347f8e3cc16cd7d35533dbce086c95807f1f0e9cd77e26c10/psutil-7.1.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3efd8fc791492e7808a51cb2b94889db7578bfaea22df931424f874468e389e3", size = 258675, upload-time = "2025-10-25T10:47:11.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/5c/79cf60c9acf36d087f0db0f82066fca4a780e97e5b3a2e4c38209c03d170/psutil-7.1.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2aeb9b64f481b8eabfc633bd39e0016d4d8bbcd590d984af764d80bf0851b8a", size = 260203, upload-time = "2025-10-25T10:47:13.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/03/0a464404c51685dcb9329fdd660b1721e076ccd7b3d97dee066bcc9ffb15/psutil-7.1.2-cp37-abi3-win_amd64.whl", hash = "sha256:8e17852114c4e7996fe9da4745c2bdef001ebbf2f260dec406290e66628bdb91", size = 246714, upload-time = "2025-10-25T10:47:15.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/32/97ca2090f2f1b45b01b6aa7ae161cfe50671de097311975ca6eea3e7aabc/psutil-7.1.2-cp37-abi3-win_arm64.whl", hash = "sha256:3e988455e61c240cc879cb62a008c2699231bf3e3d061d7fce4234463fd2abb4", size = 243742, upload-time = "2025-10-25T10:47:17.302Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue