spatialcore 0.1.9__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.
Files changed (36) hide show
  1. spatialcore/__init__.py +122 -0
  2. spatialcore/annotation/__init__.py +253 -0
  3. spatialcore/annotation/acquisition.py +529 -0
  4. spatialcore/annotation/annotate.py +603 -0
  5. spatialcore/annotation/cellxgene.py +365 -0
  6. spatialcore/annotation/confidence.py +802 -0
  7. spatialcore/annotation/discovery.py +529 -0
  8. spatialcore/annotation/expression.py +363 -0
  9. spatialcore/annotation/loading.py +529 -0
  10. spatialcore/annotation/markers.py +297 -0
  11. spatialcore/annotation/ontology.py +1282 -0
  12. spatialcore/annotation/patterns.py +247 -0
  13. spatialcore/annotation/pipeline.py +620 -0
  14. spatialcore/annotation/synapse.py +380 -0
  15. spatialcore/annotation/training.py +1457 -0
  16. spatialcore/annotation/validation.py +422 -0
  17. spatialcore/core/__init__.py +34 -0
  18. spatialcore/core/cache.py +118 -0
  19. spatialcore/core/logging.py +135 -0
  20. spatialcore/core/metadata.py +149 -0
  21. spatialcore/core/utils.py +768 -0
  22. spatialcore/data/gene_mappings/ensembl_to_hugo_human.tsv +86372 -0
  23. spatialcore/data/markers/canonical_markers.json +83 -0
  24. spatialcore/data/ontology_mappings/ontology_index.json +63865 -0
  25. spatialcore/plotting/__init__.py +109 -0
  26. spatialcore/plotting/benchmark.py +477 -0
  27. spatialcore/plotting/celltype.py +329 -0
  28. spatialcore/plotting/confidence.py +413 -0
  29. spatialcore/plotting/spatial.py +505 -0
  30. spatialcore/plotting/utils.py +411 -0
  31. spatialcore/plotting/validation.py +1342 -0
  32. spatialcore-0.1.9.dist-info/METADATA +213 -0
  33. spatialcore-0.1.9.dist-info/RECORD +36 -0
  34. spatialcore-0.1.9.dist-info/WHEEL +5 -0
  35. spatialcore-0.1.9.dist-info/licenses/LICENSE +201 -0
  36. spatialcore-0.1.9.dist-info/top_level.txt +1 -0
@@ -0,0 +1,422 @@
1
+ """
2
+ Cell type column validation for CellTypist training.
3
+
4
+ This module provides comprehensive validation of cell type annotation columns
5
+ before training to catch data quality issues early:
6
+
7
+ 1. Column existence
8
+ 2. Null/empty value detection
9
+ 3. Cardinality checks (2-500 cell types is reasonable)
10
+ 4. Minimum cells per type
11
+ 5. Suspicious pattern detection (e.g., numeric-only labels, placeholder values)
12
+ 6. Class imbalance detection
13
+
14
+ References:
15
+ - CellTypist: https://www.celltypist.org/
16
+ - Best practices for cell type annotation in single-cell genomics
17
+ """
18
+
19
+ from typing import Dict, List, Optional, Any, Literal
20
+ from dataclasses import dataclass, field
21
+ import re
22
+
23
+ import pandas as pd
24
+ import anndata as ad
25
+
26
+ from spatialcore.core.logging import get_logger
27
+
28
+ logger = get_logger(__name__)
29
+
30
+
31
+ # Known placeholder/suspicious patterns
32
+ SUSPICIOUS_PATTERNS = [
33
+ (r"^[0-9]+$", "Numeric-only cell type labels"),
34
+ (r"^(unknown|unassigned|na|n/a|none|null)$", "Placeholder values"),
35
+ (r"^cluster_?[0-9]+$", "Cluster-based labels (not biological)"),
36
+ (r"^cell_?[0-9]+$", "Generic cell labels"),
37
+ (r"^type_?[0-9]+$", "Generic type labels"),
38
+ (r"^leiden_?[0-9]*$", "Leiden clustering labels"),
39
+ (r"^louvain_?[0-9]*$", "Louvain clustering labels"),
40
+ ]
41
+
42
+
43
+ @dataclass
44
+ class ValidationIssue:
45
+ """A single validation issue.
46
+
47
+ Attributes
48
+ ----------
49
+ severity : str
50
+ One of "error", "warning", or "info".
51
+ code : str
52
+ Machine-readable code (e.g., "NULL_VALUES", "LOW_CELL_COUNTS").
53
+ message : str
54
+ Human-readable description of the issue.
55
+ details : dict, optional
56
+ Additional details about the issue.
57
+ """
58
+
59
+ severity: Literal["error", "warning", "info"]
60
+ code: str
61
+ message: str
62
+ details: Optional[Dict[str, Any]] = None
63
+
64
+ def __repr__(self) -> str:
65
+ return f"[{self.severity.upper()}] {self.code}: {self.message}"
66
+
67
+
68
+ @dataclass
69
+ class CellTypeValidationResult:
70
+ """Complete validation result for a cell type column.
71
+
72
+ Attributes
73
+ ----------
74
+ column_name : str
75
+ Name of the validated column.
76
+ is_valid : bool
77
+ True if no errors were found (warnings are OK).
78
+ n_cells : int
79
+ Total number of cells.
80
+ n_cell_types : int
81
+ Number of unique cell types.
82
+ issues : list
83
+ List of ValidationIssue objects.
84
+ cell_type_counts : pd.Series, optional
85
+ Counts per cell type, sorted descending.
86
+ """
87
+
88
+ column_name: str
89
+ is_valid: bool
90
+ n_cells: int
91
+ n_cell_types: int
92
+ issues: List[ValidationIssue] = field(default_factory=list)
93
+ cell_type_counts: Optional[pd.Series] = None
94
+
95
+ @property
96
+ def errors(self) -> List[ValidationIssue]:
97
+ """Get all error-level issues."""
98
+ return [i for i in self.issues if i.severity == "error"]
99
+
100
+ @property
101
+ def warnings(self) -> List[ValidationIssue]:
102
+ """Get all warning-level issues."""
103
+ return [i for i in self.issues if i.severity == "warning"]
104
+
105
+ @property
106
+ def infos(self) -> List[ValidationIssue]:
107
+ """Get all info-level issues."""
108
+ return [i for i in self.issues if i.severity == "info"]
109
+
110
+ def summary(self) -> str:
111
+ """Return human-readable summary of validation results."""
112
+ status = "PASSED" if self.is_valid else "FAILED"
113
+ lines = [
114
+ f"Validation: {status}",
115
+ f" Column: {self.column_name}",
116
+ f" Cells: {self.n_cells:,}",
117
+ f" Cell types: {self.n_cell_types}",
118
+ ]
119
+ if self.errors:
120
+ lines.append(f" Errors: {len(self.errors)}")
121
+ for e in self.errors:
122
+ lines.append(f" - {e.message}")
123
+ if self.warnings:
124
+ lines.append(f" Warnings: {len(self.warnings)}")
125
+ for w in self.warnings:
126
+ lines.append(f" - {w.message}")
127
+ return "\n".join(lines)
128
+
129
+
130
+ def validate_cell_type_column(
131
+ adata: ad.AnnData,
132
+ column: str,
133
+ min_cells_per_type: int = 10,
134
+ max_cell_types: int = 500,
135
+ min_cell_types: int = 2,
136
+ allow_nulls: bool = False,
137
+ max_null_fraction: float = 0.05,
138
+ check_suspicious_patterns: bool = True,
139
+ ) -> CellTypeValidationResult:
140
+ """
141
+ Validate a cell type annotation column for CellTypist training.
142
+
143
+ Performs comprehensive checks:
144
+ 1. Column existence
145
+ 2. Null/empty values (error if >5% by default)
146
+ 3. Cardinality (2-500 cell types)
147
+ 4. Minimum cells per type (10 by default)
148
+ 5. Suspicious patterns (numeric-only, placeholders)
149
+ 6. Class imbalance (warn if >1000x ratio)
150
+
151
+ Parameters
152
+ ----------
153
+ adata : AnnData
154
+ AnnData object to validate.
155
+ column : str
156
+ Name of cell type column in adata.obs.
157
+ min_cells_per_type : int, default 10
158
+ Minimum cells required per cell type. Types with fewer cells
159
+ will generate a warning.
160
+ max_cell_types : int, default 500
161
+ Maximum number of cell types (warn if exceeded).
162
+ min_cell_types : int, default 2
163
+ Minimum number of cell types required (error if not met).
164
+ allow_nulls : bool, default False
165
+ If True, allow null values (still warns if >max_null_fraction).
166
+ max_null_fraction : float, default 0.05
167
+ Maximum fraction of null values before error (if allow_nulls=False).
168
+ check_suspicious_patterns : bool, default True
169
+ Check for suspicious label patterns like numeric-only or cluster labels.
170
+
171
+ Returns
172
+ -------
173
+ CellTypeValidationResult
174
+ Complete validation result with issues and statistics.
175
+
176
+ Examples
177
+ --------
178
+ >>> from spatialcore.annotation import validate_cell_type_column
179
+ >>> result = validate_cell_type_column(adata, "cell_type")
180
+ >>> if not result.is_valid:
181
+ ... print(result.summary())
182
+ ... raise ValueError("Validation failed")
183
+ >>> print(f"Found {result.n_cell_types} cell types")
184
+
185
+ >>> # Inspect low-count types
186
+ >>> low_count = result.cell_type_counts[result.cell_type_counts < 100]
187
+ >>> print(low_count)
188
+ """
189
+ issues: List[ValidationIssue] = []
190
+
191
+ # Check 1: Column exists
192
+ if column not in adata.obs.columns:
193
+ available = list(adata.obs.columns)
194
+ return CellTypeValidationResult(
195
+ column_name=column,
196
+ is_valid=False,
197
+ n_cells=adata.n_obs,
198
+ n_cell_types=0,
199
+ issues=[
200
+ ValidationIssue(
201
+ severity="error",
202
+ code="COLUMN_NOT_FOUND",
203
+ message=f"Column '{column}' not found in adata.obs",
204
+ details={"available_columns": available[:20]},
205
+ )
206
+ ],
207
+ )
208
+
209
+ labels = adata.obs[column]
210
+ n_cells = len(labels)
211
+
212
+ # Check 2: Null values
213
+ null_mask = labels.isna() | (labels.astype(str).str.strip() == "")
214
+ n_nulls = int(null_mask.sum())
215
+ null_fraction = n_nulls / n_cells if n_cells > 0 else 0.0
216
+
217
+ if n_nulls > 0:
218
+ if null_fraction > max_null_fraction and not allow_nulls:
219
+ issues.append(
220
+ ValidationIssue(
221
+ severity="error",
222
+ code="EXCESSIVE_NULLS",
223
+ message=f"{n_nulls:,} null values ({null_fraction:.1%} of cells)",
224
+ details={"n_nulls": n_nulls, "fraction": float(null_fraction)},
225
+ )
226
+ )
227
+ else:
228
+ issues.append(
229
+ ValidationIssue(
230
+ severity="warning",
231
+ code="NULL_VALUES",
232
+ message=f"{n_nulls:,} null values ({null_fraction:.1%} of cells)",
233
+ details={"n_nulls": n_nulls, "fraction": float(null_fraction)},
234
+ )
235
+ )
236
+
237
+ # Filter out nulls for remaining checks
238
+ valid_labels = labels[~null_mask].astype(str)
239
+ cell_type_counts = valid_labels.value_counts()
240
+ n_cell_types = len(cell_type_counts)
241
+
242
+ # Check 3: Cardinality - too few
243
+ if n_cell_types < min_cell_types:
244
+ issues.append(
245
+ ValidationIssue(
246
+ severity="error",
247
+ code="TOO_FEW_TYPES",
248
+ message=f"Only {n_cell_types} cell types found (minimum: {min_cell_types})",
249
+ details={"n_cell_types": n_cell_types, "min_required": min_cell_types},
250
+ )
251
+ )
252
+
253
+ # Check 4: Cardinality - too many
254
+ if n_cell_types > max_cell_types:
255
+ issues.append(
256
+ ValidationIssue(
257
+ severity="warning",
258
+ code="MANY_CELL_TYPES",
259
+ message=f"{n_cell_types} cell types found (may indicate over-annotation)",
260
+ details={"n_cell_types": n_cell_types, "max_typical": max_cell_types},
261
+ )
262
+ )
263
+
264
+ # Check 5: Minimum cells per type
265
+ low_count_types = cell_type_counts[cell_type_counts < min_cells_per_type]
266
+ if len(low_count_types) > 0:
267
+ issues.append(
268
+ ValidationIssue(
269
+ severity="warning",
270
+ code="LOW_CELL_COUNTS",
271
+ message=f"{len(low_count_types)} cell types have <{min_cells_per_type} cells",
272
+ details={
273
+ "affected_types": dict(low_count_types.head(10)),
274
+ "n_affected": len(low_count_types),
275
+ },
276
+ )
277
+ )
278
+
279
+ # Check 6: Suspicious patterns
280
+ if check_suspicious_patterns and n_cell_types > 0:
281
+ suspicious_types = []
282
+ for cell_type in cell_type_counts.index:
283
+ for pattern, description in SUSPICIOUS_PATTERNS:
284
+ if re.match(pattern, str(cell_type), re.IGNORECASE):
285
+ suspicious_types.append((str(cell_type), description))
286
+ break
287
+
288
+ if suspicious_types:
289
+ issues.append(
290
+ ValidationIssue(
291
+ severity="warning",
292
+ code="SUSPICIOUS_LABELS",
293
+ message=f"{len(suspicious_types)} cell types have suspicious labels",
294
+ details={
295
+ "suspicious_types": [
296
+ {"label": t, "reason": r} for t, r in suspicious_types[:10]
297
+ ],
298
+ "n_total": len(suspicious_types),
299
+ },
300
+ )
301
+ )
302
+
303
+ # Check 7: Highly imbalanced data
304
+ if n_cell_types >= 2:
305
+ max_count = int(cell_type_counts.max())
306
+ min_count = int(cell_type_counts.min())
307
+ imbalance_ratio = max_count / max(min_count, 1)
308
+
309
+ if imbalance_ratio > 1000:
310
+ issues.append(
311
+ ValidationIssue(
312
+ severity="warning",
313
+ code="HIGHLY_IMBALANCED",
314
+ message=f"Extreme class imbalance: {imbalance_ratio:.0f}x ratio",
315
+ details={
316
+ "largest_type": str(cell_type_counts.idxmax()),
317
+ "largest_count": max_count,
318
+ "smallest_type": str(cell_type_counts.idxmin()),
319
+ "smallest_count": min_count,
320
+ "imbalance_ratio": float(imbalance_ratio),
321
+ },
322
+ )
323
+ )
324
+
325
+ # Determine overall validity (only errors make it invalid)
326
+ is_valid = not any(i.severity == "error" for i in issues)
327
+
328
+ result = CellTypeValidationResult(
329
+ column_name=column,
330
+ is_valid=is_valid,
331
+ n_cells=n_cells,
332
+ n_cell_types=n_cell_types,
333
+ issues=issues,
334
+ cell_type_counts=cell_type_counts,
335
+ )
336
+
337
+ # Log summary
338
+ if is_valid:
339
+ logger.info(
340
+ f"Validation passed: {column} ({n_cell_types} cell types, {n_cells:,} cells)"
341
+ )
342
+ for issue in issues:
343
+ logger.warning(f" {issue.code}: {issue.message}")
344
+ else:
345
+ logger.error(f"Validation failed: {column}")
346
+ for issue in issues:
347
+ log_fn = logger.error if issue.severity == "error" else logger.warning
348
+ log_fn(f" {issue.code}: {issue.message}")
349
+
350
+ return result
351
+
352
+
353
+ def validate_multiple_columns(
354
+ adatas: List[ad.AnnData],
355
+ columns: List[str],
356
+ raise_on_error: bool = True,
357
+ **kwargs,
358
+ ) -> List[CellTypeValidationResult]:
359
+ """
360
+ Validate cell type columns across multiple AnnData objects.
361
+
362
+ Convenience function for validating multiple references before combining.
363
+
364
+ Parameters
365
+ ----------
366
+ adatas : List[AnnData]
367
+ List of AnnData objects to validate.
368
+ columns : List[str]
369
+ Cell type column name for each AnnData. Must have same length as adatas.
370
+ raise_on_error : bool, default True
371
+ If True, raise ValueError when any validation fails with errors.
372
+ **kwargs
373
+ Additional arguments passed to validate_cell_type_column.
374
+
375
+ Returns
376
+ -------
377
+ List[CellTypeValidationResult]
378
+ Validation results for each AnnData.
379
+
380
+ Raises
381
+ ------
382
+ ValueError
383
+ If lengths of adatas and columns don't match.
384
+ If any validation fails with errors and raise_on_error=True.
385
+
386
+ Examples
387
+ --------
388
+ >>> from spatialcore.annotation import validate_multiple_columns
389
+ >>> results = validate_multiple_columns(
390
+ ... [adata1, adata2],
391
+ ... ["cell_type", "annotation"],
392
+ ... )
393
+ >>> for r in results:
394
+ ... print(r.summary())
395
+ """
396
+ if len(adatas) != len(columns):
397
+ raise ValueError(
398
+ f"Number of adatas ({len(adatas)}) must match columns ({len(columns)})"
399
+ )
400
+
401
+ results = []
402
+ all_valid = True
403
+
404
+ for i, (adata, col) in enumerate(zip(adatas, columns)):
405
+ logger.info(f"Validating reference {i + 1}/{len(adatas)}: column '{col}'")
406
+ result = validate_cell_type_column(adata, col, **kwargs)
407
+ results.append(result)
408
+ if not result.is_valid:
409
+ all_valid = False
410
+
411
+ if not all_valid and raise_on_error:
412
+ failed = [r for r in results if not r.is_valid]
413
+ error_msgs = []
414
+ for r in failed:
415
+ for e in r.errors:
416
+ error_msgs.append(f"{r.column_name}: {e.message}")
417
+ raise ValueError(
418
+ f"Validation failed for {len(failed)} reference(s):\n"
419
+ + "\n".join(error_msgs)
420
+ )
421
+
422
+ return results
@@ -0,0 +1,34 @@
1
+ """Core utilities for SpatialCore: logging, metadata tracking, caching, gene mapping."""
2
+
3
+ from spatialcore.core.logging import get_logger, setup_logging
4
+ from spatialcore.core.metadata import MetadataTracker, update_metadata
5
+ from spatialcore.core.cache import cache_result, clear_cache, get_cache_path
6
+ from spatialcore.core.utils import (
7
+ # Gene ID mapping (Ensembl → HUGO)
8
+ load_ensembl_to_hugo_mapping,
9
+ normalize_gene_names,
10
+ download_ensembl_mapping,
11
+ is_ensembl_id,
12
+ # Expression normalization detection
13
+ check_normalization_status,
14
+ )
15
+
16
+ __all__ = [
17
+ # Logging
18
+ "get_logger",
19
+ "setup_logging",
20
+ # Metadata
21
+ "MetadataTracker",
22
+ "update_metadata",
23
+ # Caching
24
+ "cache_result",
25
+ "clear_cache",
26
+ "get_cache_path",
27
+ # Gene ID mapping
28
+ "load_ensembl_to_hugo_mapping",
29
+ "normalize_gene_names",
30
+ "download_ensembl_mapping",
31
+ "is_ensembl_id",
32
+ # Expression normalization
33
+ "check_normalization_status",
34
+ ]
@@ -0,0 +1,118 @@
1
+ """Caching utilities for intermediate results."""
2
+
3
+ import hashlib
4
+ from functools import wraps
5
+ from pathlib import Path
6
+ from typing import Callable, Optional, Union
7
+
8
+ import anndata as ad
9
+
10
+ from spatialcore.core.logging import get_logger
11
+
12
+ logger = get_logger("cache")
13
+
14
+ _CACHE_DIR = Path(".cache")
15
+
16
+
17
+ def get_cache_path(name: str, cache_dir: Optional[Union[str, Path]] = None) -> Path:
18
+ """
19
+ Get path for a cached file.
20
+
21
+ Parameters
22
+ ----------
23
+ name
24
+ Name of the cached file (without extension).
25
+ cache_dir
26
+ Optional cache directory. Defaults to '.cache/'.
27
+
28
+ Returns
29
+ -------
30
+ Path
31
+ Full path to the cache file.
32
+ """
33
+ cache_dir = Path(cache_dir) if cache_dir else _CACHE_DIR
34
+ cache_dir.mkdir(parents=True, exist_ok=True)
35
+ return cache_dir / f"{name}.h5ad"
36
+
37
+
38
+ def cache_result(
39
+ name: Optional[str] = None,
40
+ cache_dir: Optional[Union[str, Path]] = None,
41
+ ) -> Callable:
42
+ """
43
+ Decorator to cache AnnData results.
44
+
45
+ Parameters
46
+ ----------
47
+ name
48
+ Optional cache name. If not provided, uses function name + hash of args.
49
+ cache_dir
50
+ Optional cache directory.
51
+
52
+ Returns
53
+ -------
54
+ Callable
55
+ Decorated function.
56
+
57
+ Examples
58
+ --------
59
+ >>> @cache_result(name="my_analysis")
60
+ ... def expensive_computation(adata):
61
+ ... # ... computation ...
62
+ ... return adata
63
+ """
64
+ def decorator(func: Callable) -> Callable:
65
+ @wraps(func)
66
+ def wrapper(*args, **kwargs):
67
+ cache_name = name or _generate_cache_name(func.__name__, args, kwargs)
68
+ cache_path = get_cache_path(cache_name, cache_dir)
69
+
70
+ if cache_path.exists():
71
+ logger.info(f"Loading cached result from {cache_path}")
72
+ return ad.read_h5ad(cache_path)
73
+
74
+ result = func(*args, **kwargs)
75
+
76
+ if isinstance(result, ad.AnnData):
77
+ logger.info(f"Caching result to {cache_path}")
78
+ result.write_h5ad(cache_path)
79
+
80
+ return result
81
+ return wrapper
82
+ return decorator
83
+
84
+
85
+ def clear_cache(cache_dir: Optional[Union[str, Path]] = None) -> int:
86
+ """
87
+ Clear all cached files.
88
+
89
+ Parameters
90
+ ----------
91
+ cache_dir
92
+ Optional cache directory. Defaults to '.cache/'.
93
+
94
+ Returns
95
+ -------
96
+ int
97
+ Number of files removed.
98
+ """
99
+ cache_dir = Path(cache_dir) if cache_dir else _CACHE_DIR
100
+ if not cache_dir.exists():
101
+ return 0
102
+
103
+ count = 0
104
+ for f in cache_dir.glob("*.h5ad"):
105
+ f.unlink()
106
+ count += 1
107
+
108
+ logger.info(f"Cleared {count} cached files from {cache_dir}")
109
+ return count
110
+
111
+
112
+ def _generate_cache_name(func_name: str, args: tuple, kwargs: dict) -> str:
113
+ """Generate a unique cache name based on function and arguments."""
114
+ hasher = hashlib.md5()
115
+ hasher.update(func_name.encode())
116
+ hasher.update(str(args).encode())
117
+ hasher.update(str(sorted(kwargs.items())).encode())
118
+ return f"{func_name}_{hasher.hexdigest()[:8]}"
@@ -0,0 +1,135 @@
1
+ """Logging utilities for SpatialCore."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional, Union
7
+
8
+ _LOGGER_NAME = "spatialcore"
9
+ _DEFAULT_FORMAT = "[%(levelname)s] %(name)s: %(message)s"
10
+ _INITIALIZED = False
11
+
12
+
13
+ def _ensure_initialized() -> None:
14
+ """
15
+ Ensure the root spatialcore logger has at least one handler.
16
+
17
+ Called automatically by get_logger() to prevent silent log drops.
18
+ """
19
+ global _INITIALIZED
20
+ if _INITIALIZED:
21
+ return
22
+
23
+ root_logger = logging.getLogger(_LOGGER_NAME)
24
+
25
+ # Only add default handler if none exist
26
+ if not root_logger.handlers:
27
+ handler = logging.StreamHandler(sys.stdout)
28
+ handler.setFormatter(logging.Formatter(_DEFAULT_FORMAT))
29
+ handler.setLevel(logging.INFO)
30
+ root_logger.addHandler(handler)
31
+ root_logger.setLevel(logging.INFO)
32
+ root_logger.propagate = False
33
+
34
+ _INITIALIZED = True
35
+
36
+
37
+ def get_logger(name: Optional[str] = None) -> logging.Logger:
38
+ """
39
+ Get a logger instance for SpatialCore.
40
+
41
+ Automatically initializes a default stdout handler if none exists,
42
+ preventing silent log message drops.
43
+
44
+ Parameters
45
+ ----------
46
+ name
47
+ Optional submodule name. If provided, returns logger for
48
+ 'spatialcore.{name}', otherwise returns the root spatialcore logger.
49
+
50
+ Returns
51
+ -------
52
+ logging.Logger
53
+ Configured logger instance with at least one handler.
54
+ """
55
+ _ensure_initialized()
56
+
57
+ if name:
58
+ return logging.getLogger(f"{_LOGGER_NAME}.{name}")
59
+ return logging.getLogger(_LOGGER_NAME)
60
+
61
+
62
+ def setup_logging(
63
+ level: int = logging.INFO,
64
+ format_string: Optional[str] = None,
65
+ ) -> None:
66
+ """
67
+ Configure logging for SpatialCore.
68
+
69
+ Clears existing handlers and configures fresh logging. Safe to call
70
+ multiple times (idempotent after first call with same parameters).
71
+
72
+ Parameters
73
+ ----------
74
+ level
75
+ Logging level (e.g., logging.INFO, logging.DEBUG).
76
+ format_string
77
+ Custom format string. Defaults to '[%(levelname)s] %(name)s: %(message)s'.
78
+ """
79
+ global _INITIALIZED
80
+
81
+ if format_string is None:
82
+ format_string = _DEFAULT_FORMAT
83
+
84
+ logger = logging.getLogger(_LOGGER_NAME)
85
+
86
+ # Clear existing handlers to prevent duplicates
87
+ logger.handlers.clear()
88
+
89
+ handler = logging.StreamHandler(sys.stdout)
90
+ handler.setFormatter(logging.Formatter(format_string))
91
+ handler.setLevel(level)
92
+
93
+ logger.setLevel(level)
94
+ logger.addHandler(handler)
95
+ logger.propagate = False
96
+
97
+ _INITIALIZED = True
98
+
99
+
100
+ def setup_file_logging(
101
+ log_path: Union[str, Path],
102
+ level: int = logging.INFO,
103
+ format_string: Optional[str] = None,
104
+ ) -> logging.FileHandler:
105
+ """
106
+ Add file handler to SpatialCore logger.
107
+
108
+ Parameters
109
+ ----------
110
+ log_path
111
+ Path to log file.
112
+ level
113
+ Logging level.
114
+ format_string
115
+ Custom format string.
116
+
117
+ Returns
118
+ -------
119
+ logging.FileHandler
120
+ The file handler (can be removed later with logger.removeHandler).
121
+ """
122
+ if format_string is None:
123
+ format_string = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
124
+
125
+ log_path = Path(log_path)
126
+ log_path.parent.mkdir(parents=True, exist_ok=True)
127
+
128
+ handler = logging.FileHandler(log_path, mode="w")
129
+ handler.setFormatter(logging.Formatter(format_string))
130
+ handler.setLevel(level)
131
+
132
+ logger = logging.getLogger(_LOGGER_NAME)
133
+ logger.addHandler(handler)
134
+
135
+ return handler