1
0
forked from erp-dev/erp

fix: rebuild stock change record

This commit is contained in:
2025-11-05 16:57:54 +08:00
parent d88bf367f8
commit a759030cdc
16 changed files with 1116 additions and 66 deletions

0
api_v1/__init__.py Normal file
View File

6
api_v1/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiV1Config(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api_v1'

86
api_v1/serializers.py Normal file
View File

@@ -0,0 +1,86 @@
from rest_framework import serializers
from decimal import Decimal
from typing import List, Dict, Any
from stock import models as stock_models
from basic_info import models as basic_info_models
class ProductStockChangeSerializer(serializers.Serializer):
"""产品库存变动序列化器"""
product = serializers.IntegerField(help_text="产品ID")
quantity = serializers.ListField(
child=serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal('0.01')),
min_length=1,
help_text="数量列表,每个数量对应一条明细记录"
)
def validate_product(self, value: int) -> int:
"""验证产品是否存在"""
try:
basic_info_models.Product.objects.get(id=value)
except basic_info_models.Product.DoesNotExist:
raise serializers.ValidationError(f"产品ID {value} 不存在")
return value
class CreateStockChangeSerializer(serializers.Serializer):
"""创建库存变动记录序列化器"""
stock_change_record_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text="库存变动记录ID如果不提供则创建新记录"
)
products = ProductStockChangeSerializer(many=True, help_text="产品列表")
def validate_stock_change_record_id(self, value: int) -> int:
"""验证库存变动记录是否存在"""
if value is not None:
try:
stock_models.StockChangeRecord.objects.get(id=value)
except stock_models.StockChangeRecord.DoesNotExist:
raise serializers.ValidationError(f"库存变动记录ID {value} 不存在")
return value
def validate_products(self, value: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""验证产品列表"""
if not value:
raise serializers.ValidationError("产品列表不能为空")
# 检查是否有重复的产品ID
product_ids = [item['product'] for item in value]
if len(product_ids) != len(set(product_ids)):
raise serializers.ValidationError("产品列表中存在重复的产品ID")
return value
class StockChangeRecordResponseSerializer(serializers.ModelSerializer):
"""库存变动记录响应序列化器"""
class Meta:
model = stock_models.StockChangeRecord
fields = [
'id', 'type', 'warehouse', 'source_type', 'source_id',
'is_finished', 'finished_at', 'created_at', 'updated_at'
]
class StockChangeDetailResponseSerializer(serializers.ModelSerializer):
"""库存变动明细响应序列化器"""
product_name = serializers.CharField(source='product.name', read_only=True)
unit_display = serializers.CharField(source='get_unit_display', read_only=True)
class Meta:
model = stock_models.StockChangeDetail
fields = [
'id', 'product', 'product_name', 'quantity',
'unit', 'unit_display', 'stock_change_record'
]
class CreateStockChangeResponseSerializer(serializers.Serializer):
"""创建库存变动记录响应序列化器"""
stock_change_record = StockChangeRecordResponseSerializer()
details = StockChangeDetailResponseSerializer(many=True)
message = serializers.CharField()
created_details_count = serializers.IntegerField()

3
api_v1/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

7
api_v1/urls.py Normal file
View File

@@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
# 库存变动相关API
path('stock-change/', views.create_full_stock_change, name='create_full_stock_change'),
]

136
api_v1/views.py Normal file
View File

@@ -0,0 +1,136 @@
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.db import transaction
from typing import List, Dict, Any
import logging
from stock import models as stock_models
from basic_info import models as basic_info_models
from drf_spectacular.utils import extend_schema
from . import serializers
logger = logging.getLogger(__name__)
@extend_schema(tags=['创建出入库'])
@api_view(['POST'])
def create_full_stock_change(request):
"""
创建完整的库存变动记录(包含记录创建参数)
POST /api/v1/stock-change/
请求参数:
{
"type": 1, // 1=入库, 2=出库
"warehouse": 1, // 仓库ID
"source_type": 1, // 来源类型
"source_id": 1, // 可选来源单据ID
"products": [
{
"product": 1,
"quantity": [85, 75, 90]
}
]
}
"""
# 提取库存变动记录参数
record_data = {
'type': request.data.get('type'),
'warehouse': request.data.get('warehouse'),
'source_type': request.data.get('source_type'),
'source_id': request.data.get('source_id'),
}
# 验证必要参数
if not all([record_data['type'], record_data['warehouse'], record_data['source_type']]):
return Response({
'error': '缺少必要参数',
'message': '请提供 type, warehouse, source_type'
}, status=status.HTTP_400_BAD_REQUEST)
products_data = request.data.get('products', [])
if not products_data:
return Response({
'error': '产品列表不能为空'
}, status=status.HTTP_400_BAD_REQUEST)
# 验证产品数据
product_serializer = serializers.CreateStockChangeSerializer(data={
'products': products_data
})
if not product_serializer.is_valid():
return Response({
'error': '产品数据验证失败',
'details': product_serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
try:
with transaction.atomic():
# 1. 创建库存变动记录
try:
warehouse = basic_info_models.WareHouse.objects.get(id=record_data['warehouse'])
except basic_info_models.WareHouse.DoesNotExist:
return Response({
'error': f'仓库ID {record_data["warehouse"]} 不存在'
}, status=status.HTTP_400_BAD_REQUEST)
stock_change_record = stock_models.StockChangeRecord.objects.create(
type=record_data['type'],
warehouse=warehouse,
source_type=record_data['source_type'],
source_id=record_data['source_id'],
# created_by=request.user
)
logger.info(f"创建新库存变动记录 ID: {stock_change_record.id}")
# 2. 创建库存变动明细
created_details = []
created_count = 0
for product_data in products_data:
product_id = product_data['product']
quantities = product_data['quantity']
# 获取产品信息
try:
product = basic_info_models.Product.objects.get(id=product_id)
except basic_info_models.Product.DoesNotExist:
raise ValueError(f'产品ID {product_id} 不存在')
# 为每个数量创建明细记录
for quantity in quantities:
detail = stock_models.StockChangeDetail.objects.create(
stock_change_record=stock_change_record,
product=product,
quantity=quantity,
unit=product.unit
)
created_details.append(detail)
created_count += 1
# 3. 构建响应
response_serializer = serializers.CreateStockChangeResponseSerializer({
'stock_change_record': stock_change_record,
'details': created_details,
'message': f'成功创建库存变动记录及 {created_count} 条明细',
'created_details_count': created_count
})
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
except ValueError as e:
return Response({
'error': str(e)
}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.error(f"创建完整库存变动记录失败: {str(e)}", exc_info=True)
return Response({
'error': '创建库存变动记录失败',
'message': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -18,6 +18,7 @@ except ImportError:
import django.utils.encoding
django.utils.encoding.force_text = force_text
from datetime import timedelta
from pathlib import Path
from environ import Env
@@ -58,8 +59,11 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles', # 使用七牛云存储不需要staticfiles app
'rest_framework',
'drf_spectacular',
'basic_info',
'stock',
'api_v1',
]
MIDDLEWARE = [
@@ -72,6 +76,31 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
# JWT 配置
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(days=7),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'UPDATE_LAST_LOGIN': True,
}
SPECTACULAR_SETTINGS = {
'TITLE': 'Flower API',
'DESCRIPTION': 'API documentation for the Flower project',
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False,
}
ROOT_URLCONF = 'flower.urls'
TEMPLATES = [

View File

@@ -15,10 +15,25 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.urls import path, include
from stock.views import router
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns = [
# JWT 登录
path('api/auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
# path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
# API 文档
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('admin/', admin.site.urls),
path('stock/', router.urls),
path('api/v1/', include('api_v1.urls')),
]

View File

@@ -8,9 +8,14 @@ dependencies = [
"django>=5.2.7",
"django-cors>=0.1",
"django-environ>=0.12.0",
"django-filter>=25.2",
"django-ninja>=1.4.5",
"django-qiniu-storage>=2.3.1",
"django-simpleui>=2025.6.24",
"djangorestframework>=3.16.1",
"djangorestframework-simplejwt>=5.5.1",
"drf-spectacular>=0.29.0",
"markdown>=3.10",
"pillow>=12.0.0",
]

View File

@@ -1,5 +1,6 @@
from django.contrib import admin
from django.contrib.admin import action
from django.utils.safestring import mark_safe
from . import models, services
@@ -58,29 +59,43 @@ class StockSnapshotAdmin(admin.ModelAdmin):
def has_delete_permission(self, request, obj = ...):
# 禁止通过admin界面删除库存快照
return False
return True
def get_readonly_fields(self, request, obj = ...):
# 所有字段均为只读
return [field.name for field in self.model._meta.fields]
@admin.register(models.StockChangeDetail)
class StockChangeDetailAdmin(admin.ModelAdmin):
list_display = (
'id',
'stock_change_record',
'product',
'quantity',
'unit',
)
search_fields = ('stock_change_record__id', 'product__name')
list_filter = ('stock_change_record', 'stock_change_record__type')
ordering = ('-created_at',)
@admin.register(models.StockChangeRecord)
class StockChangeRecordAdmin(admin.ModelAdmin):
list_display = (
'id',
'type',
'product',
'warehouse',
'quantity',
'details',
'source_type',
'source_id',
'num_of_rolls',
'is_finished',
'finished_at',
'created_by',
'created_at',
)
search_fields = ('product__name', 'warehouse__name', 'source_id')
search_fields = ('warehouse__name', 'source_id')
list_filter = ('type', 'source_type', 'type', 'is_finished', 'created_at')
ordering = ('-created_at', '-updated_at')
readonly_fields = ('created_by', 'is_finished', 'finished_at')
@@ -103,4 +118,17 @@ class StockChangeRecordAdmin(admin.ModelAdmin):
record = queryset.first()
services.make_stock_change_completed(record)
@admin.display(description='匹数')
def num_of_rolls(self, obj: models.StockChangeRecord):
return obj.num_of_rolls
@admin.display(description='明细概览')
def details(self, obj):
details = obj.details.all()
detail_list = [
f'{detail.product.name}, {detail.quantity}/{detail.get_unit_display()}'
for detail in details
]
return mark_safe('<br>'.join(detail_list))
actions = [make_selected_stock_changes_completed]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.7 on 2025-11-05 05:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('basic_info', '0017_product_image'),
('stock', '0030_alter_stocksnapshot_delta'),
]
operations = [
migrations.RemoveField(
model_name='stockchangerecord',
name='num_of_rolls',
),
migrations.RemoveField(
model_name='stockchangerecord',
name='product',
),
migrations.RemoveField(
model_name='stockchangerecord',
name='quantity',
),
migrations.CreateModel(
name='StockChangeDetail',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('quantity', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='数量')),
('unit', models.IntegerField(choices=[(1, ''), (2, ''), (3, '公斤')], verbose_name='单位')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stock_change_details', to='basic_info.product', verbose_name='产品')),
('stock_change_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='details', to='stock.stockchangerecord', verbose_name='库存变动记录')),
],
options={
'verbose_name': '库存变动明细',
'verbose_name_plural': '库存变动明细',
},
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2025-11-05 06:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0031_remove_stockchangerecord_num_of_rolls_and_more'),
]
operations = [
migrations.RemoveField(
model_name='stockchangerecord',
name='unit',
),
]

View File

@@ -2,6 +2,7 @@ from django.db import models
from django.core.exceptions import ValidationError
from flower.common import ModelBase
from basic_info import models as basic_info_models
from functools import reduce
import decimal
@@ -48,12 +49,6 @@ class StockChangeRecord(ModelBase):
id = models.BigAutoField(primary_key=True)
type = models.IntegerField(choices=StockChangeTypeEnum.choices, verbose_name='变动类型')
product = models.ForeignKey(
basic_info_models.Product,
on_delete=models.PROTECT,
related_name='stock_change_records',
verbose_name='产品',
)
warehouse = models.ForeignKey(
basic_info_models.WareHouse,
on_delete=models.PROTECT,
@@ -64,14 +59,6 @@ class StockChangeRecord(ModelBase):
choices=StockChangeSourceEnum.choices,
verbose_name='变动来源',
)
unit = models.IntegerField(
verbose_name='单位',
choices=basic_info_models.ProductUnitEnum.choices,
)
num_of_rolls = models.IntegerField(
default=1,
verbose_name='匹数',
)
created_by = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
@@ -79,14 +66,13 @@ class StockChangeRecord(ModelBase):
blank=True,
verbose_name='操作人',
)
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量')
source_id = models.BigIntegerField(null=True, blank=True, verbose_name='来源单据ID')
is_finished = models.BooleanField(default=False, verbose_name='完成出入库')
finished_at = models.DateTimeField(null=True, blank=True, verbose_name='完成时间')
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
def __str__(self):
return f'出入库记录 {self.id} - 仓库:{self.warehouse.name} - 产品: {self.product.name}'
return f'出入库记录 {self.id} - 仓库:{self.warehouse.name}'
class Meta:
verbose_name = '出入库记录'
@@ -115,17 +101,57 @@ class StockChangeRecord(ModelBase):
return self.type == StockChangeTypeEnum.REMOVE
@property
def delta(self) -> decimal.Decimal:
"""计算库存变动的数量增减"""
def num_of_rolls(self) -> int:
"""获取匹数"""
self.details.count()
return self.details.count()
@property
def delta_num_of_rolls(self) -> int:
"""计算库存变动的匹数增减"""
# 入库为正,出库为负
if self.type == StockChangeTypeEnum.ADD:
return self.quantity
return self.num_of_rolls
elif self.type == StockChangeTypeEnum.REMOVE:
return -self.quantity
return self.num_of_rolls * -1
else:
raise ValueError("未知的库存变动类型")
class StockChangeDetail(ModelBase):
"""库存变动明细模型"""
id = models.BigAutoField(primary_key=True)
product = models.ForeignKey(
basic_info_models.Product,
on_delete=models.PROTECT,
related_name='stock_change_details',
verbose_name='产品',
)
unit = models.IntegerField(
verbose_name='单位',
choices=basic_info_models.ProductUnitEnum.choices,
)
stock_change_record = models.ForeignKey(
StockChangeRecord,
on_delete=models.CASCADE,
related_name='details',
verbose_name='库存变动记录',
)
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量')
unit = models.IntegerField(
verbose_name='单位',
choices=basic_info_models.ProductUnitEnum.choices,
)
def __str__(self):
return f'库存变动明细 {self.id} - 记录ID: {self.stock_change_record.id}'
class Meta:
verbose_name = '库存变动明细'
verbose_name_plural = '库存变动明细'
class Inventory(ModelBase):
"""库存模型"""
@@ -217,7 +243,7 @@ class StockSnapshot(ModelBase):
)
def __str__(self):
return f'库存快照 {self.id} - 日期: {self.snapshot_date} - 产品: {self.product.name}'
return f'库存快照 {self.id} - 产品: {self.product.name}'
class Meta:
verbose_name = '库存快照'

View File

@@ -19,13 +19,14 @@ def find_inventory(product_id: int, warehouse_id: int) -> models.Inventory | Non
return None
def create_stock_snapshot(stock_change_record: models.StockChangeRecord, inventory: models.Inventory) -> models.StockSnapshot:
def create_stock_snapshot(stock_change_detail: models.StockChangeDetail, inventory: models.Inventory) -> models.StockSnapshot:
"""创建库存快照记录"""
if not stock_change_record or stock_change_record.id is None:
raise ValueError("无效的库存变动记录")
if not stock_change_detail or stock_change_detail.id is None:
raise ValueError("无效的库存变动明细记录")
delta = stock_change_record.delta
delta = stock_change_detail.quantity if stock_change_detail.stock_change_record.is_incoming else -stock_change_detail.quantity
rolls_delta = 1 if stock_change_detail.stock_change_record.is_incoming else -1
# 必须在库存变动发生后创建快照
snapshot = models.StockSnapshot(
@@ -34,9 +35,9 @@ def create_stock_snapshot(stock_change_record: models.StockChangeRecord, invento
delta=delta,
quantity_before=inventory.quantity - delta,
quantity_after=inventory.quantity,
stock_change_record_id=stock_change_record.id,
unit=stock_change_record.unit,
num_of_rolls=stock_change_record.num_of_rolls or 1,
stock_change_record_id=stock_change_detail.stock_change_record.id,
unit=stock_change_detail.unit,
num_of_rolls=inventory.num_of_rolls + rolls_delta,
)
snapshot.save()
logger.info(
@@ -47,7 +48,7 @@ def create_stock_snapshot(stock_change_record: models.StockChangeRecord, invento
def make_stock_change_completed(stock_change_record: models.StockChangeRecord) -> bool:
"""处理库存变动完成后的逻辑"""
"""处理库存变动完成后的逻辑, 实际扣减库存也在这里发生"""
# TODO: 这里可以添加事务处理以确保数据一致性
if not stock_change_record or stock_change_record.id is None:
@@ -57,44 +58,55 @@ def make_stock_change_completed(stock_change_record: models.StockChangeRecord) -
if stock_change_record.is_finished:
return True
inventory_record = find_inventory(
product_id=stock_change_record.product_id,
warehouse_id=stock_change_record.warehouse_id,
)
if not inventory_record:
# TODO: 逻辑更改 = 出入库单现在不记录库存总量了
# 需要根据产品分别统计变动数量(要考虑不同单位)
for detail in stock_change_record.details.all():
detail: models.StockChangeDetail
logger.info(
f'库存记录未找到产品ID: {stock_change_record.product_id}, '
f'仓库ID: {stock_change_record.warehouse_id},正在创建新的库存记录。'
f'处理库存变动明细产品ID: {detail.product_id}, '
f'数量: {detail.quantity}, 单位: {detail.get_unit_display()}'
)
# 如果没有找到库存记录且为入库,则创建一个新的库存记录
inventory_record = models.Inventory(
product_id=stock_change_record.product_id,
inventory_record = find_inventory(
product_id=detail.product_id,
warehouse_id=stock_change_record.warehouse_id,
quantity=stock_change_record.delta,
num_of_rolls=stock_change_record.num_of_rolls or 0,
minimum_quantity=0,
)
logger.info(
f'创建新的库存记录产品ID: {inventory_record.product_id}, '
f'仓库ID: {inventory_record.warehouse_id}, 数量: {inventory_record.quantity},'
f'匹数: {inventory_record.num_of_rolls}'
)
else:
# 更新现有库存记录
inventory_record.quantity += stock_change_record.delta
if stock_change_record.num_of_rolls:
inventory_record.num_of_rolls += stock_change_record.num_of_rolls
logger.info(
f'更新库存记录 {inventory_record.id},新数量: {inventory_record.quantity}, '
f'新匹数: {inventory_record.num_of_rolls}'
)
inventory_record.save()
create_stock_snapshot(stock_change_record, inventory_record)
# 确定方向
positive = 1 if stock_change_record.is_incoming else -1
if inventory_record:
# 更新现有库存记录
inventory_record.quantity += detail.quantity * positive
inventory_record.num_of_rolls += positive
logger.info(
f'更新库存记录 {inventory_record.id},新数量: {inventory_record.quantity}, '
f'新匹数: {inventory_record.num_of_rolls}'
)
inventory_record.save()
else:
# 创建新库存记录
inventory_record = models.Inventory(
product_id=detail.product_id,
warehouse_id=stock_change_record.warehouse_id,
quantity=detail.quantity * positive,
num_of_rolls=1 if stock_change_record.is_incoming else -1,
)
inventory_record.save()
logger.info(
f'创建新库存记录 {inventory_record.id},数量: {inventory_record.quantity}, '
f'匹数: {inventory_record.num_of_rolls}'
)
# 无论库存记录原本是否存在都创建库存快照
create_stock_snapshot(detail, inventory_record)
# 所有明细处理完毕,标记库存变动记录为已完成
stock_change_record.is_finished = True
stock_change_record.finished_at = timezone.now()
stock_change_record.save()

View File

@@ -1,3 +1,427 @@
from django.test import TestCase
from django.utils import timezone
from decimal import Decimal
from unittest.mock import patch, MagicMock
import logging
# Create your tests here.
from basic_info.models import Product, WareHouse, Supplier, ProductUnitEnum, ProductCategory
from . import models, services
class StockServicesTestCase(TestCase):
"""库存服务层测试"""
@classmethod
def setUpTestData(cls):
"""设置测试数据(类级别,所有测试方法共享)"""
# 产品单位(使用枚举值)
cls.unit_meter = ProductUnitEnum.METER
cls.unit_kg = ProductUnitEnum.KG
# 创建产品类别
cls.fabric_category = ProductCategory.objects.create(
name="布料类",
description="各种布料产品",
product_prefix="FAB"
)
# 创建仓库
cls.warehouse_main = WareHouse.objects.create(
name="主仓库",
location="主仓库地址"
)
cls.warehouse_backup = WareHouse.objects.create(
name="备用仓库",
location="备用仓库地址"
)
# 创建供应商
cls.supplier = Supplier.objects.create(
name="测试供应商",
contact="张三",
mobile="13800138000"
)
# 创建产品
cls.product_fabric_a = Product.objects.create(
category=cls.fabric_category,
name="布料A",
human_id="FABRIC-A-001",
color="红色",
spec="规格A",
single_price_in=Decimal('100.00'),
single_price_out=Decimal('120.00'),
unit=cls.unit_meter
)
cls.product_fabric_b = Product.objects.create(
category=cls.fabric_category,
name="布料B",
human_id="FABRIC-B-001",
color="蓝色",
spec="规格B",
single_price_in=Decimal('150.00'),
single_price_out=Decimal('180.00'),
unit=cls.unit_kg
)
def setUp(self):
"""每个测试方法执行前的设置"""
# 创建采购单
self.purchase_order = models.PurchaseOrder.objects.create(
supplier=self.supplier,
order_date=timezone.now().date(),
total_amount=Decimal('1000.00'),
remarks="测试采购单"
)
# 创建库存变动记录(入库)
self.stock_change_in = models.StockChangeRecord.objects.create(
type=models.StockChangeTypeEnum.ADD,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
warehouse=self.warehouse_main,
is_finished=False
)
# 创建库存变动记录(出库)
self.stock_change_out = models.StockChangeRecord.objects.create(
type=models.StockChangeTypeEnum.REMOVE,
source_type=models.StockChangeSourceEnum.SALES,
source_id=1,
warehouse=self.warehouse_main,
is_finished=False
)
# 创建库存变动明细
self.stock_detail_in = models.StockChangeDetail.objects.create(
stock_change_record=self.stock_change_in,
product=self.product_fabric_a,
quantity=Decimal('100'),
unit=self.unit_meter
)
self.stock_detail_out = models.StockChangeDetail.objects.create(
stock_change_record=self.stock_change_out,
product=self.product_fabric_a,
quantity=Decimal('50'),
unit=self.unit_meter
)
class FindInventoryTestCase(StockServicesTestCase):
"""测试 find_inventory 函数"""
def test_find_existing_inventory(self):
"""测试查找已存在的库存记录"""
# 先创建一个库存记录
inventory = models.Inventory.objects.create(
product=self.product_fabric_a,
warehouse=self.warehouse_main,
quantity=50,
num_of_rolls=5
)
# 测试查找
found_inventory = services.find_inventory(
product_id=self.product_fabric_a.id,
warehouse_id=self.warehouse_main.id
)
self.assertIsNotNone(found_inventory)
self.assertEqual(found_inventory.id, inventory.id)
self.assertEqual(found_inventory.quantity, 50)
self.assertEqual(found_inventory.num_of_rolls, 5)
def test_find_nonexistent_inventory(self):
"""测试查找不存在的库存记录"""
result = services.find_inventory(
product_id=self.product_fabric_a.id,
warehouse_id=self.warehouse_main.id
)
self.assertIsNone(result)
def test_find_inventory_wrong_warehouse(self):
"""测试在错误的仓库中查找库存"""
# 在主仓库创建库存
models.Inventory.objects.create(
product=self.product_fabric_a,
warehouse=self.warehouse_main,
quantity=50,
num_of_rolls=5
)
# 在备用仓库查找
result = services.find_inventory(
product_id=self.product_fabric_a.id,
warehouse_id=self.warehouse_backup.id
)
self.assertIsNone(result)
class CreateStockSnapshotTestCase(StockServicesTestCase):
"""测试 create_stock_snapshot 函数"""
def setUp(self):
super().setUp()
# 创建库存记录
self.inventory = models.Inventory.objects.create(
product=self.product_fabric_a,
warehouse=self.warehouse_main,
quantity=150,
num_of_rolls=15
)
def test_create_snapshot_for_incoming_stock(self):
"""测试为入库记录创建快照"""
with patch.object(services.logger, 'info') as mock_logger:
snapshot = services.create_stock_snapshot(
self.stock_detail_in,
self.inventory
)
self.assertIsNotNone(snapshot)
self.assertEqual(snapshot.product_id, self.product_fabric_a.id)
self.assertEqual(snapshot.warehouse_id, self.warehouse_main.id)
self.assertEqual(snapshot.delta, 100) # 入库数量为正
self.assertEqual(snapshot.quantity_before, 50) # 150 - 100
self.assertEqual(snapshot.quantity_after, 150)
self.assertEqual(snapshot.stock_change_record_id, self.stock_change_in.id)
# 验证日志调用
mock_logger.assert_called_once()
def test_create_snapshot_for_outgoing_stock(self):
"""测试为出库记录创建快照"""
snapshot = services.create_stock_snapshot(
self.stock_detail_out,
self.inventory
)
self.assertIsNotNone(snapshot)
self.assertEqual(snapshot.delta, -50) # 出库数量为负
self.assertEqual(snapshot.quantity_before, 200) # 150 - (-50)
self.assertEqual(snapshot.quantity_after, 150)
def test_create_snapshot_with_invalid_detail(self):
"""测试使用无效明细记录创建快照"""
# 测试 None 参数
with self.assertRaises(ValueError) as cm:
services.create_stock_snapshot(None, self.inventory)
self.assertIn("无效的库存变动明细记录", str(cm.exception))
# 测试未保存的明细记录
unsaved_detail = models.StockChangeDetail(
stock_change_record=self.stock_change_in,
product=self.product_fabric_a,
quantity=50
)
with self.assertRaises(ValueError):
services.create_stock_snapshot(unsaved_detail, self.inventory)
class MakeStockChangeCompletedTestCase(StockServicesTestCase):
"""测试 make_stock_change_completed 函数"""
def test_complete_invalid_record(self):
"""测试完成无效的库存变动记录"""
result = services.make_stock_change_completed(None)
self.assertFalse(result)
# 测试未保存的记录
unsaved_record = models.StockChangeRecord(
type=models.StockChangeTypeEnum.ADD,
warehouse=self.warehouse_main
)
result = services.make_stock_change_completed(unsaved_record)
self.assertFalse(result)
def test_complete_already_finished_record(self):
"""测试完成已经完成的记录"""
self.stock_change_in.is_finished = True
self.stock_change_in.save()
result = services.make_stock_change_completed(self.stock_change_in)
self.assertTrue(result)
@patch('stock.services.find_inventory')
@patch('stock.services.create_stock_snapshot')
def test_complete_inbound_record_new_inventory(self, mock_snapshot, mock_find):
"""测试完成入库记录(新建库存)"""
# 模拟没有找到现有库存
mock_find.return_value = None
mock_snapshot.return_value = MagicMock()
result = services.make_stock_change_completed(self.stock_change_in)
self.assertTrue(result)
self.assertTrue(self.stock_change_in.is_finished)
self.assertIsNotNone(self.stock_change_in.finished_at)
# 验证创建了新的库存记录
new_inventory = models.Inventory.objects.get(
product=self.product_fabric_a,
warehouse=self.warehouse_main
)
self.assertEqual(new_inventory.quantity, 100)
self.assertEqual(new_inventory.num_of_rolls, 1)
# 验证调用了相关函数
mock_find.assert_called_once_with(
product_id=self.product_fabric_a.id,
warehouse_id=self.warehouse_main.id
)
mock_snapshot.assert_called_once()
def test_complete_inbound_record_existing_inventory(self):
"""测试完成入库记录(更新现有库存)"""
# 创建现有库存
existing_inventory = models.Inventory.objects.create(
product=self.product_fabric_a,
warehouse=self.warehouse_main,
quantity=50,
num_of_rolls=5
)
with patch('stock.services.create_stock_snapshot') as mock_snapshot:
mock_snapshot.return_value = MagicMock()
result = services.make_stock_change_completed(self.stock_change_in)
self.assertTrue(result)
# 检查库存更新
existing_inventory.refresh_from_db()
self.assertEqual(existing_inventory.quantity, 150) # 50 + 100
self.assertEqual(existing_inventory.num_of_rolls, 6) # 5 + 1
# 验证记录状态
self.stock_change_in.refresh_from_db()
self.assertTrue(self.stock_change_in.is_finished)
def test_complete_outbound_record(self):
"""测试完成出库记录"""
# 创建现有库存
existing_inventory = models.Inventory.objects.create(
product=self.product_fabric_a,
warehouse=self.warehouse_main,
quantity=100,
num_of_rolls=10
)
with patch('stock.services.create_stock_snapshot') as mock_snapshot:
mock_snapshot.return_value = MagicMock()
result = services.make_stock_change_completed(self.stock_change_out)
self.assertTrue(result)
# 检查库存减少
existing_inventory.refresh_from_db()
self.assertEqual(existing_inventory.quantity, 50) # 100 - 50
self.assertEqual(existing_inventory.num_of_rolls, 9) # 10 - 1
def test_complete_record_with_multiple_details(self):
"""测试完成包含多个明细的记录"""
# 添加另一个明细
models.StockChangeDetail.objects.create(
stock_change_record=self.stock_change_in,
product=self.product_fabric_b,
quantity=Decimal('200'),
unit=self.unit_kg
)
with patch('stock.services.create_stock_snapshot') as mock_snapshot:
mock_snapshot.return_value = MagicMock()
result = services.make_stock_change_completed(self.stock_change_in)
self.assertTrue(result)
# 检查为两个产品都创建了库存记录
inventory_a = models.Inventory.objects.get(
product=self.product_fabric_a,
warehouse=self.warehouse_main
)
inventory_b = models.Inventory.objects.get(
product=self.product_fabric_b,
warehouse=self.warehouse_main
)
self.assertEqual(inventory_a.quantity, 100)
self.assertEqual(inventory_b.quantity, 200)
# 验证快照创建了两次
self.assertEqual(mock_snapshot.call_count, 2)
@patch.object(services.logger, 'info')
def test_logging_during_completion(self, mock_logger):
"""测试完成过程中的日志记录"""
services.make_stock_change_completed(self.stock_change_in)
# 验证日志调用
self.assertGreater(mock_logger.call_count, 0)
# 检查最后一条日志消息
last_call_args = mock_logger.call_args_list[-1][0]
self.assertIn('标记为已完成', last_call_args[0])
class StockServicesIntegrationTestCase(StockServicesTestCase):
"""集成测试:测试服务函数之间的协作"""
def test_complete_workflow(self):
"""测试完整的库存变动工作流"""
# 1. 确保开始时没有库存
self.assertEqual(
models.Inventory.objects.filter(
product=self.product_fabric_a,
warehouse=self.warehouse_main
).count(),
0
)
# 2. 完成入库操作
result = services.make_stock_change_completed(self.stock_change_in)
self.assertTrue(result)
# 3. 验证库存被创建
inventory = services.find_inventory(
self.product_fabric_a.id,
self.warehouse_main.id
)
self.assertIsNotNone(inventory)
self.assertEqual(inventory.quantity, 100)
# 4. 验证快照被创建
snapshots = models.StockSnapshot.objects.filter(
stock_change_record=self.stock_change_in
)
self.assertEqual(snapshots.count(), 1)
snapshot = snapshots.first()
self.assertEqual(snapshot.delta, 100)
self.assertEqual(snapshot.quantity_before, 0)
self.assertEqual(snapshot.quantity_after, 100)
# 5. 完成出库操作
result = services.make_stock_change_completed(self.stock_change_out)
self.assertTrue(result)
# 6. 验证库存被更新
inventory.refresh_from_db()
self.assertEqual(inventory.quantity, 50) # 100 - 50
# 7. 验证出库快照
out_snapshots = models.StockSnapshot.objects.filter(
stock_change_record=self.stock_change_out
)
self.assertEqual(out_snapshots.count(), 1)
out_snapshot = out_snapshots.first()
self.assertEqual(out_snapshot.delta, -50)
self.assertEqual(out_snapshot.quantity_before, 100)
self.assertEqual(out_snapshot.quantity_after, 50)
# 禁用测试期间的日志输出
logging.disable(logging.CRITICAL)

213
uv.lock generated
View File

@@ -20,6 +20,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
@@ -83,6 +92,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/b3/0a3bec4ecbfee960f39b1842c2f91e4754251e0a6ed443db9fe3f666ba8f/django_environ-0.12.0-py2.py3-none-any.whl", hash = "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca", size = 19957, upload-time = "2025-01-13T17:03:32.918Z" },
]
[[package]]
name = "django-filter"
version = "25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" },
]
[[package]]
name = "django-ninja"
version = "1.4.5"
@@ -116,6 +137,49 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/97/d7/57ac337258399c33c28b5d960407e2c640f22a2edabf2e894d1b51e25fee/django-simpleui-2025.6.24.tar.gz", hash = "sha256:95d1224454a0531071ec7fcd1c6f57acf90e85160c72d6b1d23d538c3a92f4ed", size = 6483918, upload-time = "2025-06-24T06:35:27.545Z" }
[[package]]
name = "djangorestframework"
version = "3.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
]
[[package]]
name = "djangorestframework-simplejwt"
version = "5.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
]
[[package]]
name = "drf-spectacular"
version = "0.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
{ name = "inflection" },
{ name = "jsonschema" },
{ name = "pyyaml" },
{ name = "uritemplate" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722, upload-time = "2025-11-02T03:40:26.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433, upload-time = "2025-11-02T03:40:24.823Z" },
]
[[package]]
name = "flower"
version = "0.1.0"
@@ -124,9 +188,14 @@ dependencies = [
{ name = "django" },
{ name = "django-cors" },
{ name = "django-environ" },
{ name = "django-filter" },
{ name = "django-ninja" },
{ name = "django-qiniu-storage" },
{ name = "django-simpleui" },
{ name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" },
{ name = "drf-spectacular" },
{ name = "markdown" },
{ name = "pillow" },
]
@@ -140,9 +209,14 @@ requires-dist = [
{ name = "django", specifier = ">=5.2.7" },
{ name = "django-cors", specifier = ">=0.1" },
{ name = "django-environ", specifier = ">=0.12.0" },
{ name = "django-filter", specifier = ">=25.2" },
{ name = "django-ninja", specifier = ">=1.4.5" },
{ name = "django-qiniu-storage", specifier = ">=2.3.1" },
{ name = "django-simpleui", specifier = ">=2025.6.24" },
{ name = "djangorestframework", specifier = ">=3.16.1" },
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
{ name = "drf-spectacular", specifier = ">=0.29.0" },
{ name = "markdown", specifier = ">=3.10" },
{ name = "pillow", specifier = ">=12.0.0" },
]
@@ -158,6 +232,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "inflection"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
]
[[package]]
name = "jsonschema"
version = "4.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "markdown"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" },
]
[[package]]
name = "pillow"
version = "12.0.0"
@@ -269,6 +388,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" },
]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "qiniu"
version = "7.17.0"
@@ -281,6 +435,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/1d/bd3703fe3c1c2efc1eff9314a9d2f5dd276ecb78d303634942756e231b0c/qiniu-7.17.0-py3-none-any.whl", hash = "sha256:3cd3d25ba1fb9abd2eefb98d5669873d7199e9d84decb25ff5db7968a08a9fb9", size = 81769, upload-time = "2025-07-11T07:32:41.665Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
@@ -296,6 +463,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rpds-py"
version = "0.28.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" },
{ url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" },
{ url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" },
{ url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" },
{ url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" },
{ url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" },
{ url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" },
{ url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" },
{ url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" },
{ url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" },
{ url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" },
{ url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" },
{ url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" },
{ url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" },
{ url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" },
{ url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" },
{ url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" },
{ url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" },
{ url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" },
{ url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" },
{ url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" },
{ url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" },
{ url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" },
{ url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" },
]
[[package]]
name = "six"
version = "1.17.0"
@@ -344,6 +548,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "uritemplate"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"