atdata 0.2.0a1__py3-none-any.whl → 0.2.2b1__py3-none-any.whl

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.
@@ -0,0 +1,529 @@
1
+ """Module manager for automatic Python module generation.
2
+
3
+ This module provides automatic generation and management of Python modules
4
+ for dynamically decoded schema types. When enabled, modules are generated
5
+ on schema access to provide IDE autocomplete and type checking support.
6
+
7
+ Unlike simple .pyi stubs, the generated modules are actual Python code that
8
+ can be imported at runtime. This allows ``decode_schema`` to return properly
9
+ typed classes that work with both static type checkers and runtime.
10
+
11
+ Example:
12
+ ::
13
+
14
+ >>> from atdata.local import Index
15
+ >>>
16
+ >>> # Enable auto-stub generation
17
+ >>> index = Index(auto_stubs=True)
18
+ >>>
19
+ >>> # Modules are generated automatically on decode_schema
20
+ >>> MyType = index.decode_schema("atdata://local/sampleSchema/MySample@1.0.0")
21
+ >>> # MyType is now properly typed for IDE autocomplete!
22
+ >>>
23
+ >>> # Get the stub directory path for IDE configuration
24
+ >>> print(f"Add to IDE: {index.stub_dir}")
25
+ """
26
+
27
+ from pathlib import Path
28
+ from typing import Optional, Union, Type
29
+ import os
30
+ import re
31
+ import sys
32
+ import tempfile
33
+ import fcntl
34
+ import importlib.util
35
+
36
+ from ._schema_codec import generate_module
37
+
38
+
39
+ # Default stub directory location
40
+ DEFAULT_STUB_DIR = Path.home() / ".atdata" / "stubs"
41
+
42
+ # Pattern to extract version from module docstring
43
+ _VERSION_PATTERN = re.compile(r"^Schema: .+@(\d+\.\d+\.\d+)", re.MULTILINE)
44
+
45
+ # Pattern to extract authority from atdata:// URI
46
+ _AUTHORITY_PATTERN = re.compile(r"^atdata://([^/]+)/")
47
+
48
+ # Default authority for schemas without a ref
49
+ DEFAULT_AUTHORITY = "local"
50
+
51
+
52
+ def _extract_authority(schema_ref: Optional[str]) -> str:
53
+ """Extract authority from a schema reference URI.
54
+
55
+ Args:
56
+ schema_ref: Schema ref like "atdata://local/sampleSchema/Name@1.0.0"
57
+ or "atdata://alice.bsky.social/sampleSchema/Name@1.0.0"
58
+
59
+ Returns:
60
+ Authority string (e.g., "local", "alice.bsky.social", "did_plc_xxx").
61
+ Special characters like ':' are replaced with '_' for filesystem safety.
62
+ """
63
+ if not schema_ref:
64
+ return DEFAULT_AUTHORITY
65
+
66
+ match = _AUTHORITY_PATTERN.match(schema_ref)
67
+ if match:
68
+ authority = match.group(1)
69
+ # Make filesystem-safe: replace : with _
70
+ return authority.replace(":", "_")
71
+
72
+ return DEFAULT_AUTHORITY
73
+
74
+
75
+ class StubManager:
76
+ """Manages automatic generation of Python modules for decoded schemas.
77
+
78
+ The StubManager handles:
79
+ - Determining module file paths from schema metadata
80
+ - Checking if modules exist and are current
81
+ - Generating modules atomically (write to temp, rename)
82
+ - Creating __init__.py files for proper package structure
83
+ - Importing classes from generated modules
84
+ - Cleaning up old modules
85
+
86
+ Modules are organized by authority (from the schema ref URI) to avoid
87
+ collisions between schemas with the same name from different sources::
88
+
89
+ ~/.atdata/stubs/
90
+ __init__.py
91
+ local/
92
+ __init__.py
93
+ MySample_1_0_0.py
94
+ alice.bsky.social/
95
+ __init__.py
96
+ MySample_1_0_0.py
97
+ did_plc_abc123/
98
+ __init__.py
99
+ OtherSample_2_0_0.py
100
+
101
+ Args:
102
+ stub_dir: Directory to write module files. Defaults to ``~/.atdata/stubs/``.
103
+
104
+ Example:
105
+ ::
106
+
107
+ >>> manager = StubManager()
108
+ >>> schema_dict = {"name": "MySample", "version": "1.0.0", "fields": [...]}
109
+ >>> SampleClass = manager.ensure_module(schema_dict)
110
+ >>> print(manager.stub_dir)
111
+ /Users/you/.atdata/stubs
112
+ """
113
+
114
+ def __init__(self, stub_dir: Optional[Union[str, Path]] = None):
115
+ if stub_dir is None:
116
+ self._stub_dir = DEFAULT_STUB_DIR
117
+ else:
118
+ self._stub_dir = Path(stub_dir)
119
+
120
+ self._initialized = False
121
+ self._first_generation = True
122
+ # Cache of imported classes: (authority, name, version) -> class
123
+ self._class_cache: dict[tuple[str, str, str], Type] = {}
124
+
125
+ @property
126
+ def stub_dir(self) -> Path:
127
+ """The directory where module files are written."""
128
+ return self._stub_dir
129
+
130
+ def _ensure_dir_exists(self) -> None:
131
+ """Create stub directory with __init__.py if it doesn't exist."""
132
+ if not self._initialized:
133
+ self._stub_dir.mkdir(parents=True, exist_ok=True)
134
+ # Create root __init__.py
135
+ init_path = self._stub_dir / "__init__.py"
136
+ if not init_path.exists():
137
+ init_path.write_text('"""Auto-generated atdata schema modules."""\n')
138
+ self._initialized = True
139
+
140
+ def _module_filename(self, name: str, version: str) -> str:
141
+ """Generate module filename from schema name and version.
142
+
143
+ Replaces dots in version with underscores to avoid confusion
144
+ with file extensions.
145
+
146
+ Args:
147
+ name: Schema name (e.g., "MySample")
148
+ version: Schema version (e.g., "1.0.0")
149
+
150
+ Returns:
151
+ Filename like "MySample_1_0_0.py"
152
+ """
153
+ safe_version = version.replace(".", "_")
154
+ return f"{name}_{safe_version}.py"
155
+
156
+ def _stub_filename(self, name: str, version: str) -> str:
157
+ """Alias for _module_filename for backwards compatibility."""
158
+ return self._module_filename(name, version)
159
+
160
+ def _module_path(self, name: str, version: str, authority: str = DEFAULT_AUTHORITY) -> Path:
161
+ """Get full path to module file for a schema.
162
+
163
+ Args:
164
+ name: Schema name
165
+ version: Schema version
166
+ authority: Authority from schema ref (e.g., "local", "alice.bsky.social")
167
+
168
+ Returns:
169
+ Path like ~/.atdata/stubs/local/MySample_1_0_0.py
170
+ """
171
+ return self._stub_dir / authority / self._module_filename(name, version)
172
+
173
+ def _stub_path(self, name: str, version: str, authority: str = DEFAULT_AUTHORITY) -> Path:
174
+ """Alias for _module_path for backwards compatibility."""
175
+ return self._module_path(name, version, authority)
176
+
177
+ def _module_is_current(self, path: Path, version: str) -> bool:
178
+ """Check if an existing module file matches the expected version.
179
+
180
+ Reads the module docstring to extract the version and compares
181
+ it to the expected version.
182
+
183
+ Args:
184
+ path: Path to the module file
185
+ version: Expected schema version
186
+
187
+ Returns:
188
+ True if module exists and version matches
189
+ """
190
+ if not path.exists():
191
+ return False
192
+
193
+ try:
194
+ with open(path, "r", encoding="utf-8") as f:
195
+ content = f.read(500) # Read first 500 chars for docstring
196
+ match = _VERSION_PATTERN.search(content)
197
+ if match:
198
+ return match.group(1) == version
199
+ return False
200
+ except (OSError, IOError):
201
+ return False
202
+
203
+ def _stub_is_current(self, path: Path, version: str) -> bool:
204
+ """Alias for _module_is_current for backwards compatibility."""
205
+ return self._module_is_current(path, version)
206
+
207
+ def _ensure_authority_package(self, authority: str) -> None:
208
+ """Ensure authority subdirectory exists with __init__.py."""
209
+ self._ensure_dir_exists()
210
+ authority_dir = self._stub_dir / authority
211
+ authority_dir.mkdir(parents=True, exist_ok=True)
212
+ init_path = authority_dir / "__init__.py"
213
+ if not init_path.exists():
214
+ init_path.write_text(f'"""Auto-generated schema modules for {authority}."""\n')
215
+
216
+ def _write_module_atomic(self, path: Path, content: str, authority: str) -> None:
217
+ """Write module file atomically using temp file and rename.
218
+
219
+ This ensures that concurrent processes won't see partial files.
220
+ Uses file locking for additional safety on systems that support it.
221
+
222
+ Args:
223
+ path: Destination path for the module file
224
+ content: Module file content to write
225
+ authority: Authority namespace (for creating __init__.py)
226
+ """
227
+ self._ensure_authority_package(authority)
228
+
229
+ # Create temp file in same directory for atomic rename
230
+ fd, temp_path = tempfile.mkstemp(
231
+ suffix=".py.tmp",
232
+ dir=path.parent, # Use parent dir (authority subdir) for atomic rename
233
+ )
234
+ temp_path = Path(temp_path)
235
+
236
+ try:
237
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
238
+ # Try to get exclusive lock (non-blocking, ignore if unavailable)
239
+ # File locking is best-effort - not all filesystems support it
240
+ try:
241
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
242
+ except (OSError, IOError):
243
+ # Lock unavailable (NFS, Windows, etc.) - proceed without lock
244
+ # Atomic rename provides the real protection
245
+ pass
246
+
247
+ f.write(content)
248
+ f.flush()
249
+ os.fsync(f.fileno())
250
+
251
+ # Atomic rename (on POSIX systems)
252
+ temp_path.rename(path)
253
+
254
+ except Exception:
255
+ # Clean up temp file on error - best effort, ignore failures
256
+ try:
257
+ temp_path.unlink()
258
+ except OSError:
259
+ pass # Temp file cleanup failed, re-raising original error
260
+ raise
261
+
262
+ def _write_stub_atomic(self, path: Path, content: str) -> None:
263
+ """Legacy method - extracts authority from path and calls _write_module_atomic."""
264
+ # Extract authority from path (parent directory name)
265
+ authority = path.parent.name
266
+ self._write_module_atomic(path, content, authority)
267
+
268
+ def ensure_stub(self, schema: dict) -> Optional[Path]:
269
+ """Ensure a module file exists for the given schema.
270
+
271
+ If a current module already exists, returns its path without
272
+ regenerating. Otherwise, generates the module and writes it.
273
+
274
+ Modules are namespaced by the authority from the schema's $ref URI
275
+ to avoid collisions between schemas with the same name from
276
+ different sources.
277
+
278
+ Args:
279
+ schema: Schema dict with 'name', 'version', and 'fields' keys.
280
+ Can also be a LocalSchemaRecord (supports dict-style access).
281
+ Should include '$ref' for proper namespacing.
282
+
283
+ Returns:
284
+ Path to the module file, or None if schema is missing required fields.
285
+ """
286
+ # Extract schema metadata (works with dict or LocalSchemaRecord)
287
+ name = schema.get("name") if hasattr(schema, "get") else None
288
+ version = schema.get("version", "1.0.0") if hasattr(schema, "get") else "1.0.0"
289
+ schema_ref = schema.get("$ref") if hasattr(schema, "get") else None
290
+
291
+ if not name:
292
+ return None
293
+
294
+ # Extract authority from schema ref for namespacing
295
+ authority = _extract_authority(schema_ref)
296
+ path = self._module_path(name, version, authority)
297
+
298
+ # Skip if current module exists
299
+ if self._module_is_current(path, version):
300
+ return path
301
+
302
+ # Generate and write module
303
+ # Convert to dict if needed for generate_module
304
+ if hasattr(schema, "to_dict"):
305
+ schema_dict = schema.to_dict()
306
+ else:
307
+ schema_dict = schema
308
+
309
+ content = generate_module(schema_dict)
310
+ self._write_module_atomic(path, content, authority)
311
+
312
+ # Print helpful message on first generation
313
+ if self._first_generation:
314
+ self._first_generation = False
315
+ self._print_ide_hint()
316
+
317
+ return path
318
+
319
+ def ensure_module(self, schema: dict) -> Optional[Type]:
320
+ """Ensure a module exists and return the class from it.
321
+
322
+ This is the primary method for getting a properly-typed class from
323
+ a schema. It generates the module if needed, imports the class,
324
+ and returns it with proper type information.
325
+
326
+ Args:
327
+ schema: Schema dict with 'name', 'version', and 'fields' keys.
328
+ Can also be a LocalSchemaRecord (supports dict-style access).
329
+ Should include '$ref' for proper namespacing.
330
+
331
+ Returns:
332
+ The PackableSample subclass from the generated module, or None
333
+ if schema is missing required fields.
334
+ """
335
+ # Extract schema metadata
336
+ name = schema.get("name") if hasattr(schema, "get") else None
337
+ version = schema.get("version", "1.0.0") if hasattr(schema, "get") else "1.0.0"
338
+ schema_ref = schema.get("$ref") if hasattr(schema, "get") else None
339
+
340
+ if not name:
341
+ return None
342
+
343
+ authority = _extract_authority(schema_ref)
344
+
345
+ # Check cache first
346
+ cache_key = (authority, name, version)
347
+ if cache_key in self._class_cache:
348
+ return self._class_cache[cache_key]
349
+
350
+ # Ensure module exists
351
+ path = self.ensure_stub(schema)
352
+ if path is None:
353
+ return None
354
+
355
+ # Import and cache the class
356
+ cls = self._import_class_from_module(path, name)
357
+ if cls is not None:
358
+ self._class_cache[cache_key] = cls
359
+
360
+ return cls
361
+
362
+ def _import_class_from_module(self, module_path: Path, class_name: str) -> Optional[Type]:
363
+ """Import a class from a generated module file.
364
+
365
+ Uses importlib to dynamically load the module and extract the class.
366
+
367
+ Args:
368
+ module_path: Path to the .py module file
369
+ class_name: Name of the class to import
370
+
371
+ Returns:
372
+ The imported class, or None if import fails
373
+ """
374
+ if not module_path.exists():
375
+ return None
376
+
377
+ try:
378
+ # Create a unique module name based on the path
379
+ module_name = f"_atdata_generated_{module_path.stem}"
380
+
381
+ # Load the module spec
382
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
383
+ if spec is None or spec.loader is None:
384
+ return None
385
+
386
+ # Create and execute the module
387
+ module = importlib.util.module_from_spec(spec)
388
+ sys.modules[module_name] = module
389
+ spec.loader.exec_module(module)
390
+
391
+ # Get the class from the module
392
+ cls = getattr(module, class_name, None)
393
+ return cls
394
+
395
+ except (ModuleNotFoundError, AttributeError, ImportError, OSError):
396
+ # Import failed - return None and let caller fall back to dynamic generation
397
+ return None
398
+
399
+ def _print_ide_hint(self) -> None:
400
+ """Print a one-time hint about IDE configuration."""
401
+ import sys as _sys
402
+ print(
403
+ f"\n[atdata] Generated schema module in: {self._stub_dir}\n"
404
+ f"[atdata] For IDE support, add this path to your type checker:\n"
405
+ f"[atdata] VS Code/Pylance: Add to python.analysis.extraPaths\n"
406
+ f"[atdata] PyCharm: Mark as Sources Root\n"
407
+ f"[atdata] mypy: Add to mypy_path in mypy.ini\n",
408
+ file=_sys.stderr,
409
+ )
410
+
411
+ def get_stub_path(
412
+ self, name: str, version: str, authority: str = DEFAULT_AUTHORITY
413
+ ) -> Optional[Path]:
414
+ """Get the path to an existing stub file.
415
+
416
+ Args:
417
+ name: Schema name
418
+ version: Schema version
419
+ authority: Authority namespace (default: "local")
420
+
421
+ Returns:
422
+ Path if stub exists, None otherwise
423
+ """
424
+ path = self._stub_path(name, version, authority)
425
+ return path if path.exists() else None
426
+
427
+ def list_stubs(self, authority: Optional[str] = None) -> list[Path]:
428
+ """List all module files in the stub directory.
429
+
430
+ Args:
431
+ authority: If provided, only list modules for this authority.
432
+ If None, lists all modules across all authorities.
433
+
434
+ Returns:
435
+ List of paths to existing module files (excludes __init__.py)
436
+ """
437
+ if not self._stub_dir.exists():
438
+ return []
439
+
440
+ if authority:
441
+ # List modules for specific authority
442
+ authority_dir = self._stub_dir / authority
443
+ if not authority_dir.exists():
444
+ return []
445
+ return [p for p in authority_dir.glob("*.py") if p.name != "__init__.py"]
446
+
447
+ # List all modules across all authorities (recursive, excluding __init__.py)
448
+ return [p for p in self._stub_dir.glob("**/*.py") if p.name != "__init__.py"]
449
+
450
+ def clear_stubs(self, authority: Optional[str] = None) -> int:
451
+ """Remove module files from the stub directory.
452
+
453
+ Args:
454
+ authority: If provided, only clear modules for this authority.
455
+ If None, clears all modules across all authorities.
456
+
457
+ Returns:
458
+ Number of files removed
459
+ """
460
+ stubs = self.list_stubs(authority)
461
+ removed = 0
462
+ for path in stubs:
463
+ try:
464
+ path.unlink()
465
+ removed += 1
466
+ except OSError:
467
+ # File already removed or permission denied - skip and continue
468
+ continue
469
+
470
+ # Clear the class cache for removed modules
471
+ if authority:
472
+ keys_to_remove = [k for k in self._class_cache if k[0] == authority]
473
+ else:
474
+ keys_to_remove = list(self._class_cache.keys())
475
+ for key in keys_to_remove:
476
+ del self._class_cache[key]
477
+
478
+ # Clean up empty authority directories (including __init__.py)
479
+ if self._stub_dir.exists():
480
+ for subdir in self._stub_dir.iterdir():
481
+ if subdir.is_dir():
482
+ # Check if only __init__.py remains
483
+ contents = list(subdir.iterdir())
484
+ if len(contents) == 0:
485
+ try:
486
+ subdir.rmdir()
487
+ except OSError:
488
+ continue
489
+ elif len(contents) == 1 and contents[0].name == "__init__.py":
490
+ try:
491
+ contents[0].unlink()
492
+ subdir.rmdir()
493
+ except OSError:
494
+ continue
495
+
496
+ return removed
497
+
498
+ def clear_stub(
499
+ self, name: str, version: str, authority: str = DEFAULT_AUTHORITY
500
+ ) -> bool:
501
+ """Remove a specific module file.
502
+
503
+ Args:
504
+ name: Schema name
505
+ version: Schema version
506
+ authority: Authority namespace (default: "local")
507
+
508
+ Returns:
509
+ True if file was removed, False if it didn't exist
510
+ """
511
+ path = self._stub_path(name, version, authority)
512
+ if path.exists():
513
+ try:
514
+ path.unlink()
515
+ # Clear from class cache
516
+ cache_key = (authority, name, version)
517
+ if cache_key in self._class_cache:
518
+ del self._class_cache[cache_key]
519
+ return True
520
+ except OSError:
521
+ return False
522
+ return False
523
+
524
+
525
+ __all__ = [
526
+ "StubManager",
527
+ "DEFAULT_STUB_DIR",
528
+ "DEFAULT_AUTHORITY",
529
+ ]
atdata/_type_utils.py ADDED
@@ -0,0 +1,90 @@
1
+ """Shared type conversion utilities for schema handling.
2
+
3
+ This module provides common type mapping functions used by both local.py
4
+ and atmosphere/schema.py to avoid code duplication.
5
+ """
6
+
7
+ import types
8
+ from typing import Any, get_origin, get_args, Union
9
+
10
+ # Mapping from numpy dtype strings to schema dtype names
11
+ NUMPY_DTYPE_MAP = {
12
+ "float16": "float16", "float32": "float32", "float64": "float64",
13
+ "int8": "int8", "int16": "int16", "int32": "int32", "int64": "int64",
14
+ "uint8": "uint8", "uint16": "uint16", "uint32": "uint32", "uint64": "uint64",
15
+ "bool": "bool", "complex64": "complex64", "complex128": "complex128",
16
+ }
17
+
18
+ # Mapping from Python primitive types to schema type names
19
+ PRIMITIVE_TYPE_MAP = {
20
+ str: "str", int: "int", float: "float", bool: "bool", bytes: "bytes",
21
+ }
22
+
23
+
24
+ def numpy_dtype_to_string(dtype: Any) -> str:
25
+ """Convert a numpy dtype annotation to a schema dtype string.
26
+
27
+ Args:
28
+ dtype: A numpy dtype or type annotation containing dtype info.
29
+
30
+ Returns:
31
+ Schema dtype string (e.g., "float32", "int64"). Defaults to "float32".
32
+ """
33
+ dtype_str = str(dtype)
34
+ for key, value in NUMPY_DTYPE_MAP.items():
35
+ if key in dtype_str:
36
+ return value
37
+ return "float32"
38
+
39
+
40
+ def unwrap_optional(python_type: Any) -> tuple[Any, bool]:
41
+ """Extract the inner type from Optional/Union types.
42
+
43
+ Handles both `Optional[T]` (Union[T, None]) and `T | None` syntax.
44
+
45
+ Args:
46
+ python_type: A Python type annotation.
47
+
48
+ Returns:
49
+ Tuple of (inner_type, is_optional). If type is not Optional,
50
+ returns (python_type, False).
51
+
52
+ Raises:
53
+ TypeError: If complex union types (Union[A, B] where both are non-None).
54
+ """
55
+ origin = get_origin(python_type)
56
+
57
+ if origin is Union or isinstance(python_type, types.UnionType):
58
+ args = get_args(python_type)
59
+ non_none_args = [a for a in args if a is not type(None)]
60
+ is_optional = type(None) in args or len(non_none_args) < len(args)
61
+
62
+ if len(non_none_args) == 1:
63
+ return non_none_args[0], is_optional
64
+ elif len(non_none_args) > 1:
65
+ raise TypeError(f"Complex union types not supported: {python_type}")
66
+
67
+ return python_type, False
68
+
69
+
70
+ def is_ndarray_type(python_type: Any) -> bool:
71
+ """Check if a type annotation represents an NDArray."""
72
+ type_str = str(python_type)
73
+ return "NDArray" in type_str or "ndarray" in type_str.lower()
74
+
75
+
76
+ def extract_ndarray_dtype(python_type: Any) -> str:
77
+ """Extract dtype from NDArray type annotation.
78
+
79
+ Args:
80
+ python_type: NDArray type annotation (e.g., NDArray[np.float32]).
81
+
82
+ Returns:
83
+ Dtype string (e.g., "float32"). Defaults to "float32".
84
+ """
85
+ args = get_args(python_type)
86
+ if args:
87
+ dtype_arg = args[-1]
88
+ if dtype_arg is not None:
89
+ return numpy_dtype_to_string(dtype_arg)
90
+ return "float32"