Skip to content

Commit d822841

Browse files
committed
feat: Add orjson for faster JSON processing and update schema caching logic; enhance tests for schema functionality
1 parent 55da9f3 commit d822841

7 files changed

Lines changed: 480 additions & 202 deletions

File tree

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ requires-python = ">=3.8"
3535
dependencies = [
3636
"aiohttp>=3.8.0,<5.0.0",
3737
"dacite>=1.6.0,<2.0.0",
38+
"orjson>=3.0.0",
3839
]
3940

4041
[project.urls]
@@ -55,3 +56,9 @@ veedb = ["py.typed", "VERSION"]
5556

5657
[tool.setuptools.dynamic]
5758
version = {file = "src/veedb/VERSION"}
59+
60+
[tool.poetry.group.dev.dependencies]
61+
pytest-asyncio = "^0.21.0"
62+
63+
[tool.pytest.ini_options]
64+
asyncio_mode = "auto"

src/veedb/apitypes/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@
141141
class ImageCommon:
142142
id: VNDBID
143143
url: str
144-
dims: Tuple[int, int] # [width, height]
144+
dims: List[int] # [width, height]
145145
sexual: float # 0.0-2.0 (average flagging vote)
146146
violence: float # 0.0-2.0 (average flagging vote)
147147
votecount: int

src/veedb/schema_validator.py

Lines changed: 149 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import asyncio
2-
import json
2+
import orjson # Added for faster JSON processing
33
import time
44
import os
55
from pathlib import Path
@@ -8,6 +8,7 @@
88
import aiohttp
99

1010
from .exceptions import InvalidRequestError, VNDBAPIError
11+
from .methods.fetch import _fetch_api # Ensure this import is present
1112

1213
# Forward declaration for type hinting
1314
if "VNDB" not in globals():
@@ -19,31 +20,89 @@ class SchemaCache:
1920
"""
2021
Manages the download, caching, and retrieval of the VNDB API schema.
2122
"""
23+
2224
def __init__(self, cache_dir: str = ".veedb_cache", cache_filename: str = "schema.json", ttl_hours: float = 24.0, local_schema_path: Optional[str] = None):
23-
self.cache_dir = Path(cache_dir)
24-
self.cache_file = self.cache_dir / cache_filename
25+
# Use string paths initially to avoid any Path recursion issues
26+
self._cache_dir_str = str(cache_dir) if cache_dir else ".veedb_cache"
27+
self._cache_filename_str = str(cache_filename) if cache_filename else "schema.json"
28+
self._local_schema_path_str = str(local_schema_path) if local_schema_path else None
29+
30+
# Create Path objects only when needed and with error handling
31+
self._cache_dir = None
32+
self._cache_file = None
33+
self._local_schema_path = None
34+
2535
self.ttl_seconds = ttl_hours * 3600
2636
self._schema_data: Optional[Dict[str, Any]] = None
27-
self.local_schema_path = Path(local_schema_path) if local_schema_path else None
37+
38+
@property
39+
def cache_dir(self) -> Path:
40+
"""Safely get the cache directory Path object."""
41+
if self._cache_dir is None:
42+
try:
43+
self._cache_dir = Path(self._cache_dir_str)
44+
except Exception:
45+
self._cache_dir = Path(".veedb_cache")
46+
return self._cache_dir
47+
48+
@property
49+
def cache_file(self) -> Path:
50+
"""Safely get the cache file Path object."""
51+
if self._cache_file is None:
52+
try:
53+
self._cache_file = self.cache_dir / self._cache_filename_str
54+
except Exception:
55+
self._cache_file = Path(".veedb_cache") / "schema.json"
56+
return self._cache_file
57+
58+
@property
59+
def local_schema_path(self) -> Optional[Path]:
60+
"""Safely get the local schema path Path object."""
61+
if self._local_schema_path is None and self._local_schema_path_str:
62+
try:
63+
self._local_schema_path = Path(self._local_schema_path_str)
64+
except Exception:
65+
self._local_schema_path = None
66+
return self._local_schema_path
2867

2968
def is_cached(self) -> bool:
3069
"""Check if the schema file exists in the cache or if a local path is provided."""
31-
if self.local_schema_path and self.local_schema_path.is_file():
32-
return True
33-
return self.cache_file.is_file()
70+
if self._local_schema_path_str:
71+
try:
72+
# Use os.path.isfile instead of Path.is_file() to avoid recursion
73+
return os.path.isfile(self._local_schema_path_str)
74+
except (RecursionError, OSError, Exception):
75+
pass
76+
77+
try:
78+
# Use os.path.exists instead of Path.exists() to avoid recursion
79+
cache_path = os.path.join(self._cache_dir_str, self._cache_filename_str)
80+
return os.path.exists(cache_path)
81+
except (RecursionError, OSError, Exception):
82+
return False
3483

3584
def get_cache_age(self) -> float:
3685
"""Get the age of the cache file in seconds. Returns 0 if using local_schema_path."""
37-
if self.local_schema_path and self.local_schema_path.is_file():
38-
# Treat local schema as always up-to-date unless explicitly updated
39-
return 0.0
40-
if not self.cache_file.is_file(): # Changed from self.is_cached() to self.cache_file.is_file()
86+
if self._local_schema_path_str:
87+
try:
88+
# Use os.path.isfile instead of Path methods to avoid recursion
89+
if os.path.isfile(self._local_schema_path_str):
90+
# Treat local schema as always up-to-date unless explicitly updated
91+
return 0.0
92+
except (RecursionError, OSError, Exception):
93+
pass
94+
95+
try:
96+
cache_path = os.path.join(self._cache_dir_str, self._cache_filename_str)
97+
if not os.path.exists(cache_path):
98+
return float('inf')
99+
return time.time() - os.path.getmtime(cache_path)
100+
except (RecursionError, OSError, Exception):
41101
return float('inf')
42-
return time.time() - self.cache_file.stat().st_mtime
43102

44103
def is_cache_expired(self) -> bool:
45104
"""Check if the cached schema has expired. Local schema path is never considered expired by this check."""
46-
if self.local_schema_path and self.local_schema_path.is_file():
105+
if self._local_schema_path_str and os.path.isfile(self._local_schema_path_str):
47106
return False # Local schema is not subject to TTL expiration, only manual updates
48107
return self.get_cache_age() > self.ttl_seconds
49108

@@ -57,24 +116,35 @@ def save_schema(self, schema_data: Dict[str, Any], to_local_path: bool = False):
57116
target_dir = target_path.parent
58117
target_dir.mkdir(parents=True, exist_ok=True)
59118

60-
with open(target_path, 'w', encoding='utf-8') as f:
61-
json.dump(schema_data, f, indent=2)
119+
# Use orjson for writing
120+
with open(target_path, 'wb') as f: # Open in binary mode for orjson
121+
f.write(orjson.dumps(schema_data, option=orjson.OPT_INDENT_2))
62122
self._schema_data = schema_data # Update in-memory cache as well
63-
123+
64124
def load_schema(self) -> Optional[Dict[str, Any]]:
65125
"""Load the schema data from the local_schema_path (if provided) or the cache file."""
66-
if self.local_schema_path and self.local_schema_path.is_file():
126+
if self._local_schema_path_str and os.path.isfile(self._local_schema_path_str):
67127
try:
68-
with open(self.local_schema_path, 'r', encoding='utf-8') as f:
69-
return json.load(f)
128+
# Use orjson for reading
129+
with open(self._local_schema_path_str, 'rb') as f: # Open in binary mode for orjson
130+
return orjson.loads(f.read())
70131
except Exception:
71132
# If local schema fails to load, fall back to cache or download
72133
pass
73134

74-
if self.cache_file.is_file(): # Changed from self.is_cached()
135+
# Use os.path instead of Path methods to avoid recursion issues
136+
try:
137+
cache_path_str = os.path.join(self._cache_dir_str, self._cache_filename_str)
138+
cache_file_exists = os.path.isfile(cache_path_str) # Changed from os.path.exists to os.path.isfile
139+
except (RecursionError, OSError, Exception):
140+
# If there's any issue with the cache_file path, return None
141+
return None
142+
143+
if cache_file_exists:
75144
try:
76-
with open(self.cache_file, 'r', encoding='utf-8') as f:
77-
return json.load(f)
145+
# Use orjson for reading
146+
with open(cache_path_str, 'rb') as f: # Open in binary mode for orjson
147+
return orjson.loads(f.read())
78148
except Exception:
79149
pass
80150
return None
@@ -83,8 +153,9 @@ def invalidate_cache(self):
83153
"""Remove the cache file. Does not remove user-provided local_schema_path."""
84154
self._schema_data = None
85155
try:
86-
if self.cache_file.exists():
87-
os.remove(self.cache_file)
156+
cache_path = os.path.join(self._cache_dir_str, self._cache_filename_str)
157+
if os.path.exists(cache_path):
158+
os.remove(cache_path)
88159
except FileNotFoundError:
89160
pass
90161

@@ -124,16 +195,61 @@ async def update_local_schema_from_api(self, client: 'VNDB') -> Dict[str, Any]:
124195
return await self.get_schema(client, force_download=True)
125196

126197
async def _download_schema(self, client: 'VNDB') -> Dict[str, Any]:
127-
"""Fetch the schema from the VNDB API."""
198+
"""Fetch the schema from the VNDB API directly."""
128199
try:
129-
return await client.get_schema()
130-
except VNDBAPIError as e:
131-
# If download fails but a stale cache exists, use it as a fallback
132-
if self.is_cached():
133-
schema = self.load_schema()
134-
if schema:
135-
return schema
136-
raise e # Re-raise if there's no cache at all
200+
# Call the API directly to avoid recursion - do NOT call client.get_schema()
201+
url = f"{client.base_url}/schema"
202+
session = client._get_session()
203+
204+
# Use the imported _fetch_api
205+
# The schema endpoint typically does not require a token.
206+
response_data = await _fetch_api(
207+
session=session,
208+
method="GET",
209+
url=url,
210+
token=None # Explicitly None for public schema endpoint
211+
)
212+
if not isinstance(response_data, dict):
213+
raise VNDBAPIError(f"Schema download did not return a valid JSON object. Received type: {type(response_data)}")
214+
return response_data
215+
except aiohttp.ClientError as e:
216+
raise VNDBAPIError(f"Failed to download schema due to network/HTTP error: {e}") from e
217+
except Exception as e:
218+
# Catch other potential errors during the fetch or processing
219+
raise VNDBAPIError(f"An unexpected error occurred while downloading schema: {e}") from e
220+
221+
async def update_local_schema_from_api(self, client: 'VNDB') -> Dict[str, Any]:
222+
"""Forces a download of the schema and saves it to local_schema_path if configured, else to cache."""
223+
if not self.local_schema_path:
224+
# If no specific local path, update the default cache file.
225+
# Or, one might choose to raise an error if this method is called without a local_schema_path configured.
226+
# For now, let's assume it updates the primary schema location (local if set, else cache).
227+
pass # Fall through to get_schema with force_download
228+
return await self.get_schema(client, force_download=True)
229+
230+
async def _download_schema(self, client: 'VNDB') -> Dict[str, Any]:
231+
"""Fetch the schema from the VNDB API directly."""
232+
try:
233+
# Call the API directly to avoid recursion - do NOT call client.get_schema()
234+
url = f"{client.base_url}/schema"
235+
session = client._get_session()
236+
237+
# Use the imported _fetch_api
238+
# The schema endpoint typically does not require a token.
239+
response_data = await _fetch_api(
240+
session=session,
241+
method="GET",
242+
url=url,
243+
token=None # Explicitly None for public schema endpoint
244+
)
245+
if not isinstance(response_data, dict):
246+
raise VNDBAPIError(f"Schema download did not return a valid JSON object. Received type: {type(response_data)}")
247+
return response_data
248+
except aiohttp.ClientError as e:
249+
raise VNDBAPIError(f"Failed to download schema due to network/HTTP error: {e}") from e
250+
except Exception as e:
251+
# Catch other potential errors during the fetch or processing
252+
raise VNDBAPIError(f"An unexpected error occurred while downloading schema: {e}") from e
137253

138254
class FilterValidator:
139255
"""

test_schema_fix.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Test script to verify the recursion fix for schema caching.
4+
This script tests all the schema-related functionality to ensure no recursion occurs.
5+
"""
6+
7+
import asyncio
8+
import sys
9+
import traceback
10+
from veedb import VNDB, QueryRequest
11+
12+
async def test_comprehensive_schema():
13+
"""Test all schema-related functionality to ensure no recursion."""
14+
print("🧪 Testing comprehensive schema functionality...")
15+
16+
try:
17+
async with VNDB(use_sandbox=False) as vndb:
18+
print("\n1. Testing get_schema()...")
19+
schema = await vndb.get_schema()
20+
print(f" ✅ Schema retrieved with keys: {list(schema.keys())}")
21+
22+
print("\n2. Testing get_enums()...")
23+
enums = await vndb.get_enums()
24+
print(f" ✅ Found {len(enums)} enum types: {list(enums.keys())}")
25+
26+
print("\n3. Testing filter validation (uses schema)...")
27+
validation = await vndb.validate_filters('/vn', ['title', '=', 'test'])
28+
print(f" ✅ Filter validation successful: {validation.get('valid', False)}")
29+
30+
print("\n4. Testing get_available_fields (uses schema)...")
31+
fields = await vndb.get_available_fields('/vn')
32+
print(f" ✅ Found {len(fields)} available fields for /vn")
33+
34+
print("\n5. Testing VN query (autocomplete scenario)...")
35+
search_req = QueryRequest(
36+
filters=['search', '=', 'fate'],
37+
results=3,
38+
fields='id, title, released, olang'
39+
)
40+
results = await vndb.vn.query(search_req)
41+
print(f" ✅ VN search successful: {len(results.results)} results")
42+
43+
print("\n6. Testing language enum (Discord autocomplete scenario)...")
44+
languages = enums.get('language', [])
45+
filtered_langs = [
46+
lang for lang in languages
47+
if 'en' in lang['label'].lower() or lang['id'].startswith('en')
48+
][:5]
49+
print(f" ✅ Language filtering successful: {len(filtered_langs)} matching languages")
50+
51+
print("\n🎉 All tests passed! No recursion detected.")
52+
return True
53+
54+
except RecursionError as e:
55+
print(f"\n❌ RECURSION ERROR DETECTED!")
56+
print(f"Error: {e}")
57+
traceback.print_exc()
58+
return False
59+
except Exception as e:
60+
print(f"\n❌ OTHER ERROR: {e}")
61+
traceback.print_exc()
62+
return False
63+
64+
async def main():
65+
"""Main test function."""
66+
print("VeeDB Schema Recursion Fix Test")
67+
print("=" * 40)
68+
69+
success = await test_comprehensive_schema()
70+
71+
if success:
72+
print("\n✅ ALL TESTS PASSED - Schema caching works correctly!")
73+
sys.exit(0)
74+
else:
75+
print("\n❌ TESTS FAILED - Check the error messages above")
76+
sys.exit(1)
77+
78+
if __name__ == "__main__":
79+
asyncio.run(main())

0 commit comments

Comments
 (0)