diff --git a/api_v1/__init__.py b/api_v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api_v1/apps.py b/api_v1/apps.py new file mode 100644 index 0000000..25d545a --- /dev/null +++ b/api_v1/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiV1Config(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api_v1' diff --git a/api_v1/serializers.py b/api_v1/serializers.py new file mode 100644 index 0000000..977836e --- /dev/null +++ b/api_v1/serializers.py @@ -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() \ No newline at end of file diff --git a/api_v1/tests.py b/api_v1/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/api_v1/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/api_v1/urls.py b/api_v1/urls.py new file mode 100644 index 0000000..25fde7a --- /dev/null +++ b/api_v1/urls.py @@ -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'), +] \ No newline at end of file diff --git a/api_v1/views.py b/api_v1/views.py new file mode 100644 index 0000000..bf1f3dc --- /dev/null +++ b/api_v1/views.py @@ -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) diff --git a/flower/settings.py b/flower/settings.py index febd0cf..ecd7b22 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -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 = [ diff --git a/flower/urls.py b/flower/urls.py index 05319c7..33cbc91 100644 --- a/flower/urls.py +++ b/flower/urls.py @@ -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')), ] diff --git a/pyproject.toml b/pyproject.toml index 9823b72..e4c87f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/stock/admin.py b/stock/admin.py index 43044c4..b920718 100644 --- a/stock/admin.py +++ b/stock/admin.py @@ -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('
'.join(detail_list)) + actions = [make_selected_stock_changes_completed] \ No newline at end of file diff --git a/stock/migrations/0031_remove_stockchangerecord_num_of_rolls_and_more.py b/stock/migrations/0031_remove_stockchangerecord_num_of_rolls_and_more.py new file mode 100644 index 0000000..20f4a87 --- /dev/null +++ b/stock/migrations/0031_remove_stockchangerecord_num_of_rolls_and_more.py @@ -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': '库存变动明细', + }, + ), + ] diff --git a/stock/migrations/0032_remove_stockchangerecord_unit.py b/stock/migrations/0032_remove_stockchangerecord_unit.py new file mode 100644 index 0000000..d5b00d1 --- /dev/null +++ b/stock/migrations/0032_remove_stockchangerecord_unit.py @@ -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', + ), + ] diff --git a/stock/models.py b/stock/models.py index 805331b..f499555 100644 --- a/stock/models.py +++ b/stock/models.py @@ -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 = '库存快照' diff --git a/stock/services.py b/stock/services.py index 767ea4a..c93cdea 100644 --- a/stock/services.py +++ b/stock/services.py @@ -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() diff --git a/stock/tests.py b/stock/tests.py index 7ce503c..3ef0bcd 100644 --- a/stock/tests.py +++ b/stock/tests.py @@ -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) diff --git a/uv.lock b/uv.lock index 7b4afb4..b14f914 100644 --- a/uv.lock +++ b/uv.lock @@ -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"