diff --git a/Cargo.lock b/Cargo.lock index 68c9c5e..e7ee9cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1805,6 +1805,7 @@ dependencies = [ "sqlx", "thiserror", "tokio", + "tracing", ] [[package]] @@ -1871,6 +1872,8 @@ dependencies = [ "sqlx", "tokio", "toml", + "tracing", + "tracing-subscriber", ] [[package]] diff --git a/ryx-common/Cargo.toml b/ryx-common/Cargo.toml index bb899f6..4015504 100644 --- a/ryx-common/Cargo.toml +++ b/ryx-common/Cargo.toml @@ -10,3 +10,4 @@ sqlx = { workspace = true } tokio = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } +tracing = { workspace = true } diff --git a/ryx-python/ryx/__init__.py b/ryx-python/ryx/__init__.py index 2268ff2..f20e902 100644 --- a/ryx-python/ryx/__init__.py +++ b/ryx-python/ryx/__init__.py @@ -2,8 +2,26 @@ # Import the compiled Rust extension directly to avoid circular import import ryx.ryx_core as _core +import logging as _logging import os +# Configure Ryx logging from RYX_LOG_LEVEL env var +_ryx_logger = _logging.getLogger("ryx") +_ryx_log_level = os.environ.get("RYX_LOG_LEVEL", "").strip().upper() +if _ryx_log_level in ("NO_LOG", "OFF", "SILENT"): + _ryx_logger.disabled = True + _ryx_logger.propagate = False + _ryx_logger.addHandler(_logging.NullHandler()) +elif _ryx_log_level: + _ryx_log_level_num = getattr(_logging, _ryx_log_level, _logging.INFO) + _ryx_log_handler = _logging.StreamHandler() + _ryx_log_handler.setFormatter( + _logging.Formatter("[ryx] %(levelname)s %(name)s: %(message)s") + ) + _ryx_logger.addHandler(_ryx_log_handler) + _ryx_logger.setLevel(_ryx_log_level_num) + _ryx_logger.propagate = False + # ORM core from ryx.models import Constraint, Index, Model @@ -123,8 +141,12 @@ async def setup( # For old versions wrap the url with a dict if isinstance(urls, str): - urls = {'default': urls} + urls = {'default': urls} + _ryx_logger.info( + "Initializing pools: %s (max_conn=%d, min_conn=%d)", + list(urls.keys()), max_connections, min_connections, + ) await _core.setup( urls, max_connections = max_connections, @@ -133,10 +155,12 @@ async def setup( idle_timeout = idle_timeout, max_lifetime = max_lifetime, ) + _ryx_logger.info("Pools initialized: %s", list(urls.keys())) def register_lookup(name: str, sql_template: str) -> None: """Register a custom lookup operator (process-global).""" + _ryx_logger.debug("Register custom lookup: %s = %s", name, sql_template) _core.register_lookup(name, sql_template) @@ -345,7 +369,10 @@ def _discover_config_file(): def _auto_setup(): global _AUTO_INIT_DONE - if _AUTO_INIT_DONE or not _should_auto_init(): + if _AUTO_INIT_DONE: + return + if not _should_auto_init(): + _ryx_logger.debug("Auto-init disabled via RYX_AUTO_INITIALIZE") return urls = _discover_urls_from_env() @@ -356,8 +383,11 @@ def _auto_setup(): pool_cfg = cfg.get("pool", {}) or {} if not urls: + _ryx_logger.debug("No URLs found — auto-init skipped") return + _ryx_logger.info("Auto-initializing with URLs: %s", list(urls.keys())) + try: import asyncio diff --git a/ryx-python/ryx/bulk.py b/ryx-python/ryx/bulk.py index 38115ec..d5a1547 100644 --- a/ryx-python/ryx/bulk.py +++ b/ryx-python/ryx/bulk.py @@ -25,6 +25,7 @@ from __future__ import annotations +import logging from typing import List, Sequence, Type, TYPE_CHECKING, Optional if TYPE_CHECKING: @@ -33,6 +34,8 @@ from ryx import ryx_core as _core from ryx.router import get_router +logger = logging.getLogger("ryx.bulk") + def _resolve_alias(model: "Model") -> Optional[str]: """Resolve DB alias using Router → Meta.database → default(None).""" @@ -96,6 +99,11 @@ async def bulk_create( """ from ryx.models import _apply_auto_timestamps + logger.info( + "bulk_create %s: %d rows (batch_size=%d, validate=%s, ignore_conflicts=%s)", + model.__name__, len(instances), batch_size, validate, ignore_conflicts, + ) + if not instances: return list(instances) @@ -271,6 +279,11 @@ async def bulk_update( Signals: Does NOT fire pre_save / post_save signals (for performance). """ + logger.info( + "bulk_update %s: %d rows, fields=%s (batch_size=%d)", + model.__name__, len(instances), fields, batch_size, + ) + if not instances or not fields: return 0 @@ -352,6 +365,11 @@ async def bulk_delete( Signals: Does NOT fire pre_delete / post_delete signals. """ + logger.info( + "bulk_delete %s: %d rows (batch_size=%d)", + model.__name__, len(instances), batch_size, + ) + pk_field = model._meta.pk_field if not pk_field: raise ValueError(f"{model.__name__} has no primary key") diff --git a/ryx-python/ryx/cache.py b/ryx-python/ryx/cache.py index 2cfa25f..c76b402 100644 --- a/ryx-python/ryx/cache.py +++ b/ryx-python/ryx/cache.py @@ -34,11 +34,14 @@ import asyncio import hashlib import json +import logging import time from abc import ABC, abstractmethod from collections import OrderedDict from typing import Any, Optional +logger = logging.getLogger("ryx.cache") + #### ## ABSTRACT CACHE BACKEND @@ -175,6 +178,10 @@ def configure_cache( global _cache_backend, _auto_invalidate _cache_backend = backend _auto_invalidate = auto_invalidate + logger.info( + "Cache configured: backend=%s, auto_invalidate=%s", + type(backend).__name__, auto_invalidate, + ) if auto_invalidate: _register_invalidation_signals() @@ -215,6 +222,7 @@ async def invalidate(key: str) -> None: """ if _cache_backend: await _cache_backend.delete(key) + logger.debug("Cache invalidate: %s", key) async def invalidate_model(model: type) -> None: @@ -231,12 +239,14 @@ async def invalidate_model(model: type) -> None: keys = await _cache_backend.keys(prefix) if keys: await _cache_backend.delete_many(keys) + logger.debug("Cache invalidate model %s: %d keys", model.__name__, len(keys)) async def invalidate_all() -> None: """Clear the entire cache.""" if _cache_backend: await _cache_backend.clear() + logger.debug("Cache invalidate all") #### @@ -269,9 +279,11 @@ async def _execute(self) -> list: # Try cache first cached = await backend.get(key) if cached is not None: + logger.debug("Cache HIT: %s", key) return cached # Cache miss → hit DB + logger.debug("Cache MISS: %s", key) result = await super()._execute() # type: ignore[misc] # Serialise model instances to plain dicts for caching diff --git a/ryx-python/ryx/cli/commands/makemigrations.py b/ryx-python/ryx/cli/commands/makemigrations.py index 2ad54ec..b8597f3 100644 --- a/ryx-python/ryx/cli/commands/makemigrations.py +++ b/ryx-python/ryx/cli/commands/makemigrations.py @@ -4,6 +4,7 @@ import sys from ryx.cli.commands.base import Command +from ryx.cli.style import PREFIX, OK, WARN, green, red, yellow class MakeMigrationsCommand(Command): @@ -28,6 +29,11 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: metavar="DIR", help="Migrations directory (default: migrations)", ) + parser.add_argument( + "--alias", + metavar="ALIAS", + help="Database alias to generate migrations for (creates subdirectory)", + ) parser.add_argument( "--name", metavar="NAME", help="Override migration name slug" ) @@ -44,30 +50,34 @@ async def execute(self, args: argparse.Namespace) -> int: cfg = getattr(args, "resolved_config", None) or resolve_config(args) models = self._load_models(args.models or cfg.models) if not models: - print("[ryx] No models found. Pass --models myapp.models or set [models].files in ryx.toml") + print(f"{PREFIX} {WARN} No models found. Pass {yellow('--models myapp.models')} or set [models].files in ryx.toml") return 1 from ryx.migrations.autodetect import Autodetector - detector = Autodetector(models=models, migrations_dir=args.dir) + detector = Autodetector( + models=models, + migrations_dir=args.dir, + alias=getattr(args, "alias", None), + ) operations = detector.detect() if not operations: - print("[ryx] No changes detected.") + print(f"{PREFIX} No changes detected.") if args.check: return 0 return 0 if args.check: - print(f"[ryx] {len(operations)} change(s) detected:") + print(f"{PREFIX} {red(str(len(operations)))} change(s) detected:") for op in operations: - print(f" - {op.describe()}") + print(f" {op.describe()}") return 1 path = detector.write_migration(operations) - print(f"[ryx] Created migration: {path}") + print(f"{PREFIX} {OK} Created {green(str(path.name))}") for op in operations: - print(f" - {op.describe()}") + print(f" {op.describe()}") return 0 diff --git a/ryx-python/ryx/cli/commands/migrate.py b/ryx-python/ryx/cli/commands/migrate.py index 213b5cf..6f8834a 100644 --- a/ryx-python/ryx/cli/commands/migrate.py +++ b/ryx-python/ryx/cli/commands/migrate.py @@ -1,13 +1,12 @@ from __future__ import annotations import argparse -import asyncio import sys -from pathlib import Path -from typing import List, Optional +from typing import Optional from ryx.cli.commands.base import Command from ryx.cli.config import get_config, Config +from ryx.cli.style import PREFIX, OK, FAIL, cyan, green class MigrateCommand(Command): @@ -38,6 +37,11 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: metavar="ALIAS", help="Run migrations for a specific database alias", ) + parser.add_argument( + "--no-interactive", + action="store_true", + help="Fail if no migration files found (for CI)", + ) async def execute(self, args: argparse.Namespace) -> int: cfg = getattr(args, "resolved_config", None) @@ -52,7 +56,7 @@ async def execute(self, args: argparse.Namespace) -> int: # Masking the first URL for the log first_url = list(urls.values())[0] if isinstance(urls, dict) else urls - print(f"[ryx] Connecting to {self._mask_url(first_url)} ...") + print(f"{PREFIX} Connecting to {cyan(self._mask_url(first_url))} ...") import ryx @@ -66,22 +70,17 @@ async def execute(self, args: argparse.Namespace) -> int: models, dry_run=getattr(args, "dry_run", False), alias_filter=getattr(args, "database", None) or (cfg.db_alias if cfg else None), + migrations_dir=getattr(args, "dir", "migrations"), + no_interactive=getattr(args, "no_interactive", False), ) - if getattr(args, "plan", False): - # For plan, we just want to see what would happen - # In a real implementation, this would be a separate runner method - print("[ryx] --plan is active. Running in dry-run mode...") - # We could force dry_run = True here - changes = await runner.migrate() if changes: - print( - f"[ryx] Applied {len(changes)} change(s) across configured databases." - ) + count = green(str(len(changes))) + print(f"{PREFIX} {OK} Applied {count} change(s)") else: - print("[ryx] No pending migrations.") + print(f"{PREFIX} No pending migrations") return 0 @@ -129,11 +128,12 @@ def _mask_url(self, url: str) -> str: return re.sub(r"(:)[^:@/]+(@)", r"\1***\2", url) def _print_missing_url(self) -> None: + from ryx.cli.style import FAIL, yellow, cyan print( - "[ryx] No database URL found.\n" - " Set RYX_DATABASE_URL environment variable, or\n" - " pass --url postgres://user:pass@host/db, or\n" - " create ryx_settings.py with DATABASE_URL = '...'" + f"{PREFIX} {FAIL} No database URL found.\n" + f" Set {cyan('RYX_DATABASE_URL')} environment variable, or\n" + f" pass {yellow('--url postgres://user:pass@host/db')}, or\n" + f" create {cyan('ryx_settings.py')} with {cyan('DATABASE_URL')} = '...'" ) diff --git a/ryx-python/ryx/cli/config.py b/ryx-python/ryx/cli/config.py index 4e5d4fd..850681d 100644 --- a/ryx-python/ryx/cli/config.py +++ b/ryx-python/ryx/cli/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os from dataclasses import dataclass, field from pathlib import Path @@ -7,6 +8,9 @@ from ryx.cli.config_loader import get_loader, load_config +_ryx_logger = logging.getLogger("ryx") +_logger = logging.getLogger("ryx.cli") + @dataclass class Config: @@ -54,6 +58,14 @@ def from_args(cls, args) -> "Config": config.debug = getattr(args, "debug", False) config.verbose = getattr(args, "verbose", False) + # Wire CLI flags to logging levels + if config.debug: + _ryx_logger.setLevel(logging.DEBUG) + _logger.debug("Debug mode enabled via CLI --debug") + elif config.verbose: + _ryx_logger.setLevel(logging.INFO) + _logger.info("Verbose mode enabled via CLI --verbose") + # Load config file if specified config_file = getattr(args, "config_file", None) if config_file: diff --git a/ryx-python/ryx/cli/style.py b/ryx-python/ryx/cli/style.py new file mode 100644 index 0000000..a4f62c4 --- /dev/null +++ b/ryx-python/ryx/cli/style.py @@ -0,0 +1,81 @@ +""" +Ryx CLI — color and formatting utilities. + +Usage:: + + from ryx.cli.style import PREFIX, OK, FAIL, WARN, green, cyan + + print(f"{PREFIX} {OK} Migration applied") + print(f"{PREFIX} {FAIL} {red('Error:')} something broke") +""" + +from __future__ import annotations + +import os +import sys + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_RED = "\033[31m" +_GREEN = "\033[32m" +_YELLOW = "\033[33m" +_BLUE = "\033[34m" +_MAGENTA = "\033[35m" +_CYAN = "\033[36m" +_GREY = "\033[90m" + + +def _supports_color() -> bool: + if not sys.stdout.isatty(): + return False + if os.environ.get("NO_COLOR"): + return False + term = os.environ.get("TERM", "") + if term == "dumb": + return False + return True + + +_USE_COLOR = _supports_color() + + +def _c(text: str, code: str) -> str: + return f"{code}{text}{_RESET}" if _USE_COLOR else text + + +def bold(text: str) -> str: + return _c(text, _BOLD) + + +def dim(text: str) -> str: + return _c(text, _GREY) + + +def red(text: str) -> str: + return _c(text, _RED) + + +def green(text: str) -> str: + return _c(text, _GREEN) + + +def yellow(text: str) -> str: + return _c(text, _YELLOW) + + +def blue(text: str) -> str: + return _c(text, _BLUE) + + +def magenta(text: str) -> str: + return _c(text, _MAGENTA) + + +def cyan(text: str) -> str: + return _c(text, _CYAN) + + +PREFIX = _c("[ryx]", f"{_BLUE}{_BOLD}") if _USE_COLOR else "[ryx]" +OK = _c("✓", _GREEN) if _USE_COLOR else "✓" +FAIL = _c("✗", _RED) if _USE_COLOR else "✗" +WARN = _c("⚠", _YELLOW) if _USE_COLOR else "⚠" diff --git a/ryx-python/ryx/migrations/autodetect.py b/ryx-python/ryx/migrations/autodetect.py index 235d5e9..050b779 100644 --- a/ryx-python/ryx/migrations/autodetect.py +++ b/ryx-python/ryx/migrations/autodetect.py @@ -41,7 +41,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from ryx.migrations.state import ( ColumnState, SchemaState, TableState, @@ -58,18 +58,24 @@ class CreateTable: """Create a new database table.""" table: str columns: List[ColumnState] + model: Optional[type] = None def describe(self) -> str: return f"Create table '{self.table}'" def to_python(self) -> str: - cols = ", ".join( - f'ColumnState(name={c.name!r}, db_type={c.db_type!r}, ' - f'nullable={c.nullable!r}, primary_key={c.primary_key!r}, ' - f'unique={c.unique!r})' - for c in self.columns - ) - return f" CreateTable(table={self.table!r}, columns=[{cols}])," + cols = ",\n ".join(_column_state_repr(c) for c in self.columns) + lines = [ + f" CreateTable(", + f" table={self.table!r},", + f" columns=[", + f" {cols},", + f" ],", + ] + if self.model: + lines.append(f" model={self.model.__qualname__},") + lines.append(f" ),") + return "\n".join(lines) ### @@ -80,18 +86,21 @@ class AddField: """Add a column to an existing table.""" table: str column: ColumnState + model: Optional[type] = None def describe(self) -> str: return f"Add field '{self.column.name}' to '{self.table}'" def to_python(self) -> str: - c = self.column - return ( - f" AddField(table={self.table!r}, " - f"column=ColumnState(name={c.name!r}, db_type={c.db_type!r}, " - f"nullable={c.nullable!r}, primary_key={c.primary_key!r}, " - f"unique={c.unique!r}))," - ) + lines = [ + f" AddField(", + f" table={self.table!r},", + f" column={_column_state_repr(self.column)},", + ] + if self.model: + lines.append(f" model={self.model.__qualname__},") + lines.append(f" ),") + return "\n".join(lines) ### @@ -101,22 +110,30 @@ def to_python(self) -> str: class AlterField: """Change a column's type or constraints.""" table: str - old_col: ColumnState new_col: ColumnState + old_col: Optional[ColumnState] = None + model: Optional[type] = None def describe(self) -> str: + old = self.old_col + old_info = f"{old.db_type} → " if old else "" return ( - f"Alter field '{self.old_col.name}' on '{self.table}': " - f"{self.old_col.db_type} → {self.new_col.db_type}" + f"Alter field '{self.new_col.name}' on '{self.table}': " + f"{old_info}{self.new_col.db_type}" ) def to_python(self) -> str: - nc = self.new_col - return ( - f" AlterField(table={self.table!r}, " - f"new_col=ColumnState(name={nc.name!r}, db_type={nc.db_type!r}, " - f"nullable={nc.nullable!r}))," - ) + lines = [ + f" AlterField(", + f" table={self.table!r},", + f" new_col={_column_state_repr(self.new_col)},", + ] + if self.old_col: + lines.append(f" old_col={_column_state_repr(self.old_col)},") + if self.model: + lines.append(f" model={self.model.__qualname__},") + lines.append(f" ),") + return "\n".join(lines) ### @@ -129,15 +146,23 @@ class CreateIndex: name: str fields: List[str] unique: bool = False + model: Optional[type] = None def describe(self) -> str: return f"Create {'unique ' if self.unique else ''}index '{self.name}' on '{self.table}'" def to_python(self) -> str: - return ( - f" CreateIndex(table={self.table!r}, name={self.name!r}, " - f"fields={self.fields!r}, unique={self.unique!r})," - ) + lines = [ + f" CreateIndex(", + f" table={self.table!r},", + f" name={self.name!r},", + f" fields={self.fields!r},", + f" unique={self.unique!r},", + ] + if self.model: + lines.append(f" model={self.model.__qualname__},") + lines.append(f" ),") + return "\n".join(lines) ### @@ -171,6 +196,59 @@ class MigrationFile: operations: List[Any] # Operation instances +### +## COLUMN SERIALIZATION +#### +def _column_state_repr(c: ColumnState) -> str: + """Return a Python repr string for a ColumnState, including all fields.""" + parts = [f"name={c.name!r}", f"db_type={c.db_type!r}"] + if c.nullable is not True: + parts.append(f"nullable={c.nullable!r}") + if c.primary_key: + parts.append(f"primary_key={c.primary_key!r}") + if c.unique: + parts.append(f"unique={c.unique!r}") + if c.default is not None: + parts.append(f"default={c.default!r}") + return f"ColumnState({', '.join(parts)})" + + +### +## STANDALONE HELPERS (shared with runner) +#### +def load_migration_file(path: Path) -> MigrationFile: + """Import and return the Migration class from a migration file path.""" + spec = importlib.util.spec_from_file_location(path.stem, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + cls = mod.Migration + return MigrationFile( + name=path.stem, + dependencies=cls.dependencies, + operations=cls.operations, + ) + + +def apply_migration_to_state(mf: MigrationFile, state: SchemaState) -> None: + """Apply the operations in a MigrationFile to a SchemaState.""" + for op in mf.operations: + if isinstance(op, CreateTable): + table = TableState(name=op.table) + for col in op.columns: + table.add_column(col) + state.add_table(table) + + elif isinstance(op, AddField): + if state.has_table(op.table): + state.tables[op.table].add_column(op.column) + + elif isinstance(op, AlterField): + if state.has_table(op.table) and state.tables[op.table].has_column(op.new_col.name): + col = state.tables[op.table].columns[op.new_col.name] + col.db_type = op.new_col.db_type + col.nullable = op.new_col.nullable + + ### ## AUTODETECTOR #### @@ -182,6 +260,8 @@ class Autodetector: migrations_dir: Path to the migrations directory (relative or absolute). Created if it doesn't exist. app_label: Optional app namespace prefix for migration names. + alias: Optional database alias. If set, files go in + ``{migrations_dir}/{alias}/``. """ def __init__( @@ -189,9 +269,12 @@ def __init__( models: List[type], migrations_dir: str = "migrations", app_label: str = "", + alias: Optional[str] = None, ) -> None: self._models = models - self._migrations_dir = Path(migrations_dir) + self._alias = alias + base = Path(migrations_dir) + self._migrations_dir = base / alias if alias else base self._app_label = app_label # Public API @@ -236,6 +319,10 @@ def write_migration(self, operations: List[Any]) -> Path: ops_code = "\n".join(op.to_python() for op in operations) timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + # Collect unique model imports from operations + model_imports = self._collect_model_imports(operations) + model_imports_code = "\n".join(model_imports) + "\n" if model_imports else "" + content = f'''# Auto-generated by ryx ORM — {timestamp} # Do not edit manually unless you know what you are doing. @@ -243,7 +330,7 @@ def write_migration(self, operations: List[Any]) -> Path: CreateTable, AddField, AlterField, CreateIndex, RunSQL, ) from ryx.migrations.state import ColumnState - +{model_imports_code} class Migration: """Migration {file_name} @@ -261,6 +348,20 @@ class Migration: file_path.write_text(content) return file_path + @staticmethod + def _collect_model_imports(operations: list) -> list: + """Build ``from myapp.models import Author`` lines from operation models.""" + imports: Dict[str, list] = {} + for op in operations: + model = getattr(op, "model", None) + if not model: + continue + imports.setdefault(model.__module__, []).append(model.__qualname__) + return [ + f"from {mod} import {', '.join(sorted(set(cls)))}" + for mod, cls in sorted(imports.items()) + ] + # Internal helpers def _load_applied_state(self) -> SchemaState: """Build the current state by replaying all applied migrations in order. @@ -279,8 +380,8 @@ def _load_applied_state(self) -> SchemaState: for mf in migration_files: try: - migration = self._load_migration_file(mf) - self._apply_migration_to_state(migration, state) + migration = load_migration_file(mf) + apply_migration_to_state(migration, state) except Exception as e: import warnings warnings.warn( @@ -290,62 +391,47 @@ def _load_applied_state(self) -> SchemaState: return state - def _load_migration_file(self, path: Path) -> MigrationFile: - """Import and return the Migration class from a migration file.""" - spec = importlib.util.spec_from_file_location(path.stem, path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - cls = mod.Migration - return MigrationFile( - name = path.stem, - dependencies = cls.dependencies, - operations = cls.operations, - ) - - def _apply_migration_to_state(self, mf: MigrationFile, state: SchemaState) -> None: - """Apply the operations in a MigrationFile to a SchemaState.""" - for op in mf.operations: - if isinstance(op, CreateTable): - table = TableState(name=op.table) - for col in op.columns: - table.add_column(col) - state.add_table(table) - - elif isinstance(op, AddField): - if state.has_table(op.table): - state.tables[op.table].add_column(op.column) - - elif isinstance(op, AlterField): - if state.has_table(op.table) and state.tables[op.table].has_column(op.new_col.name): - state.tables[op.table].columns[op.new_col.name] = op.new_col - def _changes_to_operations( self, changes: List[SchemaChange], target: SchemaState, ) -> List[Any]: """Convert SchemaChange diffs to Operation objects.""" + # Build table → model lookup + table_to_model: Dict[str, type] = {} + for m in self._models: + if hasattr(m, "_meta"): + table_to_model[m._meta.table_name] = m + ops: List[Any] = [] for change in changes: + cls = table_to_model.get(change.table) + if change.kind == ChangeKind.CREATE_TABLE: table = target.tables.get(change.table) if table: ops.append(CreateTable( table = change.table, columns = list(table.columns.values()), + model = cls, )) elif change.kind == ChangeKind.ADD_COLUMN: if change.new_state: - ops.append(AddField(table=change.table, column=change.new_state)) + ops.append(AddField( + table=change.table, + column=change.new_state, + model=cls, + )) elif change.kind == ChangeKind.ALTER_COLUMN: if change.old_state and change.new_state: ops.append(AlterField( table = change.table, - old_col = change.old_state, new_col = change.new_state, + old_col = change.old_state, + model = cls, )) # Also add index creation operations for all models @@ -361,15 +447,16 @@ def _changes_to_operations( name = idx.name, fields = idx.fields, unique = idx.unique, + model = model, )) for i, fields in enumerate(meta.index_together): name = f"idx_{table}_{'_'.join(fields)}_{i}" - ops.append(CreateIndex(table=table, name=name, fields=list(fields))) + ops.append(CreateIndex(table=table, name=name, fields=list(fields), model=model)) for i, fields in enumerate(meta.unique_together): name = f"uq_{table}_{'_'.join(fields)}_{i}" - ops.append(CreateIndex(table=table, name=name, fields=list(fields), unique=True)) + ops.append(CreateIndex(table=table, name=name, fields=list(fields), unique=True, model=model)) return ops diff --git a/ryx-python/ryx/migrations/runner.py b/ryx-python/ryx/migrations/runner.py index ea0e3ea..3ddfb18 100644 --- a/ryx-python/ryx/migrations/runner.py +++ b/ryx-python/ryx/migrations/runner.py @@ -17,10 +17,19 @@ from __future__ import annotations import logging -import os -from typing import List, Optional +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Set from ryx import ryx_core as _core +from ryx.migrations.autodetect import ( + AddField, + AlterField, + CreateIndex, + CreateTable, + RunSQL, + load_migration_file, +) from ryx.migrations.state import ( ChangeKind, ColumnState, @@ -32,6 +41,8 @@ ) from ryx.migrations.ddl import DDLGenerator, detect_backend +from ryx.cli.style import PREFIX, OK, FAIL, WARN, green, yellow, red, cyan, magenta + logger = logging.getLogger("ryx.migrations") MIGRATIONS_TABLE = "ryx_migrations" @@ -64,10 +75,14 @@ def __init__( dry_run: bool = False, backend: Optional[str] = None, alias_filter: Optional[str] = None, + migrations_dir: str = "migrations", + no_interactive: bool = False, ) -> None: self._models = models self._dry_run = dry_run self._alias_filter = alias_filter + self._migrations_dir = Path(migrations_dir) + self._no_interactive = no_interactive # 'backend' is now a fallback if we can't detect it from the pool self._fallback_backend = backend.lower() if backend else "postgres" self._ddl = None # Will be initialized per-database during migration @@ -75,6 +90,15 @@ def __init__( async def migrate(self) -> List[SchemaChange]: """Detect and apply all pending schema changes across configured databases. + Strategy: + 1. Discover ALL migration files recursively under ``{migrations_dir}/``, + sorted globally by numeric prefix. + 2. For each DB alias, filter operations to only those whose table + routes to this alias (via ``Meta.database`` or ``Router``). + 3. Apply only relevant operations per alias; track each file as applied + per-alias in ``ryx_migrations``. + 4. If no migration files exist at all, offer interactive fallback. + Returns: A list of all SchemaChange objects applied across databases. """ @@ -82,26 +106,26 @@ async def migrate(self) -> List[SchemaChange]: router = get_router() - all_applied_changes = [] + # Discover all migration files (flat, recursive) + all_migration_files = self._discover_all_migration_files() + + all_applied_changes: List[SchemaChange] = [] aliases = _core.list_aliases() for alias in aliases: - # Filter by alias if requested via CLI if self._alias_filter and alias != self._alias_filter: continue logger.info("Running migrations for database: %s", alias) - # 1. Setup backend and DDL generator for this specific alias + # Setup backend and DDL generator for this alias try: backend = _core.get_backend(alias) logger.info("Backend for alias '%s': %s", alias, backend) except Exception as e: logger.warning( "Could not detect backend for alias %s: %s. Falling back to %s", - alias, - e, - self._fallback_backend, + alias, e, self._fallback_backend, ) backend = self._fallback_backend @@ -109,47 +133,289 @@ async def migrate(self) -> List[SchemaChange]: self._ddl = DDLGenerator(backend) self._current_alias = alias - # 2. Determine which models belong to this database - models_for_db = [] - for model in self._models: - # Routing priority: Router -> Meta.database -> default - db = None - if router: - db = router.db_for_write(model) - if not db: - db = getattr(model._meta, "database", None) - - if db == alias or (db is None and alias == "default"): - models_for_db.append(model) - + # Determine models for this alias + models_for_db = self._filter_models_for_db(alias, router) if not models_for_db: logger.debug("No models mapped to database %s, skipping.", alias) continue - # 3. Process migrations for this database - await self._ensure_migrations_table(alias) - current_state = await self._introspect_schema(alias) - target_state = project_state_from_models(models_for_db) - changes = diff_states(current_state, target_state) - - if not changes: - logger.info("Database %s is up to date.", alias) - else: - logger.info("Detected %d change(s) for %s:", len(changes), alias) - for ch in changes: - logger.info(" - [%s] %s", alias, ch) - - if self._dry_run: - self._print_dry_run(changes, target_state, alias) + if all_migration_files: + changes = await self._apply_file_migrations(alias, all_migration_files) all_applied_changes.extend(changes) - else: - await self._apply_changes(changes, target_state, alias) + elif models_for_db: + await self._handle_no_migration_files(alias, models_for_db) + + # Always apply indexes, constraints, M2M tables + if not self._dry_run: await self._apply_meta_extras(alias) - all_applied_changes.extend(changes) logger.info("Multi-DB migration complete.") return all_applied_changes + # ------------------------------------------------------------------ + # MODEL → ALIAS ROUTING HELPERS + # ------------------------------------------------------------------ + def _filter_models_for_db(self, alias: str, router) -> list: + """Return models whose route maps to the given database alias.""" + models = [] + for model in self._models: + db = None + if router: + db = router.db_for_write(model) + if not db: + db = getattr(model._meta, "database", None) + if db == alias or (db is None and alias == "default"): + models.append(model) + return models + + def _operation_is_relevant(self, op, alias: str) -> bool: + """Return True if the operation should be executed for *alias*. + + ``RunSQL`` always executes. Table-level ops (CreateTable, AddField, …) + check ``op.model`` (the Model class itself, injected at migration-file + generation time): if ``model._meta.database`` matches *alias*, + the operation is relevant. + """ + if isinstance(op, RunSQL): + return True + model = getattr(op, "model", None) + if model is None: + return True + db = getattr(model._meta, "database", None) + return db == alias or (db is None and alias == "default") + + # ------------------------------------------------------------------ + # DISCOVER ALL MIGRATION FILES (flat, recursive) + # ------------------------------------------------------------------ + def _discover_all_migration_files(self) -> List[Path]: + """Recursively find all ``[0-9]*.py`` files under ``migrations/``. + + Returns a globally sorted list (by numeric prefix), ignoring ``__pycache__``. + """ + if not self._migrations_dir.exists(): + return [] + all_files: List[Path] = [] + for f in self._migrations_dir.rglob("[0-9]*.py"): + # Skip files in __pycache__ + if "__pycache__" in f.parts: + continue + all_files.append(f) + all_files.sort(key=lambda p: p.stem) + logger.debug("Discovered %d migration file(s)", len(all_files)) + return all_files + + # ------------------------------------------------------------------ + # FILE-BASED MIGRATIONS (flat + per-alias operation routing) + # ------------------------------------------------------------------ + async def _apply_file_migrations( + self, + alias: str, + all_migration_files: List[Path], + ) -> list: + """Apply pending migration files to *alias*. + + Each file's operations are filtered via ``model_path`` to only + include those relevant to *alias*. Tracks applied files per-alias + in ``ryx_migrations`` using the ``alias|file_stem`` key. + + Returns list of SchemaChange objects for reporting. + """ + await self._ensure_migrations_table(alias) + applied: Set[str] = await self._get_applied_migrations(alias) + + pending = [f for f in all_migration_files if f.stem not in applied] + if not pending: + logger.info("Database %s is up to date.", alias) + return [] + + changes: List[SchemaChange] = [] + for mf_path in pending: + migration = load_migration_file(mf_path) + + # Filter to only operations relevant to this alias + relevant_ops = [ + op for op in migration.operations + if self._operation_is_relevant(op, alias) + ] + + if not relevant_ops: + logger.debug( + "Skipping %s for %s — no relevant operations", + mf_path.stem, alias, + ) + await self._record_migration(alias, mf_path.stem) + continue + + logger.info("Applying: %s to %s (%d op(s))", mf_path.stem, alias, len(relevant_ops)) + label = f"{mf_path.stem}" + print(f"{PREFIX} {cyan(label)} → {magenta(alias)} ({len(relevant_ops)} op(s))") + + if self._dry_run: + print(f" {yellow('(dry-run)')} would apply: {cyan(label)}") + changes.append(SchemaChange( + kind=ChangeKind.CREATE_TABLE, + table=mf_path.stem, + description=f"Migration {mf_path.stem}", + )) + continue + + for op in relevant_ops: + sql = self._operation_to_ddl(op) + if sql: + logger.debug("SQL: %s", sql.strip()) + from ryx.executor_helpers import raw_execute + try: + await raw_execute(sql, alias=alias) + except Exception as e: + logger.error( + "DDL failed in %s: %s — %s", mf_path.stem, sql, e + ) + raise + + await self._record_migration(alias, mf_path.stem) + print(f"{PREFIX} {OK} {green(mf_path.stem)}") + + return changes + + def _operation_to_ddl(self, op) -> Optional[str]: + """Convert a migration Operation to a DDL SQL string.""" + if isinstance(op, CreateTable): + table = TableState(name=op.table) + for col in op.columns: + table.add_column(col) + return self._ddl.create_table(table) + + if isinstance(op, AddField): + return self._ddl.add_column(op.table, op.column) + + if isinstance(op, AlterField): + return self._ddl.alter_column(op.table, op.new_col) + + if isinstance(op, CreateIndex): + return self._ddl.create_index_from_fields( + op.table, op.fields, op.name, unique=op.unique, + ) + + if isinstance(op, RunSQL): + return op.sql + + return None + + # ------------------------------------------------------------------ + # INTERACTIVE FALLBACK (no migration files exist at all) + # ------------------------------------------------------------------ + async def _handle_no_migration_files( + self, alias: str, models: list, + ) -> None: + """Called when no migration files exist anywhere under ``migrations/``. + + Offers the user an interactive choice, or errors if ``--no-interactive``. + """ + if self._no_interactive: + print(f"{PREFIX} {WARN} No migration files exist and {yellow('--no-interactive')} is set.") + print(f" Run {yellow('ryx makemigrations --models ')} first") + return + + print( + f"\n{PREFIX} {yellow('No migration files exist')} for database {magenta(alias)}" + ) + print(f" {len(models)} model(s) are not yet tracked.") + print() + print(f" {green('L')}ive DDL — apply changes directly (development only)") + print(f" {green('A')}uto-generate migration files, then migrate") + print(f" {green('M')}anual — run {yellow('ryx makemigrations')} first") + print(f" {green('S')}kip this database for now") + print() + + choice = input(f" {PREFIX} Choice [S]: ").strip().upper() or "S" + + if choice == "L": + logger.info("Applying live DDL for %s ...", alias) + current_state = await self._introspect_schema(alias) + target_state = project_state_from_models(models) + changes = diff_states(current_state, target_state) + if changes: + print(f"{PREFIX} {green(str(len(changes)))} live change(s) → {magenta(alias)}") + await self._apply_changes(changes, target_state, alias) + + elif choice == "A": + logger.info("Auto-generating migration files for %s ...", alias) + from ryx.migrations.autodetect import Autodetector + + detector = Autodetector( + models=models, + migrations_dir=str(self._migrations_dir), + ) + operations = detector.detect() + if not operations: + print(f"{PREFIX} No changes detected.") + return + path = detector.write_migration(operations) + print(f"{PREFIX} {OK} Created {green(path.name)}") + + migration = load_migration_file(path) + await self._ensure_migrations_table(alias) + for op in migration.operations: + sql = self._operation_to_ddl(op) + if sql: + from ryx.executor_helpers import raw_execute + await raw_execute(sql, alias=alias) + await self._record_migration(alias, path.stem) + print(f"{PREFIX} {OK} {green(path.stem)} applied") + + elif choice == "M": + print(f"{PREFIX} Run {yellow('ryx makemigrations --models ')}") + print(f" Then run {yellow('ryx migrate')} again") + + else: + print(f"{PREFIX} {yellow('Skipped')} database {magenta(alias)}") + + # ------------------------------------------------------------------ + # MIGRATION TRACKING TABLE + # ------------------------------------------------------------------ + async def _get_applied_migrations(self, alias: str) -> Set[str]: + """Return set of migration file stems already applied for this alias. + + Uses ``alias|stem`` as the tracking key. Falls back to bare stems + for backward compatibility with the old format. + """ + applied: Set[str] = set() + prefix = f"{alias}|" + try: + from ryx.executor_helpers import raw_fetch + + rows = await raw_fetch( + f"SELECT name FROM {MIGRATIONS_TABLE} ORDER BY id", + alias=alias, + ) + for row in rows: + name: str = row.get("name", "") + if name.startswith(prefix): + applied.add(name[len(prefix):]) + elif "|" not in name: + applied.add(name) # old-style bare stem + except Exception: + pass + return applied + + async def _record_migration(self, alias: str, stem: str) -> None: + """Insert a row into the tracking table for an applied migration. + + Stores ``alias|stem`` as the name to allow per-alias tracking. + """ + from ryx.executor_helpers import raw_execute + + ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + qualified = f"{alias}|{stem}" + sql = ( + f"INSERT INTO {MIGRATIONS_TABLE} (name, applied_at) " + f"VALUES ('{qualified}', '{ts}')" + ) + try: + await raw_execute(sql, alias=alias) + except Exception as e: + logger.warning("Could not record migration '%s': %s", stem, e) + # Schema introspection async def _introspect_schema(self, alias: str) -> SchemaState: """Query the live database to build a current SchemaState.""" diff --git a/ryx-python/ryx/models.py b/ryx-python/ryx/models.py index 5613d27..9f835f0 100644 --- a/ryx-python/ryx/models.py +++ b/ryx-python/ryx/models.py @@ -26,11 +26,14 @@ async def after_delete(self) → post-SQL hook from __future__ import annotations +import logging import re from datetime import datetime from typing import Any, Dict, List, Optional from ryx import ryx_core as _core + +logger = logging.getLogger("ryx.models") from ryx.exceptions import DoesNotExist, MultipleObjectsReturned from ryx.fields import AutoField, DateTimeField, DateField, TimeField, Field, ManyToManyField from ryx.signals import post_delete, post_save, pre_delete, pre_save @@ -233,6 +236,7 @@ async def count(self) -> int: async def create(self, **kw): """Create and save a new model instance.""" + logger.debug("Manager.create(%s): %s", self._model.__name__, kw) instance = self._model(**kw) # Use the manager's alias if specified @@ -613,6 +617,10 @@ async def save( if alias: builder = builder.set_using(alias) new_id = await builder.execute_insert(values, returning_id=True) + logger.debug( + "INSERT %s (pk=%s) on %s", + type(self).__name__, new_id, alias or "default", + ) if self._meta.pk_field: object.__setattr__(self, self._meta.pk_field.attname, new_id) @@ -642,6 +650,11 @@ async def save( pk_field.column, "exact", self.pk, negated=False ) await builder.execute_update(values) + logger.debug( + "UPDATE %s (pk=%s, fields=%s) on %s", + type(self).__name__, self.pk, + [f.column for f in fields_to_save], alias or "default", + ) # after_save hook await self.after_save(created) @@ -681,6 +694,10 @@ async def delete(self) -> None: builder = builder.set_using(alias) builder = builder.add_filter(pk_field.column, "exact", self.pk, negated=False) await builder.execute_delete() + logger.debug( + "DELETE %s (pk=%s) on %s", + type(self).__name__, self.pk, alias or "default", + ) # Clear pk to signal "no longer in DB" object.__setattr__(self, self._meta.pk_field.attname, None) diff --git a/ryx-python/ryx/pool_ext.py b/ryx-python/ryx/pool_ext.py index 12acde6..4dee9e4 100644 --- a/ryx-python/ryx/pool_ext.py +++ b/ryx-python/ryx/pool_ext.py @@ -10,10 +10,13 @@ from __future__ import annotations +import logging from typing import Any, List from ryx import ryx_core as _core +logger = logging.getLogger("ryx.pool") + async def execute_with_params(sql: str, values: List[Any]) -> int: """Execute a parameterized SQL statement and return rows_affected. @@ -25,6 +28,7 @@ async def execute_with_params(sql: str, values: List[Any]) -> int: Returns: Number of rows affected. """ + logger.debug("EXEC: %s [%d params]", sql[:120], len(values)) return await _core.execute_with_params(sql, values) @@ -38,4 +42,5 @@ async def fetch_with_params(sql: str, values: List[Any]) -> list: Returns: List of row dicts. """ + logger.debug("FETCH: %s [%d params]", sql[:120], len(values)) return await _core.fetch_with_params(sql, values) diff --git a/ryx-python/ryx/relations.py b/ryx-python/ryx/relations.py index 2d158d0..4e38602 100644 --- a/ryx-python/ryx/relations.py +++ b/ryx-python/ryx/relations.py @@ -29,9 +29,11 @@ from __future__ import annotations -# import asyncio +import logging from typing import Any, Dict, List, TYPE_CHECKING +logger = logging.getLogger("ryx.relations") + if TYPE_CHECKING: from ryx.models import Model from ryx.queryset import QuerySet @@ -125,6 +127,10 @@ async def apply_select_related( result.append(instance) + logger.debug( + "select_related %s: %d rows via LEFT JOIN on %s", + model.__name__, len(result), list(joins.keys()), + ) return result diff --git a/ryx-python/ryx/router.py b/ryx-python/ryx/router.py index 6fa6451..d27ba97 100644 --- a/ryx-python/ryx/router.py +++ b/ryx-python/ryx/router.py @@ -6,8 +6,11 @@ """ from __future__ import annotations +import logging from typing import Any, Optional, TYPE_CHECKING +logger = logging.getLogger("ryx.router") + if TYPE_CHECKING: from ryx.models import Model @@ -42,6 +45,7 @@ def set_router(router: BaseRouter) -> None: """Set the global router for the application.""" global _router _router = router + logger.info("Router set: %s", type(router).__name__) def get_router() -> Optional[BaseRouter]: diff --git a/ryx-python/ryx/signals.py b/ryx-python/ryx/signals.py index 2c89829..f82ea78 100644 --- a/ryx-python/ryx/signals.py +++ b/ryx-python/ryx/signals.py @@ -45,7 +45,7 @@ async def on_post_save(sender, instance, created, **kwargs): import weakref from typing import Any, Callable, Optional, Type -logger = logging.getLogger("Rxy.signals") +logger = logging.getLogger("ryx.signals") #### diff --git a/ryx-python/ryx/transaction.py b/ryx-python/ryx/transaction.py index 5c5f08a..edbc6a0 100644 --- a/ryx-python/ryx/transaction.py +++ b/ryx-python/ryx/transaction.py @@ -42,7 +42,7 @@ from ryx import ryx_core as _core -logger = logging.getLogger("Ryx.transaction") +logger = logging.getLogger("ryx.transaction") # ContextVar: holds the currently active transaction handle (if any) # for the current async task. This enables auto-enlistment in a future version. diff --git a/ryx-python/src/lib.rs b/ryx-python/src/lib.rs index 8d994f2..ec99239 100644 --- a/ryx-python/src/lib.rs +++ b/ryx-python/src/lib.rs @@ -1012,8 +1012,26 @@ fn bulk_update<'py>( // Module definition // ### +fn _init_tracing() { + let level = std::env::var("RYX_LOG_LEVEL").unwrap_or_default(); + if level.is_empty() { + return; + } + let lower = level.to_lowercase(); + if lower == "no_log" || lower == "off" || lower == "silent" { + return; + } + let filter = format!("ryx={}", lower); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .try_init(); +} + #[pymodule] fn ryx_core(m: &Bound<'_, PyModule>) -> PyResult<()> { + _init_tracing(); + lookups::init_registry(); let mut builder = tokio::runtime::Builder::new_multi_thread(); diff --git a/ryx-query/src/compiler/compilr.rs b/ryx-query/src/compiler/compilr.rs index 05cf399..25f079f 100644 --- a/ryx-query/src/compiler/compilr.rs +++ b/ryx-query/src/compiler/compilr.rs @@ -17,6 +17,7 @@ use crate::lookups::date_lookups as date; use crate::lookups::json_lookups as json; use crate::lookups::{self, LookupContext}; use crate::symbols::{GLOBAL_INTERNER, Symbol}; +use tracing::debug; use dashmap::DashMap; use once_cell::sync::Lazy; use smallvec::SmallVec; @@ -144,6 +145,7 @@ pub struct CompiledQuery { } pub fn compile(node: &QueryNode) -> QueryResult { + debug!(?node.operation, table = ?GLOBAL_INTERNER.resolve(node.table), "Compiling query"); let mut values: SmallVec<[SqlValue; 8]> = SmallVec::new(); let plan_hash = compute_plan_hash(node); let mut node_column_names: Option> = None; diff --git a/ryx-query/src/lookups/lookups.rs b/ryx-query/src/lookups/lookups.rs index c781ace..342c859 100644 --- a/ryx-query/src/lookups/lookups.rs +++ b/ryx-query/src/lookups/lookups.rs @@ -13,6 +13,7 @@ use std::sync::{OnceLock, RwLock}; // Removed unused SqlValue import use crate::backend::Backend; use crate::errors::{QueryError, QueryResult}; +use tracing::debug; // Re-export submodules pub use crate::lookups::common_lookups; @@ -122,10 +123,13 @@ pub fn register_custom( .write() .map_err(|e| QueryError::Internal(format!("Registry lock poisoned: {e}")))?; + let name = name.into(); + let sql_template = sql_template.into(); + debug!("Register custom lookup: {} = {}", name, sql_template); guard.custom.insert( - name.into(), + name, PythonLookup { - sql_template: sql_template.into(), + sql_template, }, ); diff --git a/ryx-rs/Cargo.toml b/ryx-rs/Cargo.toml index 4e59f4e..4c806d9 100644 --- a/ryx-rs/Cargo.toml +++ b/ryx-rs/Cargo.toml @@ -17,6 +17,8 @@ chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } once_cell = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } toml = { workspace = true, optional = true } serde_yaml = { workspace = true, optional = true } redis = { version = "0.28", optional = true, features = ["tokio-comp"] } diff --git a/ryx-rs/src/lib.rs b/ryx-rs/src/lib.rs index b4bbfbe..cd82d72 100644 --- a/ryx-rs/src/lib.rs +++ b/ryx-rs/src/lib.rs @@ -11,6 +11,8 @@ pub mod row; pub mod stream; pub mod transaction; +use std::sync::OnceLock; + // Re-export key traits and types for convenience pub use config::{is_initialized, RyxConfig, PoolConfigSection, MigrationsConfig}; pub use model::{FieldMeta, Model, RelationMeta, Relationships}; @@ -20,6 +22,30 @@ pub use queryset::QuerySet; pub use row::FromRow; pub use transaction::transaction; +/// Initialize the global `tracing` subscriber from `RYX_LOG_LEVEL`. +/// +/// Called automatically by [`init()`]. Safe to call multiple times — +/// only the first call takes effect. +pub fn init_tracing() { + static INIT: OnceLock<()> = OnceLock::new(); + INIT.get_or_init(|| { + let level = + std::env::var("RYX_LOG_LEVEL").unwrap_or_default(); + if level.is_empty() { + return; + } + let lower = level.to_lowercase(); + if lower == "no_log" || lower == "off" || lower == "silent" { + return; + } + let filter = format!("ryx={}", lower); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .try_init(); + }); +} + /// Auto-detect config and initialize the pool. /// /// Equivalent to the Python `import ryx` auto-setup: @@ -29,6 +55,8 @@ pub use transaction::transaction; /// /// No-op if already initialized or no config is found. pub async fn init() -> RyxResult<()> { + init_tracing(); + tracing::info!("Initializing Ryx ..."); config::init().await }