ap-client 0.5.3.dev7__tar.gz → 0.5.3.dev8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/PKG-INFO +1 -1
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/instance_commands.py +482 -51
- ap_client-0.5.3.dev8/ap_client/instance_options.py +94 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/irepo_sdk.py +18 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/pyproject.toml +1 -1
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/__init__.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/akpool_commands.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/api.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/cli.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/config.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/dataset_commands.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/dataset_register.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/exporter.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/fs_commands.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/managed_ak.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/manifest_view.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/ossutil_commands.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/ossutil_io.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/profile_commands.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/repo_read_guard.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/share_api.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/split_publish.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/tbb.py +0 -0
- {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/waiter.py +0 -0
|
@@ -1,23 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Instance metadata, directory browsing, transfer and source-hash commands.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* ``list`` / ``get`` —— **控制面**,走 ``api.list_dataset_series_instances`` /
|
|
7
|
-
``api.get_dataset_series_instance``,``--split`` 必填,不需要 SDK;
|
|
8
|
-
* ``push`` / ``pull`` —— 数据面,走 ``repo.instances.push_many`` / ``pull``。
|
|
9
|
-
|
|
10
|
-
分层约束(设计 §2):``instance list/get`` 属控制面,必须在没装
|
|
11
|
-
``ap-client[dataset]`` 时照常工作——所以 SDK 只能在**需要它的那几个命令函数
|
|
12
|
-
内部**懒加载:
|
|
13
|
-
|
|
14
|
-
from .irepo_sdk import SdkMissing, load_repo, sdk_error_exit
|
|
15
|
-
|
|
16
|
-
``SdkMissing`` → 打印 ``str(exc)`` 到 stderr 并 ``typer.Exit(2)``(缺依赖是使用
|
|
17
|
-
错误);SDK 抛出的业务异常 → ``sdk_error_exit(exc, what=..., stage=...)``。
|
|
18
|
-
|
|
19
|
-
渲染与退出码沿用 ``ap_client.cli`` 的既有 helper,同样函数内部 import 以避免与
|
|
20
|
-
``cli`` 循环依赖。默认列:``INSTANCE SPLIT VERSION DIGEST UPDATED_AT REGISTERED``。
|
|
3
|
+
SDK imports are lazy so help remains available without Dataset support.
|
|
4
|
+
Local validation and hashing do not construct a Repo or contact AP.
|
|
5
|
+
Remote operations share SDK configuration and the CLI output/error contract.
|
|
21
6
|
"""
|
|
22
7
|
|
|
23
8
|
from __future__ import annotations
|
|
@@ -63,6 +48,7 @@ _VERSION_HELP = (
|
|
|
63
48
|
)
|
|
64
49
|
|
|
65
50
|
|
|
51
|
+
from . import instance_options # noqa: E402
|
|
66
52
|
from .dataset_commands import ( # noqa: E402
|
|
67
53
|
_ENVIRONMENT_HELP,
|
|
68
54
|
_METADATA_MODEL_HELP,
|
|
@@ -124,6 +110,22 @@ def _fail(message: str, hint: str, exit_code: int = 1) -> "typer.Exit":
|
|
|
124
110
|
return typer.Exit(exit_code)
|
|
125
111
|
|
|
126
112
|
|
|
113
|
+
def _path_value(value: Optional[str]) -> Optional[str]:
|
|
114
|
+
try:
|
|
115
|
+
return instance_options.relative_path(value)
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
raise _fail(str(exc), "Use --path solution, --path tests/sealed, or --path .", 2) from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _enhanced_options(owner: Any, method: str, options: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
try:
|
|
122
|
+
return instance_options.sdk_options(owner, method, options)
|
|
123
|
+
except (TypeError, ValueError) as exc:
|
|
124
|
+
raise _fail(
|
|
125
|
+
str(exc), "Upgrade Dataset support: pip install --upgrade 'ap-client[dataset]'", 2
|
|
126
|
+
) from exc
|
|
127
|
+
|
|
128
|
+
|
|
127
129
|
def _load_repo_or_exit():
|
|
128
130
|
"""懒加载 SDK;缺 extra → 安装提示 + 退出码 2(设计 §2)。
|
|
129
131
|
|
|
@@ -464,15 +466,40 @@ def instance_get(
|
|
|
464
466
|
metadata_model: Optional[str] = typer.Option(
|
|
465
467
|
None, "--metadata-model", help=_METADATA_MODEL_HELP
|
|
466
468
|
),
|
|
469
|
+
path: Optional[str] = typer.Option(
|
|
470
|
+
None,
|
|
471
|
+
"--path",
|
|
472
|
+
help="List a subdirectory relative to the instance assets; use '.' for the root",
|
|
473
|
+
),
|
|
474
|
+
page_size: int = typer.Option(
|
|
475
|
+
100, "--page-size", min=1, max=1000, help="Directory listing page size"
|
|
476
|
+
),
|
|
477
|
+
max_pages: int = typer.Option(50, "--max-pages", min=1, help="Maximum directory listing pages"),
|
|
467
478
|
output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
|
|
468
479
|
):
|
|
469
|
-
"""Show
|
|
480
|
+
"""Show instance metadata and root assets; use --path to browse a directory."""
|
|
470
481
|
from .irepo_sdk import _reason
|
|
471
482
|
|
|
472
483
|
output_format = _fmt(output_format)
|
|
484
|
+
resolved_path = _path_value(path)
|
|
473
485
|
resolved_version = _version_value(version)
|
|
474
486
|
repo = _load_repo_or_exit()
|
|
475
487
|
env = _environment(environment)
|
|
488
|
+
if resolved_path is not None:
|
|
489
|
+
_list_instance_objects(
|
|
490
|
+
repo,
|
|
491
|
+
dataset,
|
|
492
|
+
resolved_version,
|
|
493
|
+
instance,
|
|
494
|
+
split,
|
|
495
|
+
resolved_path,
|
|
496
|
+
env,
|
|
497
|
+
_metadata_model_for_split(metadata_model, split=split),
|
|
498
|
+
page_size,
|
|
499
|
+
max_pages,
|
|
500
|
+
output_format,
|
|
501
|
+
)
|
|
502
|
+
return
|
|
476
503
|
read_kwargs = _sdk_read_kwargs(
|
|
477
504
|
repo.instances,
|
|
478
505
|
"get",
|
|
@@ -498,17 +525,99 @@ def instance_get(
|
|
|
498
525
|
f"Failed to query instance {instance}: not found",
|
|
499
526
|
f"Check the instance id: ap instance list --dataset {dataset} --split {split}",
|
|
500
527
|
)
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
528
|
+
_list_instance_objects(
|
|
529
|
+
repo,
|
|
530
|
+
dataset,
|
|
531
|
+
resolved_version,
|
|
532
|
+
instance,
|
|
533
|
+
split,
|
|
534
|
+
"",
|
|
535
|
+
env,
|
|
536
|
+
_metadata_model_for_split(metadata_model, split=split),
|
|
537
|
+
page_size,
|
|
538
|
+
max_pages,
|
|
539
|
+
output_format,
|
|
540
|
+
metadata=_instance_record(record),
|
|
541
|
+
)
|
|
507
542
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
543
|
+
|
|
544
|
+
def _list_instance_objects(
|
|
545
|
+
repo: Any,
|
|
546
|
+
dataset: str,
|
|
547
|
+
version: str,
|
|
548
|
+
instance: str,
|
|
549
|
+
split: str,
|
|
550
|
+
path: str,
|
|
551
|
+
environment: str,
|
|
552
|
+
metadata_model: str,
|
|
553
|
+
page_size: int,
|
|
554
|
+
max_pages: int,
|
|
555
|
+
output_format: str,
|
|
556
|
+
*,
|
|
557
|
+
metadata: dict | None = None,
|
|
558
|
+
) -> None:
|
|
559
|
+
from .irepo_sdk import _reason
|
|
560
|
+
|
|
561
|
+
kwargs = _enhanced_options(
|
|
562
|
+
repo.instances,
|
|
563
|
+
"list_objects",
|
|
564
|
+
{
|
|
565
|
+
"version": version,
|
|
566
|
+
"path": path,
|
|
567
|
+
"page_size": page_size,
|
|
568
|
+
"max_pages": max_pages,
|
|
569
|
+
},
|
|
570
|
+
)
|
|
571
|
+
kwargs.update(
|
|
572
|
+
_sdk_read_kwargs(
|
|
573
|
+
repo.instances, "list_objects", environment=environment, metadata_model=metadata_model
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
try:
|
|
577
|
+
result = repo.instances.list_objects(dataset, instance, split=split, **kwargs)
|
|
578
|
+
except Exception as exc: # noqa: BLE001
|
|
579
|
+
raise _fail(
|
|
580
|
+
f"Failed to list instance {instance} directory {path or '.'}: {_reason(exc)}",
|
|
581
|
+
"Check the instance, directory, environment and read permission",
|
|
582
|
+
) from exc
|
|
583
|
+
payload = _model_dict(result)
|
|
584
|
+
payload.update(instance=instance, path=path or ".")
|
|
585
|
+
rows = [dict(item, type="directory", size_bytes="") for item in payload.get("directories", [])]
|
|
586
|
+
rows.extend(dict(item, type="file") for item in payload.get("files", []))
|
|
587
|
+
from .manifest_view import _visible
|
|
588
|
+
|
|
589
|
+
rows = [
|
|
590
|
+
{key: _visible(value) if isinstance(value, str) else value for key, value in row.items()}
|
|
591
|
+
for row in rows
|
|
592
|
+
]
|
|
593
|
+
if metadata is not None and output_format in ("json", "yaml"):
|
|
594
|
+
_emit_detail(dict(metadata, objects=payload), output_format)
|
|
595
|
+
else:
|
|
596
|
+
if metadata is not None:
|
|
597
|
+
manifest = metadata.get("manifest")
|
|
598
|
+
if isinstance(manifest, list):
|
|
599
|
+
from .manifest_view import manifest_lines
|
|
600
|
+
|
|
601
|
+
_emit_detail(
|
|
602
|
+
{key: value for key, value in metadata.items() if key != "manifest"},
|
|
603
|
+
output_format,
|
|
604
|
+
)
|
|
605
|
+
typer.echo()
|
|
606
|
+
for line in manifest_lines(manifest):
|
|
607
|
+
typer.echo(line)
|
|
608
|
+
else:
|
|
609
|
+
_emit_detail(metadata, output_format)
|
|
610
|
+
typer.echo("\nassets: .")
|
|
611
|
+
_emit(
|
|
612
|
+
payload,
|
|
613
|
+
rows,
|
|
614
|
+
(("type", "TYPE"), ("name", "NAME"), ("path", "PATH"), ("size_bytes", "BYTES")),
|
|
615
|
+
output_format,
|
|
616
|
+
)
|
|
617
|
+
if payload.get("truncated"):
|
|
618
|
+
typer.echo(
|
|
619
|
+
"warning: directory listing is incomplete; increase --max-pages and retry", err=True
|
|
620
|
+
)
|
|
512
621
|
|
|
513
622
|
|
|
514
623
|
def _check_version_autocreate(repo, dataset: str, version: str, *, environment: str) -> bool:
|
|
@@ -538,7 +647,14 @@ def _check_version_autocreate(repo, dataset: str, version: str, *, environment:
|
|
|
538
647
|
|
|
539
648
|
|
|
540
649
|
def _confirm_upload(
|
|
541
|
-
dataset: str,
|
|
650
|
+
dataset: str,
|
|
651
|
+
version: str,
|
|
652
|
+
split: str,
|
|
653
|
+
count: int,
|
|
654
|
+
*,
|
|
655
|
+
yes: bool,
|
|
656
|
+
auto_create: bool,
|
|
657
|
+
prune: bool = False,
|
|
542
658
|
) -> None:
|
|
543
659
|
"""push 写远端前统一二次确认(可 ``-y/--yes`` 跳过),与 split publish 同口径。"""
|
|
544
660
|
if yes:
|
|
@@ -546,6 +662,10 @@ def _confirm_upload(
|
|
|
546
662
|
label = _version_label(version)
|
|
547
663
|
target = f"dataset {dataset} version {label} split {split}"
|
|
548
664
|
note = f" Version {label} does not exist and will be auto-created." if auto_create else ""
|
|
665
|
+
if prune:
|
|
666
|
+
note += (
|
|
667
|
+
" Remote instance records absent from these paths will be removed; OSS files remain."
|
|
668
|
+
)
|
|
549
669
|
if not sys.stdin.isatty():
|
|
550
670
|
raise _fail(
|
|
551
671
|
f"Uploading {count} instance(s) to {target} requires confirmation.{note}",
|
|
@@ -568,6 +688,29 @@ def instance_push(
|
|
|
568
688
|
version: Optional[str] = typer.Option(None, "--version", help=_VERSION_HELP),
|
|
569
689
|
environment: Optional[str] = typer.Option(None, "--environment", help=_ENVIRONMENT_HELP),
|
|
570
690
|
overwrite: bool = typer.Option(False, "--overwrite", help="Allow overwriting existing objects"),
|
|
691
|
+
skip_unchanged: bool = typer.Option(
|
|
692
|
+
False, "--skip-unchanged", help="Skip cases with matching source hashes"
|
|
693
|
+
),
|
|
694
|
+
content_hashes: Optional[str] = typer.Option(
|
|
695
|
+
None, "--content-hashes", help="JSON file mapping instance IDs to source hashes"
|
|
696
|
+
),
|
|
697
|
+
compress_level: Optional[int] = typer.Option(
|
|
698
|
+
None,
|
|
699
|
+
"--compress-level",
|
|
700
|
+
min=1,
|
|
701
|
+
max=9,
|
|
702
|
+
help="Gzip compression level; 1 is fastest, 9 is smallest",
|
|
703
|
+
),
|
|
704
|
+
concurrency: int = typer.Option(8, "--concurrency", min=1, help="Concurrent instance uploads"),
|
|
705
|
+
continue_on_error: bool = typer.Option(
|
|
706
|
+
False, "--continue-on-error", help="Finish other uploads and report failed cases"
|
|
707
|
+
),
|
|
708
|
+
progress: bool = typer.Option(False, "--progress", help="Write SDK upload progress to stderr"),
|
|
709
|
+
prune: bool = typer.Option(
|
|
710
|
+
False,
|
|
711
|
+
"--prune",
|
|
712
|
+
help="After delivery, remove remote metadata absent from the supplied cases; OSS files remain",
|
|
713
|
+
),
|
|
571
714
|
yes: bool = typer.Option(
|
|
572
715
|
False,
|
|
573
716
|
"--yes",
|
|
@@ -594,6 +737,22 @@ def instance_push(
|
|
|
594
737
|
f"{len(broken)}/{len(results)} instances failed local validation; nothing was uploaded",
|
|
595
738
|
"Run ap instance validate <path>... to fix the issues, then retry",
|
|
596
739
|
)
|
|
740
|
+
try:
|
|
741
|
+
hashes = instance_options.upload_hashes(
|
|
742
|
+
results, instance_options.read_hashes(content_hashes), skip_unchanged
|
|
743
|
+
)
|
|
744
|
+
if prune:
|
|
745
|
+
if not resolved:
|
|
746
|
+
raise ValueError("--prune requires an explicit --version")
|
|
747
|
+
if any(not Path(record["path"]).is_dir() for record in results):
|
|
748
|
+
raise ValueError("--prune requires a collection of instance directories")
|
|
749
|
+
instance_options.require_working_prune()
|
|
750
|
+
if skip_unchanged or prune:
|
|
751
|
+
ids = [record["instance"] for record in results]
|
|
752
|
+
if len(ids) != len(set(ids)):
|
|
753
|
+
raise ValueError("Each supplied path must have a distinct instance ID")
|
|
754
|
+
except (ImportError, OSError, ValueError) as exc:
|
|
755
|
+
raise _fail(str(exc), "Check the hashes, input paths and installed Dataset SDK", 2) from exc
|
|
597
756
|
|
|
598
757
|
# ② 写远端前统一二次确认(-y/--yes 跳过);版本不存在时提示将被自动创建。
|
|
599
758
|
# (split 已发布不可写由**服务端**裁决——pre 环境里 ingest 产出的 split 记录状态
|
|
@@ -604,13 +763,41 @@ def instance_push(
|
|
|
604
763
|
|
|
605
764
|
repo = _load_repo_or_exit()
|
|
606
765
|
_check_storage_side(repo, env)
|
|
766
|
+
enhanced: dict[str, Any] = {}
|
|
767
|
+
if skip_unchanged:
|
|
768
|
+
enhanced["skip_unchanged"] = True
|
|
769
|
+
if compress_level is not None:
|
|
770
|
+
enhanced["compress_level"] = compress_level
|
|
771
|
+
# The SDK batch path compares stored hashes before selecting format-specific uploads.
|
|
772
|
+
batch = len(targets) > 1 or prune or skip_unchanged
|
|
773
|
+
if hashes:
|
|
774
|
+
if batch:
|
|
775
|
+
enhanced["content_hashes"] = hashes
|
|
776
|
+
else:
|
|
777
|
+
source_hash = hashes.get(results[0]["instance"])
|
|
778
|
+
if source_hash:
|
|
779
|
+
enhanced["content_hash"] = source_hash
|
|
780
|
+
if batch and progress:
|
|
781
|
+
enhanced["progress_callback"] = instance_options.progress_callback
|
|
782
|
+
if batch and concurrency != 8:
|
|
783
|
+
enhanced["concurrency"] = concurrency
|
|
784
|
+
owner, method = (
|
|
785
|
+
(repo, "deliver") if prune else (repo.instances, "push_many" if batch else "push")
|
|
786
|
+
)
|
|
787
|
+
if prune:
|
|
788
|
+
enhanced["prune"] = True
|
|
789
|
+
if enhanced:
|
|
790
|
+
_enhanced_options(owner, method, enhanced)
|
|
607
791
|
auto_create = _check_version_autocreate(repo, dataset, resolved, environment=env)
|
|
608
|
-
_confirm_upload(
|
|
792
|
+
_confirm_upload(
|
|
793
|
+
dataset, resolved, split, len(targets), yes=yes, auto_create=auto_create, prune=prune
|
|
794
|
+
)
|
|
609
795
|
pushed: list[dict] = []
|
|
796
|
+
skipped: list[dict] = []
|
|
610
797
|
failed: list[dict] = []
|
|
611
798
|
unconfirmed: list[str] = []
|
|
612
799
|
result_count_mismatch = False
|
|
613
|
-
if
|
|
800
|
+
if not batch:
|
|
614
801
|
try:
|
|
615
802
|
result = repo.instances.push(
|
|
616
803
|
targets[0],
|
|
@@ -620,6 +807,7 @@ def instance_push(
|
|
|
620
807
|
push_image=False,
|
|
621
808
|
overwrite=overwrite,
|
|
622
809
|
register=True,
|
|
810
|
+
**enhanced,
|
|
623
811
|
)
|
|
624
812
|
except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
|
|
625
813
|
failed.append({"path": targets[0], "error": _describe(exc)})
|
|
@@ -628,20 +816,41 @@ def instance_push(
|
|
|
628
816
|
)
|
|
629
817
|
sdk_error_exit(exc, what=f"instance {targets[0]}", stage="upload")
|
|
630
818
|
else:
|
|
631
|
-
|
|
819
|
+
row = _push_row(targets[0], result)
|
|
820
|
+
(skipped if row["skipped"] else pushed).append(row)
|
|
821
|
+
if progress:
|
|
822
|
+
instance_options.progress_callback(1, 1, result)
|
|
632
823
|
else:
|
|
633
824
|
# 多路径:``continue_on_error=False`` 是"全或无",首个失败即抛 ItemError。
|
|
634
825
|
try:
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
826
|
+
if prune:
|
|
827
|
+
delivered = repo.deliver(
|
|
828
|
+
dataset,
|
|
829
|
+
resolved,
|
|
830
|
+
targets,
|
|
831
|
+
split=split,
|
|
832
|
+
push_image=False,
|
|
833
|
+
overwrite=overwrite,
|
|
834
|
+
continue_on_error=continue_on_error,
|
|
835
|
+
**enhanced,
|
|
836
|
+
)
|
|
837
|
+
results_many = [
|
|
838
|
+
*delivered.get("pushed", []),
|
|
839
|
+
*delivered.get("skipped", []),
|
|
840
|
+
*delivered.get("failed", []),
|
|
841
|
+
]
|
|
842
|
+
else:
|
|
843
|
+
results_many = repo.instances.push_many(
|
|
844
|
+
targets,
|
|
845
|
+
dataset,
|
|
846
|
+
resolved,
|
|
847
|
+
split=split,
|
|
848
|
+
push_image=False,
|
|
849
|
+
overwrite=overwrite,
|
|
850
|
+
register=True,
|
|
851
|
+
continue_on_error=continue_on_error,
|
|
852
|
+
**enhanced,
|
|
853
|
+
)
|
|
645
854
|
except Exception as exc: # noqa: BLE001
|
|
646
855
|
item = getattr(exc, "item", None)
|
|
647
856
|
cause = getattr(exc, "error", None) or exc
|
|
@@ -661,17 +870,61 @@ def instance_push(
|
|
|
661
870
|
else:
|
|
662
871
|
results_list = list(results_many or [])
|
|
663
872
|
result_count_mismatch = len(results_list) != len(targets)
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
pushed.append(_push_row(path, result))
|
|
873
|
+
pushed, skipped, failed, unconfirmed = _collect_upload_results(
|
|
874
|
+
targets, results, results_list, by_id=skip_unchanged or prune
|
|
875
|
+
)
|
|
668
876
|
|
|
669
|
-
_print_push_summary(
|
|
670
|
-
|
|
877
|
+
_print_push_summary(
|
|
878
|
+
dataset,
|
|
879
|
+
resolved,
|
|
880
|
+
split,
|
|
881
|
+
pushed,
|
|
882
|
+
failed,
|
|
883
|
+
unconfirmed,
|
|
884
|
+
output_format,
|
|
885
|
+
skipped=skipped if skip_unchanged or skipped else None,
|
|
886
|
+
)
|
|
887
|
+
if result_count_mismatch or unconfirmed:
|
|
671
888
|
raise _fail(
|
|
672
889
|
f"SDK returned {len(results_list)}/{len(targets)} upload results; cannot confirm the full write state",
|
|
673
890
|
f"Verify the actual content with ap instance list --dataset {dataset} --split {split}",
|
|
674
891
|
)
|
|
892
|
+
if failed:
|
|
893
|
+
raise _fail(
|
|
894
|
+
f"{len(failed)} instance upload(s) failed",
|
|
895
|
+
"Check the failure summary and retry the failed paths",
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _collect_upload_results(
|
|
900
|
+
targets: list[str],
|
|
901
|
+
records: list[dict],
|
|
902
|
+
values: list[Any],
|
|
903
|
+
*,
|
|
904
|
+
by_id: bool,
|
|
905
|
+
) -> tuple[list[dict], list[dict], list[dict], list[str]]:
|
|
906
|
+
remaining = list(targets)
|
|
907
|
+
paths_by_id = {record["instance"]: record["path"] for record in records}
|
|
908
|
+
pushed, skipped, failed = [], [], []
|
|
909
|
+
for value in values:
|
|
910
|
+
if isinstance(value, BaseException):
|
|
911
|
+
item = str(getattr(value, "item", ""))
|
|
912
|
+
path = next((raw for raw in remaining if Path(raw) == Path(item)), "") if item else ""
|
|
913
|
+
if path:
|
|
914
|
+
remaining.remove(path)
|
|
915
|
+
failed.append({"path": path, "error": _describe(getattr(value, "error", value))})
|
|
916
|
+
continue
|
|
917
|
+
path = (
|
|
918
|
+
paths_by_id.get(getattr(value, "instance_id", ""), "")
|
|
919
|
+
if by_id
|
|
920
|
+
else (remaining[0] if remaining else "")
|
|
921
|
+
)
|
|
922
|
+
if path not in remaining:
|
|
923
|
+
continue
|
|
924
|
+
remaining.remove(path)
|
|
925
|
+
row = _push_row(path, value)
|
|
926
|
+
(skipped if row["skipped"] else pushed).append(row)
|
|
927
|
+
return pushed, skipped, failed, remaining
|
|
675
928
|
|
|
676
929
|
|
|
677
930
|
def _push_row(path: str, result: Any) -> dict:
|
|
@@ -691,6 +944,8 @@ def _print_push_summary(
|
|
|
691
944
|
failed: list[dict],
|
|
692
945
|
unconfirmed: list[str],
|
|
693
946
|
output_format: str,
|
|
947
|
+
*,
|
|
948
|
+
skipped: Optional[list[dict]] = None,
|
|
694
949
|
) -> None:
|
|
695
950
|
payload = {
|
|
696
951
|
"dataset": dataset,
|
|
@@ -701,6 +956,9 @@ def _print_push_summary(
|
|
|
701
956
|
"unconfirmed": unconfirmed,
|
|
702
957
|
}
|
|
703
958
|
rows = [dict(row, status="pushed", error="") for row in pushed]
|
|
959
|
+
if skipped is not None:
|
|
960
|
+
payload["skipped"] = skipped
|
|
961
|
+
rows += [dict(row, status="skipped", error="") for row in skipped]
|
|
704
962
|
rows += [
|
|
705
963
|
{"path": row["path"], "instance": "", "status": "failed", "error": row["error"]}
|
|
706
964
|
for row in failed
|
|
@@ -728,12 +986,18 @@ def instance_pull(
|
|
|
728
986
|
None, "--output", "-o", help="Output directory (default: current directory)"
|
|
729
987
|
),
|
|
730
988
|
overwrite: bool = typer.Option(False, "--overwrite", help="Allow overwriting the target"),
|
|
989
|
+
path: Optional[str] = typer.Option(
|
|
990
|
+
None,
|
|
991
|
+
"--path",
|
|
992
|
+
help="Download a relative subdirectory (Custom/OSS assets); use '.' for all assets",
|
|
993
|
+
),
|
|
731
994
|
output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
|
|
732
995
|
):
|
|
733
996
|
"""Download instance content and metadata into <output>/<instance>/."""
|
|
734
997
|
from .irepo_sdk import sdk_error_exit
|
|
735
998
|
|
|
736
999
|
output_format = _fmt(output_format)
|
|
1000
|
+
resolved_path = _path_value(path)
|
|
737
1001
|
resolved = _version_value(version)
|
|
738
1002
|
root = Path(output or ".")
|
|
739
1003
|
target = root / instance
|
|
@@ -744,6 +1008,9 @@ def instance_pull(
|
|
|
744
1008
|
)
|
|
745
1009
|
|
|
746
1010
|
repo = _load_repo_or_exit()
|
|
1011
|
+
pull_options = {}
|
|
1012
|
+
if resolved_path is not None:
|
|
1013
|
+
pull_options = _enhanced_options(repo.instances, "pull", {"path": resolved_path})
|
|
747
1014
|
try:
|
|
748
1015
|
private_endpoint_reachable = _probe_pull_private_endpoint(repo, dataset)
|
|
749
1016
|
except Exception as exc: # noqa: BLE001
|
|
@@ -760,10 +1027,17 @@ def instance_pull(
|
|
|
760
1027
|
staging = Path(tempfile.mkdtemp(prefix=f"ap-pull-{instance}-"))
|
|
761
1028
|
try:
|
|
762
1029
|
try:
|
|
763
|
-
written = repo.instances.pull(
|
|
1030
|
+
written = repo.instances.pull(
|
|
1031
|
+
dataset, resolved, instance, staging, split, **pull_options
|
|
1032
|
+
)
|
|
764
1033
|
except Exception as exc: # noqa: BLE001
|
|
765
1034
|
sdk_error_exit(exc, what=f"instance {instance}", stage="pull")
|
|
766
1035
|
staged = _place_under_target(Path(str(written)), staging / instance)
|
|
1036
|
+
if resolved_path and not (staging / instance / resolved_path).is_dir():
|
|
1037
|
+
raise _fail(
|
|
1038
|
+
f"No files were downloaded for instance subdirectory {resolved_path}",
|
|
1039
|
+
"Check the directory with ap instance get --path; the SDK must support this instance format",
|
|
1040
|
+
)
|
|
767
1041
|
root.mkdir(parents=True, exist_ok=True)
|
|
768
1042
|
if target.exists():
|
|
769
1043
|
if not overwrite:
|
|
@@ -786,6 +1060,8 @@ def instance_pull(
|
|
|
786
1060
|
"output": str(target),
|
|
787
1061
|
"content": str(target / staged.name),
|
|
788
1062
|
}
|
|
1063
|
+
if resolved_path is not None:
|
|
1064
|
+
payload["path"] = resolved_path or "."
|
|
789
1065
|
_emit_detail(payload, output_format)
|
|
790
1066
|
|
|
791
1067
|
|
|
@@ -808,3 +1084,158 @@ def _place_under_target(written: Path, target: Path) -> Path:
|
|
|
808
1084
|
final.unlink()
|
|
809
1085
|
shutil.move(str(written), str(final))
|
|
810
1086
|
return final
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
@instance_app.command("hash")
|
|
1090
|
+
def instance_hash(
|
|
1091
|
+
paths: List[str] = typer.Argument(..., help="Local instance directories"),
|
|
1092
|
+
output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
|
|
1093
|
+
):
|
|
1094
|
+
"""Compute source hashes locally without contacting AP."""
|
|
1095
|
+
output_format = _fmt(output_format)
|
|
1096
|
+
try:
|
|
1097
|
+
from instance_repo import compute_source_hash
|
|
1098
|
+
except ImportError as exc:
|
|
1099
|
+
raise _fail(
|
|
1100
|
+
"Source hashing requires instance-repo 1.3.5.dev1 or newer",
|
|
1101
|
+
"Install Dataset support: pip install --upgrade 'ap-client[dataset]'",
|
|
1102
|
+
2,
|
|
1103
|
+
) from exc
|
|
1104
|
+
hashes, failed = [], []
|
|
1105
|
+
for raw in paths:
|
|
1106
|
+
path = Path(raw)
|
|
1107
|
+
try:
|
|
1108
|
+
if not path.is_dir():
|
|
1109
|
+
raise ValueError("source hashing requires an existing instance directory")
|
|
1110
|
+
hashes.append(
|
|
1111
|
+
{"path": raw, "instance": path.name, "source_hash": compute_source_hash(path)}
|
|
1112
|
+
)
|
|
1113
|
+
except (OSError, ValueError) as exc:
|
|
1114
|
+
failed.append({"path": raw, "error": _describe(exc)})
|
|
1115
|
+
_emit(
|
|
1116
|
+
{"hashes": hashes, "failed": failed},
|
|
1117
|
+
hashes,
|
|
1118
|
+
(("path", "PATH"), ("instance", "INSTANCE"), ("source_hash", "SOURCE_HASH")),
|
|
1119
|
+
output_format,
|
|
1120
|
+
)
|
|
1121
|
+
if failed:
|
|
1122
|
+
raise _fail(f"{len(failed)} instance directory hash(es) failed", "Check the local paths")
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
@instance_app.command("hashes")
|
|
1126
|
+
def instance_hashes(
|
|
1127
|
+
dataset: str = typer.Option(..., "--dataset", help="Dataset name"),
|
|
1128
|
+
split: str = typer.Option(..., "--split", help="Split name"),
|
|
1129
|
+
version: Optional[str] = typer.Option(None, "--version", help=_VERSION_HELP),
|
|
1130
|
+
environment: Optional[str] = typer.Option(None, "--environment", help=_ENVIRONMENT_HELP),
|
|
1131
|
+
metadata_model: Optional[str] = typer.Option(
|
|
1132
|
+
None, "--metadata-model", help=_METADATA_MODEL_HELP
|
|
1133
|
+
),
|
|
1134
|
+
output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
|
|
1135
|
+
):
|
|
1136
|
+
"""List source hashes stored on remote instances."""
|
|
1137
|
+
output_format = _fmt(output_format)
|
|
1138
|
+
resolved = _version_value(version)
|
|
1139
|
+
env = _environment(environment)
|
|
1140
|
+
repo = _load_repo_or_exit()
|
|
1141
|
+
_enhanced_options(repo.instances, "list_content_hashes", {})
|
|
1142
|
+
kwargs = _sdk_read_kwargs(
|
|
1143
|
+
repo.instances,
|
|
1144
|
+
"list_content_hashes",
|
|
1145
|
+
environment=env,
|
|
1146
|
+
metadata_model=_metadata_model_for_split(metadata_model, split=split),
|
|
1147
|
+
)
|
|
1148
|
+
try:
|
|
1149
|
+
try:
|
|
1150
|
+
fallback = oss_fallback_kwargs(repo, repo.instances, "list_content_hashes", env)
|
|
1151
|
+
except RuntimeError:
|
|
1152
|
+
# This SDK helper cannot disable its inner list() discovery yet.
|
|
1153
|
+
list_kwargs = _sdk_read_kwargs(
|
|
1154
|
+
repo.instances,
|
|
1155
|
+
"list",
|
|
1156
|
+
environment=env,
|
|
1157
|
+
metadata_model=_metadata_model_for_split(metadata_model, split=split),
|
|
1158
|
+
)
|
|
1159
|
+
list_kwargs.update(oss_fallback_kwargs(repo, repo.instances, "list", env))
|
|
1160
|
+
records = repo.instances.list(dataset, resolved, split=split, **list_kwargs)
|
|
1161
|
+
hashes = {}
|
|
1162
|
+
for record in records:
|
|
1163
|
+
served = _model_dict(record)
|
|
1164
|
+
source_hash = (served.get("extend_meta") or {}).get("source_hash")
|
|
1165
|
+
if source_hash:
|
|
1166
|
+
hashes[served["instance_id"]] = source_hash
|
|
1167
|
+
else:
|
|
1168
|
+
hashes = repo.instances.list_content_hashes(
|
|
1169
|
+
dataset, resolved, split=split, **kwargs, **fallback
|
|
1170
|
+
)
|
|
1171
|
+
except Exception as exc: # noqa: BLE001
|
|
1172
|
+
raise _fail(
|
|
1173
|
+
f"Failed to list source hashes of dataset {dataset}: {_describe(exc)}",
|
|
1174
|
+
"Check the environment, bound storage and read permission",
|
|
1175
|
+
) from exc
|
|
1176
|
+
rows = [
|
|
1177
|
+
{"instance": instance, "source_hash": value} for instance, value in sorted(hashes.items())
|
|
1178
|
+
]
|
|
1179
|
+
_emit(
|
|
1180
|
+
{"dataset": dataset, "version": resolved, "split": split, "hashes": hashes},
|
|
1181
|
+
rows,
|
|
1182
|
+
(("instance", "INSTANCE"), ("source_hash", "SOURCE_HASH")),
|
|
1183
|
+
output_format,
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@instance_app.command("delete")
|
|
1188
|
+
def instance_delete(
|
|
1189
|
+
instance: str = typer.Argument(..., help="Instance id"),
|
|
1190
|
+
dataset: str = typer.Option(..., "--dataset", help="Dataset name"),
|
|
1191
|
+
version: str = typer.Option(..., "--version", help="Dataset version containing the instance"),
|
|
1192
|
+
split: str = typer.Option(..., "--split", help="Split name"),
|
|
1193
|
+
environment: Optional[str] = typer.Option(None, "--environment", help=_ENVIRONMENT_HELP),
|
|
1194
|
+
metadata_model: Optional[str] = typer.Option(
|
|
1195
|
+
None, "--metadata-model", help=_METADATA_MODEL_HELP
|
|
1196
|
+
),
|
|
1197
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm deleting instance metadata"),
|
|
1198
|
+
output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
|
|
1199
|
+
):
|
|
1200
|
+
"""Delete instance metadata. OSS content remains available to later ingest."""
|
|
1201
|
+
from .irepo_sdk import sdk_error_exit
|
|
1202
|
+
|
|
1203
|
+
output_format = _fmt(output_format)
|
|
1204
|
+
env = _environment(environment)
|
|
1205
|
+
resolved = _version_value(version)
|
|
1206
|
+
if not resolved:
|
|
1207
|
+
raise _fail(
|
|
1208
|
+
"--version must be nonempty for metadata deletion", "Specify the dataset version", 2
|
|
1209
|
+
)
|
|
1210
|
+
repo = _load_repo_or_exit()
|
|
1211
|
+
_enhanced_options(repo.instances, "delete", {})
|
|
1212
|
+
kwargs = _sdk_read_kwargs(
|
|
1213
|
+
repo.instances,
|
|
1214
|
+
"delete",
|
|
1215
|
+
environment=env,
|
|
1216
|
+
metadata_model=_metadata_model_for_split(metadata_model, split=split),
|
|
1217
|
+
)
|
|
1218
|
+
if not yes:
|
|
1219
|
+
if not sys.stdin.isatty():
|
|
1220
|
+
raise _fail("Deleting instance metadata requires confirmation", "Add --yes (-y)", 2)
|
|
1221
|
+
if not typer.confirm(
|
|
1222
|
+
f"Delete metadata for {dataset} version {resolved} split {split} instance {instance}? OSS files remain."
|
|
1223
|
+
):
|
|
1224
|
+
raise _fail(
|
|
1225
|
+
"Metadata deletion canceled", "Rerun when you want to delete the instance record"
|
|
1226
|
+
)
|
|
1227
|
+
try:
|
|
1228
|
+
repo.instances.delete(dataset, resolved, instance, split=split, **kwargs)
|
|
1229
|
+
except Exception as exc: # noqa: BLE001
|
|
1230
|
+
sdk_error_exit(exc, what=f"instance {instance}", stage="metadata deletion")
|
|
1231
|
+
_emit_detail(
|
|
1232
|
+
{
|
|
1233
|
+
"dataset": dataset,
|
|
1234
|
+
"version": resolved,
|
|
1235
|
+
"split": split,
|
|
1236
|
+
"instance": instance,
|
|
1237
|
+
"deleted": True,
|
|
1238
|
+
"content_deleted": False,
|
|
1239
|
+
},
|
|
1240
|
+
output_format,
|
|
1241
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Local option parsing for instance directory reads and uploads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def relative_path(value: str | None) -> str | None:
|
|
15
|
+
if value is None:
|
|
16
|
+
return None
|
|
17
|
+
path = value.strip().rstrip("/")
|
|
18
|
+
if path in ("", ".") and not value.strip().startswith("/"):
|
|
19
|
+
return ""
|
|
20
|
+
if (
|
|
21
|
+
path.startswith("/")
|
|
22
|
+
or "\\" in path
|
|
23
|
+
or ":" in path
|
|
24
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in path)
|
|
25
|
+
or any(part in ("", ".", "..") for part in path.split("/"))
|
|
26
|
+
):
|
|
27
|
+
raise ValueError("--path must be relative to the instance root; use '.' for the root")
|
|
28
|
+
return path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sdk_options(owner: Any, method: str, options: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
function = getattr(owner, method, None)
|
|
33
|
+
if not callable(function):
|
|
34
|
+
raise ValueError(f"The installed instance-repo does not support {method}")
|
|
35
|
+
parameters = inspect.signature(function).parameters
|
|
36
|
+
if not any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
|
|
37
|
+
missing = set(options) - set(parameters)
|
|
38
|
+
if missing:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"The installed instance-repo does not support {method} options: "
|
|
41
|
+
+ ", ".join(sorted(missing))
|
|
42
|
+
)
|
|
43
|
+
return options
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_hashes(path: str | None) -> dict[str, str]:
|
|
47
|
+
if path is None:
|
|
48
|
+
return {}
|
|
49
|
+
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
50
|
+
if not isinstance(value, dict) or any(
|
|
51
|
+
not isinstance(key, str) or not key.strip() or not isinstance(item, str) or not item.strip()
|
|
52
|
+
for key, item in value.items()
|
|
53
|
+
):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"--content-hashes must contain a JSON object of instance IDs to nonempty hashes"
|
|
56
|
+
)
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def upload_hashes(records: list[dict], supplied: dict[str, str], skip: bool) -> dict[str, str]:
|
|
61
|
+
hashes = dict(supplied)
|
|
62
|
+
if not skip:
|
|
63
|
+
return hashes
|
|
64
|
+
from instance_repo import compute_source_hash
|
|
65
|
+
|
|
66
|
+
for record in records:
|
|
67
|
+
instance = record["instance"]
|
|
68
|
+
path = Path(record["path"])
|
|
69
|
+
if not path.is_dir():
|
|
70
|
+
raise ValueError("--skip-unchanged currently supports instance directories only")
|
|
71
|
+
if instance in hashes:
|
|
72
|
+
continue
|
|
73
|
+
hashes[instance] = compute_source_hash(path)
|
|
74
|
+
return hashes
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def require_working_prune() -> None:
|
|
78
|
+
try:
|
|
79
|
+
version = metadata.version("instance-repo")
|
|
80
|
+
except metadata.PackageNotFoundError:
|
|
81
|
+
return
|
|
82
|
+
if version in {"1.3.5.dev1", "1.3.5.dev2"}:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"instance-repo {version} cannot complete --prune; upgrade to a fixed SDK before uploading"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def progress_callback(completed: int, total: int, result: Any) -> None:
|
|
89
|
+
instance = str(getattr(result, "instance_id", "") or type(result).__name__)
|
|
90
|
+
instance = "".join(char if char.isprintable() else repr(char)[1:-1] for char in instance)
|
|
91
|
+
status = "skipped" if getattr(result, "skipped", False) else "uploaded"
|
|
92
|
+
if isinstance(result, BaseException):
|
|
93
|
+
status = "failed"
|
|
94
|
+
typer.echo(f"progress: {completed}/{total} {instance} {status}", err=True)
|
|
@@ -951,10 +951,28 @@ def load_repo(
|
|
|
951
951
|
_setdefault_env(_LEGACY_STORAGE_ENV_VAR, effective_storage_env)
|
|
952
952
|
|
|
953
953
|
repo = _Repo(**kwargs)
|
|
954
|
+
_bind_dataset_profile(repo)
|
|
954
955
|
_install_oss_probe_cache(repo, verbose=cfg.verbose)
|
|
955
956
|
return repo
|
|
956
957
|
|
|
957
958
|
|
|
959
|
+
def _bind_dataset_profile(repo: Any) -> None:
|
|
960
|
+
"""Supply the configured context missing from SDK 1.3.5 dataset discovery.
|
|
961
|
+
|
|
962
|
+
Its environment check reads ``_p``, but its constructor does not initialize
|
|
963
|
+
that field. Bind only a missing field to this Repo's resolved profile;
|
|
964
|
+
SDKs that initialize it retain their own context.
|
|
965
|
+
"""
|
|
966
|
+
client = getattr(repo, "datasets", None)
|
|
967
|
+
profile = getattr(repo, "profile", None)
|
|
968
|
+
if (
|
|
969
|
+
profile is not None
|
|
970
|
+
and callable(getattr(client, "_discovery_env_match", None))
|
|
971
|
+
and not hasattr(client, "_p")
|
|
972
|
+
):
|
|
973
|
+
client._p = profile
|
|
974
|
+
|
|
975
|
+
|
|
958
976
|
def _init_parameters(repo_cls: type) -> frozenset[str]:
|
|
959
977
|
"""``Repo.__init__`` 接受的关键字参数名;签名不可读时返回空集合。"""
|
|
960
978
|
try:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ap-client"
|
|
7
|
-
version = "0.5.3.
|
|
7
|
+
version = "0.5.3.dev8"
|
|
8
8
|
description = "Agent Platform API Client & CLI"
|
|
9
9
|
readme = { text = "A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.", content-type = "text/markdown" }
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|