quantdb-sdk 0.2.4__tar.gz → 0.2.5__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.
Files changed (23) hide show
  1. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/CHANGELOG.md +11 -0
  2. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/MANIFEST.in +5 -5
  3. {quantdb_sdk-0.2.4/quantdb_sdk.egg-info → quantdb_sdk-0.2.5}/PKG-INFO +26 -1
  4. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/README.md +26 -1
  5. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/pyproject.toml +1 -1
  6. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk/__init__.py +1 -1
  7. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk/__main__.py +12 -5
  8. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk/_utils.py +54 -2
  9. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk/async_client.py +54 -39
  10. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk/client.py +820 -808
  11. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5/quantdb_sdk.egg-info}/PKG-INFO +26 -1
  12. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/tests/test_async_client.py +6 -0
  13. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/tests/test_client.py +24 -0
  14. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/LICENSE +0 -0
  15. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk/errors.py +0 -0
  16. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk/py.typed +0 -0
  17. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk.egg-info/SOURCES.txt +0 -0
  18. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk.egg-info/dependency_links.txt +0 -0
  19. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk.egg-info/entry_points.txt +0 -0
  20. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk.egg-info/requires.txt +0 -0
  21. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/quantdb_sdk.egg-info/top_level.txt +0 -0
  22. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/setup.cfg +0 -0
  23. {quantdb_sdk-0.2.4 → quantdb_sdk-0.2.5}/tests/test_technical_indicators.py +0 -0
@@ -3,6 +3,17 @@
3
3
  所有 notable 变更都会记录在此文件。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
4
  版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
5
5
 
6
+ ## [0.2.5] - 2026-07-26
7
+
8
+ ### Security
9
+ - 下载响应文件名与 release/Manifest 相对路径均限制在指定下载目录内,防止路径穿越。
10
+ - 公网 API Host 强制 HTTPS;CLI 改从环境变量读取 API Key,避免密钥出现在命令历史和进程参数中。
11
+ - 下载增加单文件大小上限(默认 2 GiB,可通过 `QUANTDB_MAX_DOWNLOAD_BYTES` 配置)。
12
+ - `query_local()` / `a_query_local()` 只接受 WHERE 条件,拒绝完整 SQL、JOIN 和外部表函数。
13
+
14
+ ### Documentation
15
+ - 更新官网 SDK 文档:并发下载建议、V1/V2 选择、安全配置与异常处理。
16
+
6
17
  ## [0.2.4] - 2026-07-26
7
18
 
8
19
  ### Fixed
@@ -1,5 +1,5 @@
1
- include README.md
2
- include LICENSE
3
- include CHANGELOG.md
4
- include pyproject.toml
5
- recursive-include quantdb_sdk *.py *.typed
1
+ include README.md
2
+ include LICENSE
3
+ include CHANGELOG.md
4
+ include pyproject.toml
5
+ recursive-include quantdb_sdk *.py *.typed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantdb-sdk
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: QuantDB 量化数据平台官方 Python SDK
5
5
  Author: QuantDB Team
6
6
  License: MIT
@@ -111,6 +111,31 @@ result = client.sync_dataset("daily_forward", save_dir="D:/quantdb-data")
111
111
  financial = client.sync_dataset("balance", save_dir="D:/quantdb-data")
112
112
  ```
113
113
 
114
+ ### 加速批量下载:建议从 8 个工作线程开始
115
+
116
+ `sync_dataset()` 单次调用会按发布顺序串行下载,以保证本地 SQLite 同步状态和 release cursor 一致。
117
+ 当需要下载多个独立标的或文件时,可由调用方并行调度;建议先使用 **8 个工作线程**,再结合网络带宽、磁盘写入能力和账户流量配额调整。
118
+
119
+ ```python
120
+ from concurrent.futures import ThreadPoolExecutor
121
+ from quantdb_sdk import QuantDBClient
122
+
123
+ API_KEY = "qdb_xxx..."
124
+ symbols = ["600519.SH", "000001.SZ", "600036.SH"]
125
+
126
+ def download_one(symbol: str) -> str:
127
+ # 每个 worker 使用独立客户端,避免跨线程共享 HTTP Session。
128
+ with_client = QuantDBClient(api_key=API_KEY)
129
+ return with_client.download_file(
130
+ "1", "daily_forward", symbol=symbol, save_dir="D:/quantdb-data"
131
+ )
132
+
133
+ with ThreadPoolExecutor(max_workers=8) as pool:
134
+ files = list(pool.map(download_one, symbols))
135
+ ```
136
+
137
+ 不要对相同 `save_dir` 并行调用多个 `sync_dataset()`:它们会共同写入 `quantdb_sync.sqlite`,可能产生锁竞争。批量同步本身仍建议一次一个数据集执行。
138
+
114
139
  ## 流量说明
115
140
 
116
141
  免费注册用户获赠 100 MB 一次性体验流量;订阅用户每月含 30 GB 下载流量,超出部分按 ¥1/GB 从账户余额扣减。余额不足时下载会被拦截。
@@ -73,8 +73,33 @@ result = client.sync_dataset("daily_forward", save_dir="D:/quantdb-data")
73
73
  # quantdb sync qdb_xxx balance --save-dir D:/quantdb-data
74
74
  financial = client.sync_dataset("balance", save_dir="D:/quantdb-data")
75
75
  ```
76
+
77
+ ### 加速批量下载:建议从 8 个工作线程开始
78
+
79
+ `sync_dataset()` 单次调用会按发布顺序串行下载,以保证本地 SQLite 同步状态和 release cursor 一致。
80
+ 当需要下载多个独立标的或文件时,可由调用方并行调度;建议先使用 **8 个工作线程**,再结合网络带宽、磁盘写入能力和账户流量配额调整。
81
+
82
+ ```python
83
+ from concurrent.futures import ThreadPoolExecutor
84
+ from quantdb_sdk import QuantDBClient
85
+
86
+ API_KEY = "qdb_xxx..."
87
+ symbols = ["600519.SH", "000001.SZ", "600036.SH"]
88
+
89
+ def download_one(symbol: str) -> str:
90
+ # 每个 worker 使用独立客户端,避免跨线程共享 HTTP Session。
91
+ with_client = QuantDBClient(api_key=API_KEY)
92
+ return with_client.download_file(
93
+ "1", "daily_forward", symbol=symbol, save_dir="D:/quantdb-data"
94
+ )
95
+
96
+ with ThreadPoolExecutor(max_workers=8) as pool:
97
+ files = list(pool.map(download_one, symbols))
98
+ ```
99
+
100
+ 不要对相同 `save_dir` 并行调用多个 `sync_dataset()`:它们会共同写入 `quantdb_sync.sqlite`,可能产生锁竞争。批量同步本身仍建议一次一个数据集执行。
76
101
 
77
- ## 流量说明
102
+ ## 流量说明
78
103
 
79
104
  免费注册用户获赠 100 MB 一次性体验流量;订阅用户每月含 30 GB 下载流量,超出部分按 ¥1/GB 从账户余额扣减。余额不足时下载会被拦截。
80
105
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quantdb-sdk"
7
- version = "0.2.4"
7
+ version = "0.2.5"
8
8
  description = "QuantDB 量化数据平台官方 Python SDK"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -5,7 +5,7 @@ try:
5
5
  # 注意:此处用 PyPI 包名(连字符)查找版本号,不是模块名(下划线)
6
6
  __version__ = version("quantdb-sdk")
7
7
  except ImportError:
8
- __version__ = "0.2.4"
8
+ __version__ = "0.2.5"
9
9
 
10
10
  from .async_client import AsyncQuantDBClient
11
11
  from .client import DuckDBWarehouse, QuantDBClient
@@ -3,10 +3,11 @@
3
3
  提供快速验证和诊断功能:
4
4
  python -m quantdb --version
5
5
  python -m quantdb --help
6
- python -m quantdb check <api_key> # 验证 API Key 是否有效
6
+ QUANTDB_API_KEY=qdb_xxx quantdb check # 验证 API Key 是否有效
7
7
  """
8
8
 
9
9
  import argparse
10
+ import os
10
11
  import sys
11
12
 
12
13
  from . import __version__
@@ -28,19 +29,21 @@ def main() -> int:
28
29
  default="https://quantdb.quantmind.cloud",
29
30
  help="API 服务地址 (默认: https://quantdb.quantmind.cloud)",
30
31
  )
32
+ parser.add_argument(
33
+ "--api-key-env",
34
+ default="QUANTDB_API_KEY",
35
+ help="保存 API Key 的环境变量名 (默认: QUANTDB_API_KEY)",
36
+ )
31
37
 
32
38
  subparsers = parser.add_subparsers(dest="command", help="可用命令")
33
39
 
34
40
  # check 命令:验证 API Key
35
41
  check_parser = subparsers.add_parser("check", help="验证 API Key 是否有效")
36
- check_parser.add_argument("api_key", help="要验证的 API Key")
37
42
 
38
43
  # usage 命令:查询用量
39
44
  usage_parser = subparsers.add_parser("usage", help="查询账户用量和订阅状态")
40
- usage_parser.add_argument("api_key", help="API Key")
41
45
 
42
46
  sync_parser = subparsers.add_parser("sync", help="以 release/Manifest 增量同步一个数据集")
43
- sync_parser.add_argument("api_key", help="API Key")
44
47
  sync_parser.add_argument("dataset", help="数据集,如 daily_forward、balance、etf_pcf")
45
48
  sync_parser.add_argument("--save-dir", default=None, help="本地同步根目录")
46
49
  sync_parser.add_argument("--after-release", default=None, help="覆盖本地 release cursor(一般无需传入)")
@@ -51,8 +54,12 @@ def main() -> int:
51
54
  parser.print_help()
52
55
  return 0
53
56
 
57
+ api_key = os.getenv(args.api_key_env)
58
+ if not api_key:
59
+ parser.error(f"请先设置 API Key 环境变量 {args.api_key_env}")
60
+
54
61
  try:
55
- client = QuantDBClient(api_host=args.api_host, api_key=args.api_key)
62
+ client = QuantDBClient(api_host=args.api_host, api_key=api_key)
56
63
 
57
64
  if args.command == "check":
58
65
  me = client.get_me()
@@ -5,8 +5,8 @@
5
5
 
6
6
  import os
7
7
  import re
8
- from typing import Dict
9
- from urllib.parse import unquote
8
+ from typing import Dict, Optional
9
+ from urllib.parse import unquote, urlparse
10
10
 
11
11
 
12
12
  # ``sync_dataset`` 的统一数据集目录。V2 可用时走 release cursor;其余数据集
@@ -72,6 +72,58 @@ def parse_filename_from_content_disposition(value: str, fallback: str) -> str:
72
72
  return fallback
73
73
 
74
74
 
75
+ def safe_filename(filename: str, fallback: str = "download.parquet") -> str:
76
+ """Return one safe filename, using *fallback* for untrusted input."""
77
+ candidate = filename.strip()
78
+ if (not candidate or candidate in {".", ".."} or "/" in candidate
79
+ or "\\" in candidate or "\x00" in candidate
80
+ or re.match(r"^[A-Za-z]:", candidate)):
81
+ return fallback
82
+ return candidate
83
+
84
+
85
+ def safe_join(root: str, relative_path: str) -> str:
86
+ """Join an untrusted manifest path and prove it remains below *root*."""
87
+ if (not isinstance(relative_path, str) or not relative_path or "\\" in relative_path
88
+ or relative_path.startswith("/") or re.match(r"^[A-Za-z]:", relative_path)):
89
+ raise ValueError("发布清单包含非法路径")
90
+ parts = relative_path.split("/")
91
+ if any(part in {"", ".", ".."} for part in parts):
92
+ raise ValueError("发布清单包含路径穿越")
93
+ root_abs = os.path.abspath(root)
94
+ target = os.path.abspath(os.path.join(root_abs, *parts))
95
+ if os.path.commonpath([root_abs, target]) != root_abs:
96
+ raise ValueError("发布清单路径超出同步目录")
97
+ return target
98
+
99
+
100
+ def validate_api_host(api_host: str) -> str:
101
+ """Require HTTPS except for local development/test loopback endpoints."""
102
+ host = api_host.rstrip("/")
103
+ parsed = urlparse(host)
104
+ if parsed.scheme == "https" and parsed.netloc:
105
+ return host
106
+ if parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}:
107
+ return host
108
+ raise ValueError("api_host 必须为 HTTPS 地址;仅 localhost 可使用 HTTP")
109
+
110
+
111
+ def max_download_bytes() -> int:
112
+ """Configured response cap; default 2 GiB protects memory and disk."""
113
+ try:
114
+ value = int(os.getenv("QUANTDB_MAX_DOWNLOAD_BYTES", str(2 * 1024 ** 3)))
115
+ except ValueError as exc:
116
+ raise ValueError("QUANTDB_MAX_DOWNLOAD_BYTES 必须是正整数") from exc
117
+ if value <= 0:
118
+ raise ValueError("QUANTDB_MAX_DOWNLOAD_BYTES 必须大于 0")
119
+ return value
120
+
121
+
122
+ def check_download_size(content_length: Optional[str], maximum: int) -> None:
123
+ if content_length and content_length.isdigit() and int(content_length) > maximum:
124
+ raise ValueError(f"下载文件超过大小限制({maximum} 字节)")
125
+
126
+
75
127
  def bytes_to_gb(n: int) -> float:
76
128
  """将字节数转换为 GB(1024 进制)。
77
129
 
@@ -10,7 +10,11 @@ from typing import Any, Dict, List, Optional, Literal
10
10
  import httpx
11
11
  import pandas as pd
12
12
 
13
- from ._utils import SYNC_DATASET_CATEGORIES, bytes_to_gb, default_download_dir, parse_filename_from_content_disposition
13
+ from ._utils import (
14
+ SYNC_DATASET_CATEGORIES, bytes_to_gb, check_download_size, default_download_dir,
15
+ max_download_bytes, parse_filename_from_content_disposition, safe_filename, safe_join,
16
+ validate_api_host,
17
+ )
14
18
  from .errors import (
15
19
  AuthError,
16
20
  InsufficientTrafficError,
@@ -35,9 +39,9 @@ class AsyncQuantDBClient:
35
39
  token: Optional[str] = None,
36
40
  timeout: float = 60.0,
37
41
  ):
38
- self.api_host = api_host.rstrip("/")
42
+ self.api_host = validate_api_host(api_host)
39
43
  self.timeout = timeout
40
- headers = {"User-Agent": "QuantDB-Python-SDK/0.2.4"}
44
+ headers = {"User-Agent": "QuantDB-Python-SDK/0.2.5"}
41
45
  if api_key:
42
46
  headers["X-API-Key"] = api_key
43
47
  elif token:
@@ -411,15 +415,26 @@ class AsyncQuantDBClient:
411
415
  fallback = f"{sub_category}.parquet"
412
416
  if symbol:
413
417
  fallback = f"{sub_category}_{symbol}.parquet"
414
- filename = parse_filename_from_content_disposition(cd, fallback)
418
+ filename = safe_filename(parse_filename_from_content_disposition(cd, fallback), safe_filename(fallback))
415
419
 
416
420
  save_path = os.path.join(save_dir, filename)
417
421
  tmp_path = save_path + ".part"
418
- with open(tmp_path, "wb") as f:
419
- async for chunk in resp.aiter_bytes(chunk_size=8192):
420
- if chunk:
421
- f.write(chunk)
422
- os.replace(tmp_path, save_path)
422
+ maximum = max_download_bytes()
423
+ check_download_size(resp.headers.get("Content-Length"), maximum)
424
+ written = 0
425
+ try:
426
+ with open(tmp_path, "wb") as f:
427
+ async for chunk in resp.aiter_bytes(chunk_size=8192):
428
+ if chunk:
429
+ written += len(chunk)
430
+ if written > maximum:
431
+ raise ServerError(f"下载文件超过大小限制({maximum} 字节)")
432
+ f.write(chunk)
433
+ os.replace(tmp_path, save_path)
434
+ except Exception:
435
+ if os.path.exists(tmp_path):
436
+ os.remove(tmp_path)
437
+ raise
423
438
  return os.path.abspath(save_path)
424
439
 
425
440
  async def a_load_as_df(
@@ -452,17 +467,27 @@ class AsyncQuantDBClient:
452
467
  req_headers = {}
453
468
  if cached and cached.get("etag"):
454
469
  req_headers["If-None-Match"] = cached["etag"]
455
- resp = await self.client.get(
456
- f"{self.api_host}/api/v1/data/download", params=params, headers=req_headers
457
- )
458
- if resp.status_code == 304 and cached:
459
- return cached["df"]
460
- if resp.status_code != 200:
461
- self._check_response(resp)
462
- etag = resp.headers.get("ETag") or ""
463
- if cached and etag and cached.get("etag") == etag:
464
- return cached["df"]
465
- df = pd.read_parquet(io.BytesIO(resp.content))
470
+ async with self.client.stream(
471
+ "GET", f"{self.api_host}/api/v1/data/download", params=params, headers=req_headers
472
+ ) as resp:
473
+ if resp.status_code == 304 and cached:
474
+ return cached["df"]
475
+ if resp.status_code != 200:
476
+ body = await resp.aread()
477
+ self._check_response(httpx.Response(resp.status_code, content=body))
478
+ etag = resp.headers.get("ETag") or ""
479
+ if cached and etag and cached.get("etag") == etag:
480
+ return cached["df"]
481
+ maximum = max_download_bytes()
482
+ check_download_size(resp.headers.get("Content-Length"), maximum)
483
+ payload, written = io.BytesIO(), 0
484
+ async for chunk in resp.aiter_bytes(chunk_size=1024 * 1024):
485
+ if chunk:
486
+ written += len(chunk)
487
+ if written > maximum:
488
+ raise ServerError(f"下载文件超过大小限制({maximum} 字节)")
489
+ payload.write(chunk)
490
+ df = pd.read_parquet(payload)
466
491
  if etag:
467
492
  self._cache[cache_key] = {"etag": etag, "df": df}
468
493
  return df
@@ -480,13 +505,12 @@ class AsyncQuantDBClient:
480
505
  若文件不存在会先自动下载。
481
506
 
482
507
  sql 支持两种写法:
483
- 1. 完整 SELECT 语句(必须包含 FROM 子句,表名会被替换为 parquet 路径)
484
- 2. 纯 WHERE 条件字符串(不含 FROM 时自动拼装为 SELECT * FROM <path> WHERE <条件>)
508
+ 1. 仅支持纯 WHERE 条件字符串,SDK 固定查询已下载的 Parquet 文件
485
509
 
486
510
  安全限制:
487
511
  - 不允许分号(;),防止多语句注入
488
512
  - 不允许注释(-- 或 /* */),防止注释注入
489
- - 不允许 UNION / DROP / DELETE / INSERT / UPDATE / ALTER / CREATE / EXEC / EXECUTE
513
+ - 不允许子查询、JOIN、外部表函数或任何数据修改语句
490
514
  """
491
515
  file_path = await self.a_download_file(
492
516
  category_id=category_id,
@@ -503,27 +527,18 @@ class AsyncQuantDBClient:
503
527
 
504
528
  # 安全检查:拒绝危险字符和关键字
505
529
  dangerous_keywords = [
506
- ";", "--", "/*", "*/", "union", "drop", "delete",
507
- "insert", "update", "alter", "create", "exec", "execute",
530
+ ";", "--", "/*", "*/", "select", "from", "join", "union", "drop",
531
+ "delete", "insert", "update", "alter", "create", "exec", "execute",
532
+ "attach", "copy", "pragma", "install", "load", "read_", "http", "glob",
508
533
  ]
509
534
  sql_upper = sql.upper()
510
535
  for kw in dangerous_keywords:
511
536
  if kw in sql_upper:
512
537
  raise ValidationError(
513
538
  f"SQL 包含危险关键字 '{kw}',已被拒绝。"
514
- "a_query_local 仅支持 SELECT 查询,不支持数据修改或联合查询。"
539
+ "a_query_local 仅支持针对已下载文件的 WHERE 条件。"
515
540
  )
516
-
517
- if "FROM" in sql_upper:
518
- sql = re.sub(
519
- r"FROM\s+[`\"\']?\w+[`\"\']?",
520
- f"FROM '{clean_path}'",
521
- sql,
522
- count=1,
523
- flags=re.IGNORECASE,
524
- )
525
- else:
526
- sql = f"SELECT * FROM '{clean_path}' WHERE {sql}"
541
+ sql = f"SELECT * FROM '{clean_path}' WHERE {sql}"
527
542
  return duckdb.query(sql).df()
528
543
 
529
544
  async def a_sync_dataset(self, dataset: str, save_dir: Optional[str] = None, after_release: Optional[str] = None) -> Dict[str, Any]:
@@ -546,7 +561,7 @@ class AsyncQuantDBClient:
546
561
  for obj in release.get("objects", []):
547
562
  key = self._normalise_release_key(obj["key"])
548
563
  relative_path = obj.get("relative_path") or key
549
- target = os.path.join(root, *relative_path.split("/"))
564
+ target = safe_join(root, relative_path)
550
565
  expected_size = obj.get("size")
551
566
  old = state.execute("SELECT etag,sha256,size,path FROM objects WHERE key=?", (key,)).fetchone()
552
567
  if old and old[0] == obj.get("etag") and old[1] == obj.get("sha256") and os.path.exists(old[3]) and (expected_size is None or os.path.getsize(old[3]) == expected_size): continue
@@ -573,7 +588,7 @@ class AsyncQuantDBClient:
573
588
  if persisted: return {"dataset": dataset, "layout": "v2_daily_partition", "downloaded": [], "after_release": cursor, "release_id": persisted[0]}
574
589
  files = (await self._get("/api/v1/data/download/manifest", {"category_id": SYNC_DATASET_CATEGORIES[dataset], "sub_category": dataset, "layout": "v1"})).get("files", [])
575
590
  for obj in files:
576
- key, target = obj["key"], os.path.join(root, *(obj.get("relative_path") or obj["key"]).split("/"))
591
+ key, target = obj["key"], safe_join(root, obj.get("relative_path") or obj["key"])
577
592
  expected_size = obj.get("size")
578
593
  old = state.execute("SELECT etag,size,path FROM objects WHERE key=?", (key,)).fetchone()
579
594
  if old and old[0] == obj.get("etag") and os.path.exists(old[2]) and (expected_size is None or os.path.getsize(old[2]) == expected_size): continue