nrl-tracker 1.8.0__py3-none-any.whl → 1.9.1__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,531 @@
1
+ """
2
+ Optional dependencies management for the Tracker Component Library.
3
+
4
+ This module provides a unified system for handling optional dependencies,
5
+ including lazy imports, availability checks, and helpful error messages.
6
+
7
+ The system supports:
8
+ - Lazy imports that only load modules when accessed
9
+ - Availability flags for conditional code paths
10
+ - Decorators for functions requiring optional dependencies
11
+ - Helpful error messages with installation instructions
12
+
13
+ Examples
14
+ --------
15
+ Check if a dependency is available:
16
+
17
+ >>> from pytcl.core.optional_deps import is_available
18
+ >>> if is_available("plotly"):
19
+ ... import plotly.graph_objects as go
20
+
21
+ Use a decorator to require a dependency:
22
+
23
+ >>> from pytcl.core.optional_deps import requires
24
+ >>> @requires("plotly", extra="visualization")
25
+ ... def create_3d_plot(data):
26
+ ... import plotly.graph_objects as go
27
+ ... return go.Figure(data)
28
+
29
+ Import with a helpful error on failure:
30
+
31
+ >>> from pytcl.core.optional_deps import import_optional
32
+ >>> go = import_optional("plotly.graph_objects", package="plotly", extra="visualization")
33
+ """
34
+
35
+ import importlib
36
+ import logging
37
+ from functools import wraps
38
+ from types import ModuleType
39
+ from typing import Any, Callable, Optional, TypeVar
40
+
41
+ from pytcl.core.exceptions import DependencyError
42
+
43
+ # Module logger
44
+ _logger = logging.getLogger("pytcl.core.optional_deps")
45
+
46
+ # Type variable for generic function signatures
47
+ F = TypeVar("F", bound=Callable[..., Any])
48
+
49
+
50
+ # =============================================================================
51
+ # Package Configuration
52
+ # =============================================================================
53
+
54
+ # Mapping of package names to their pip install extras
55
+ # Format: package_name -> (extra_name, pip_package_name)
56
+ PACKAGE_EXTRAS: dict[str, tuple[str, str]] = {
57
+ # Visualization
58
+ "plotly": ("visualization", "plotly"),
59
+ # Astronomy
60
+ "astropy": ("astronomy", "astropy"),
61
+ "jplephem": ("astronomy", "jplephem"),
62
+ # Geodesy
63
+ "pyproj": ("geodesy", "pyproj"),
64
+ "geographiclib": ("geodesy", "geographiclib"),
65
+ # Optimization
66
+ "cvxpy": ("optimization", "cvxpy"),
67
+ # Signal processing
68
+ "pywt": ("signal", "pywavelets"),
69
+ "pywavelets": ("signal", "pywavelets"),
70
+ # Terrain data
71
+ "netCDF4": ("terrain", "netCDF4"),
72
+ }
73
+
74
+ # Friendly names for features provided by each package
75
+ PACKAGE_FEATURES: dict[str, str] = {
76
+ "plotly": "interactive visualization",
77
+ "astropy": "astronomical calculations",
78
+ "jplephem": "JPL ephemeris access",
79
+ "pyproj": "coordinate transformations",
80
+ "geographiclib": "geodetic calculations",
81
+ "cvxpy": "convex optimization",
82
+ "pywt": "wavelet transforms",
83
+ "pywavelets": "wavelet transforms",
84
+ "netCDF4": "NetCDF file reading",
85
+ }
86
+
87
+
88
+ # =============================================================================
89
+ # Availability Cache
90
+ # =============================================================================
91
+
92
+ # Cache of package availability checks
93
+ _availability_cache: dict[str, bool] = {}
94
+
95
+
96
+ def _clear_cache() -> None:
97
+ """Clear the availability cache. Mainly for testing."""
98
+ _availability_cache.clear()
99
+
100
+
101
+ def is_available(package: str) -> bool:
102
+ """
103
+ Check if an optional package is available.
104
+
105
+ Parameters
106
+ ----------
107
+ package : str
108
+ Name of the package to check (e.g., "plotly", "pywt").
109
+
110
+ Returns
111
+ -------
112
+ bool
113
+ True if the package is installed and can be imported.
114
+
115
+ Examples
116
+ --------
117
+ >>> from pytcl.core.optional_deps import is_available
118
+ >>> if is_available("plotly"):
119
+ ... from plotly import graph_objects as go
120
+ ... # use plotly
121
+ ... else:
122
+ ... print("Plotly not available")
123
+
124
+ Notes
125
+ -----
126
+ Results are cached for performance. Use ``_clear_cache()`` if you
127
+ need to re-check availability (e.g., after installing a package).
128
+ """
129
+ if package in _availability_cache:
130
+ return _availability_cache[package]
131
+
132
+ try:
133
+ importlib.import_module(package)
134
+ available = True
135
+ _logger.debug("Optional package '%s' is available", package)
136
+ except ImportError:
137
+ available = False
138
+ _logger.debug("Optional package '%s' is not available", package)
139
+
140
+ _availability_cache[package] = available
141
+ return available
142
+
143
+
144
+ # =============================================================================
145
+ # Import Helpers
146
+ # =============================================================================
147
+
148
+
149
+ def _get_install_command(package: str, extra: Optional[str] = None) -> str:
150
+ """Generate the pip install command for a package."""
151
+ if extra:
152
+ return f"pip install pytcl[{extra}]"
153
+
154
+ # Check if we know the extra for this package
155
+ if package in PACKAGE_EXTRAS:
156
+ extra_name, _ = PACKAGE_EXTRAS[package]
157
+ return f"pip install pytcl[{extra_name}]"
158
+
159
+ # Default to direct package install
160
+ pip_package = package
161
+ if package in PACKAGE_EXTRAS:
162
+ _, pip_package = PACKAGE_EXTRAS[package]
163
+ return f"pip install {pip_package}"
164
+
165
+
166
+ def _get_feature_name(package: str) -> str:
167
+ """Get a friendly feature name for a package."""
168
+ return PACKAGE_FEATURES.get(package, f"{package} functionality")
169
+
170
+
171
+ def import_optional(
172
+ module_name: str,
173
+ *,
174
+ package: Optional[str] = None,
175
+ extra: Optional[str] = None,
176
+ feature: Optional[str] = None,
177
+ ) -> ModuleType:
178
+ """
179
+ Import an optional module with a helpful error message on failure.
180
+
181
+ Parameters
182
+ ----------
183
+ module_name : str
184
+ Full module path to import (e.g., "plotly.graph_objects").
185
+ package : str, optional
186
+ Package name for error message. If not provided, extracted from
187
+ module_name.
188
+ extra : str, optional
189
+ Name of the pytcl extra that provides this dependency
190
+ (e.g., "visualization", "astronomy").
191
+ feature : str, optional
192
+ Description of the feature requiring this dependency.
193
+
194
+ Returns
195
+ -------
196
+ module : ModuleType
197
+ The imported module.
198
+
199
+ Raises
200
+ ------
201
+ DependencyError
202
+ If the module cannot be imported.
203
+
204
+ Examples
205
+ --------
206
+ >>> go = import_optional(
207
+ ... "plotly.graph_objects",
208
+ ... package="plotly",
209
+ ... extra="visualization",
210
+ ... feature="3D plotting"
211
+ ... )
212
+ """
213
+ if package is None:
214
+ package = module_name.split(".")[0]
215
+
216
+ try:
217
+ module = importlib.import_module(module_name)
218
+ _logger.debug("Successfully imported optional module '%s'", module_name)
219
+ return module
220
+ except ImportError as e:
221
+ if feature is None:
222
+ feature = _get_feature_name(package)
223
+
224
+ install_cmd = _get_install_command(package, extra)
225
+
226
+ msg = f"{package} is required for {feature}. " f"Install with: {install_cmd}"
227
+ _logger.warning("Failed to import optional module '%s': %s", module_name, e)
228
+ raise DependencyError(
229
+ msg,
230
+ package=package,
231
+ feature=feature,
232
+ install_command=install_cmd,
233
+ ) from e
234
+
235
+
236
+ # =============================================================================
237
+ # Decorator for Optional Dependencies
238
+ # =============================================================================
239
+
240
+
241
+ def requires(
242
+ *packages: str,
243
+ extra: Optional[str] = None,
244
+ feature: Optional[str] = None,
245
+ ) -> Callable[[F], F]:
246
+ """
247
+ Decorator to mark a function as requiring optional dependencies.
248
+
249
+ When the decorated function is called, it checks if the required
250
+ packages are available. If not, it raises a DependencyError with
251
+ a helpful message.
252
+
253
+ Parameters
254
+ ----------
255
+ *packages : str
256
+ One or more package names required by the function.
257
+ extra : str, optional
258
+ Name of the pytcl extra that provides these dependencies.
259
+ feature : str, optional
260
+ Description of the feature provided by the function.
261
+
262
+ Returns
263
+ -------
264
+ decorator : callable
265
+ Decorator that wraps the function with dependency checking.
266
+
267
+ Examples
268
+ --------
269
+ >>> from pytcl.core.optional_deps import requires
270
+ >>>
271
+ >>> @requires("plotly", extra="visualization")
272
+ ... def create_plot(data):
273
+ ... import plotly.graph_objects as go
274
+ ... return go.Figure(data)
275
+ >>>
276
+ >>> # This will raise DependencyError if plotly is not installed
277
+ >>> create_plot([1, 2, 3])
278
+
279
+ Multiple packages:
280
+
281
+ >>> @requires("astropy", "jplephem", extra="astronomy")
282
+ ... def compute_ephemeris(body, time):
283
+ ... from astropy.time import Time
284
+ ... import jplephem
285
+ ... # ...
286
+
287
+ Notes
288
+ -----
289
+ The decorator checks availability at call time, not at definition
290
+ time. This allows the module to be imported even if the optional
291
+ dependencies are not installed.
292
+ """
293
+
294
+ def decorator(func: F) -> F:
295
+ @wraps(func)
296
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
297
+ missing = [pkg for pkg in packages if not is_available(pkg)]
298
+
299
+ if missing:
300
+ # Get feature name from first package if not provided
301
+ feat = feature or _get_feature_name(missing[0])
302
+
303
+ if len(missing) == 1:
304
+ pkg_str = missing[0]
305
+ install_cmd = _get_install_command(missing[0], extra)
306
+ else:
307
+ pkg_str = ", ".join(missing)
308
+ install_cmd = _get_install_command(missing[0], extra)
309
+
310
+ msg = (
311
+ f"{pkg_str} {'is' if len(missing) == 1 else 'are'} required "
312
+ f"for {feat}. Install with: {install_cmd}"
313
+ )
314
+ raise DependencyError(
315
+ msg,
316
+ package=missing[0],
317
+ feature=feat,
318
+ install_command=install_cmd,
319
+ )
320
+
321
+ return func(*args, **kwargs)
322
+
323
+ return wrapper
324
+
325
+ return decorator
326
+
327
+
328
+ # =============================================================================
329
+ # Availability Flags (for backward compatibility)
330
+ # =============================================================================
331
+
332
+
333
+ # These flags are computed lazily when first accessed
334
+ class _AvailabilityFlags:
335
+ """Lazy availability flags for common optional packages."""
336
+
337
+ @property
338
+ def HAS_PLOTLY(self) -> bool:
339
+ """True if plotly is available."""
340
+ return is_available("plotly")
341
+
342
+ @property
343
+ def HAS_PYWT(self) -> bool:
344
+ """True if pywavelets is available."""
345
+ return is_available("pywt")
346
+
347
+ @property
348
+ def PYWT_AVAILABLE(self) -> bool:
349
+ """True if pywavelets is available (alias)."""
350
+ return is_available("pywt")
351
+
352
+ @property
353
+ def HAS_JPLEPHEM(self) -> bool:
354
+ """True if jplephem is available."""
355
+ return is_available("jplephem")
356
+
357
+ @property
358
+ def HAS_ASTROPY(self) -> bool:
359
+ """True if astropy is available."""
360
+ return is_available("astropy")
361
+
362
+ @property
363
+ def HAS_PYPROJ(self) -> bool:
364
+ """True if pyproj is available."""
365
+ return is_available("pyproj")
366
+
367
+ @property
368
+ def HAS_CVXPY(self) -> bool:
369
+ """True if cvxpy is available."""
370
+ return is_available("cvxpy")
371
+
372
+ @property
373
+ def HAS_NETCDF4(self) -> bool:
374
+ """True if netCDF4 is available."""
375
+ return is_available("netCDF4")
376
+
377
+
378
+ # Create singleton instance
379
+ _flags = _AvailabilityFlags()
380
+
381
+ # Export individual flags for convenient access
382
+ HAS_PLOTLY = property(lambda self: _flags.HAS_PLOTLY)
383
+ HAS_PYWT = property(lambda self: _flags.HAS_PYWT)
384
+ PYWT_AVAILABLE = property(lambda self: _flags.PYWT_AVAILABLE)
385
+ HAS_JPLEPHEM = property(lambda self: _flags.HAS_JPLEPHEM)
386
+ HAS_ASTROPY = property(lambda self: _flags.HAS_ASTROPY)
387
+ HAS_PYPROJ = property(lambda self: _flags.HAS_PYPROJ)
388
+ HAS_CVXPY = property(lambda self: _flags.HAS_CVXPY)
389
+ HAS_NETCDF4 = property(lambda self: _flags.HAS_NETCDF4)
390
+
391
+
392
+ # =============================================================================
393
+ # Lazy Module Loader
394
+ # =============================================================================
395
+
396
+
397
+ class LazyModule:
398
+ """
399
+ A lazy module loader that imports the module on first access.
400
+
401
+ This allows optional dependencies to be "imported" at module level
402
+ without triggering an import error until they're actually used.
403
+
404
+ Parameters
405
+ ----------
406
+ module_name : str
407
+ Full module path to import.
408
+ package : str, optional
409
+ Package name for error messages.
410
+ extra : str, optional
411
+ pytcl extra that provides this dependency.
412
+ feature : str, optional
413
+ Feature description for error messages.
414
+
415
+ Examples
416
+ --------
417
+ >>> from pytcl.core.optional_deps import LazyModule
418
+ >>> go = LazyModule("plotly.graph_objects", package="plotly")
419
+ >>> # No import yet...
420
+ >>> fig = go.Figure() # Import happens here
421
+ """
422
+
423
+ def __init__(
424
+ self,
425
+ module_name: str,
426
+ *,
427
+ package: Optional[str] = None,
428
+ extra: Optional[str] = None,
429
+ feature: Optional[str] = None,
430
+ ):
431
+ self._module_name = module_name
432
+ self._package = package or module_name.split(".")[0]
433
+ self._extra = extra
434
+ self._feature = feature
435
+ self._module: Optional[ModuleType] = None
436
+
437
+ def _load(self) -> ModuleType:
438
+ """Load the module if not already loaded."""
439
+ if self._module is None:
440
+ self._module = import_optional(
441
+ self._module_name,
442
+ package=self._package,
443
+ extra=self._extra,
444
+ feature=self._feature,
445
+ )
446
+ return self._module
447
+
448
+ def __getattr__(self, name: str) -> Any:
449
+ """Delegate attribute access to the loaded module."""
450
+ module = self._load()
451
+ return getattr(module, name)
452
+
453
+ def __dir__(self) -> list[str]:
454
+ """Return module attributes for tab completion."""
455
+ try:
456
+ module = self._load()
457
+ return dir(module)
458
+ except DependencyError:
459
+ return []
460
+
461
+
462
+ # =============================================================================
463
+ # Convenience Functions
464
+ # =============================================================================
465
+
466
+
467
+ def check_dependencies(*packages: str, extra: Optional[str] = None) -> None:
468
+ """
469
+ Check that all required packages are available.
470
+
471
+ Parameters
472
+ ----------
473
+ *packages : str
474
+ Package names to check.
475
+ extra : str, optional
476
+ pytcl extra for installation hint.
477
+
478
+ Raises
479
+ ------
480
+ DependencyError
481
+ If any package is not available.
482
+
483
+ Examples
484
+ --------
485
+ >>> from pytcl.core.optional_deps import check_dependencies
486
+ >>> check_dependencies("plotly", extra="visualization")
487
+ >>> # Raises DependencyError if plotly is not installed
488
+ """
489
+ missing = [pkg for pkg in packages if not is_available(pkg)]
490
+
491
+ if missing:
492
+ feature = _get_feature_name(missing[0])
493
+ install_cmd = _get_install_command(missing[0], extra)
494
+
495
+ if len(missing) == 1:
496
+ msg = f"{missing[0]} is required. Install with: {install_cmd}"
497
+ else:
498
+ msg = f"{', '.join(missing)} are required. Install with: {install_cmd}"
499
+
500
+ raise DependencyError(
501
+ msg,
502
+ package=missing[0],
503
+ feature=feature,
504
+ install_command=install_cmd,
505
+ )
506
+
507
+
508
+ __all__ = [
509
+ # Core functions
510
+ "is_available",
511
+ "import_optional",
512
+ "requires",
513
+ "check_dependencies",
514
+ # Lazy loading
515
+ "LazyModule",
516
+ # Configuration
517
+ "PACKAGE_EXTRAS",
518
+ "PACKAGE_FEATURES",
519
+ # Availability flags (backward compatibility)
520
+ "HAS_PLOTLY",
521
+ "HAS_PYWT",
522
+ "PYWT_AVAILABLE",
523
+ "HAS_JPLEPHEM",
524
+ "HAS_ASTROPY",
525
+ "HAS_PYPROJ",
526
+ "HAS_CVXPY",
527
+ "HAS_NETCDF4",
528
+ # Internal (for testing)
529
+ "_clear_cache",
530
+ "_flags",
531
+ ]
pytcl/core/validation.py CHANGED
@@ -14,16 +14,14 @@ from typing import Any, Callable, Literal, Sequence, TypeVar
14
14
  import numpy as np
15
15
  from numpy.typing import ArrayLike, NDArray
16
16
 
17
+ # Import ValidationError from exceptions module for consistency
18
+ # Re-export for backward compatibility
19
+ from pytcl.core.exceptions import ValidationError
20
+
17
21
  # Type variable for generic function signatures
18
22
  F = TypeVar("F", bound=Callable[..., Any])
19
23
 
20
24
 
21
- class ValidationError(ValueError):
22
- """Exception raised when input validation fails."""
23
-
24
- pass
25
-
26
-
27
25
  def validate_array(
28
26
  arr: ArrayLike,
29
27
  name: str = "array",