Metadata-Version: 2.4
Name: rootlogger
Version: 0.6.3
Summary: Hierarchical logging wrapper focused on tree output and readability
Author: ks
License-Expression: MIT
Project-URL: Repository, https://github.com/kasairo/rootlogger
Project-URL: Issues, https://github.com/kasairo/rootlogger/issues
Keywords: logging,tree,console,debug
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == "dev"
Dynamic: license-file

﻿# rootlogger

`rootlogger` is a lightweight wrapper around Python logging focused on:

- tree-shaped execution visualization
- readable key-value output
- progress display helpers
- simple exception logging decorators

> Japanese documentation: [README_jp.md](README_jp.md)

## Installation

```bash
pip install rootlogger
```

For local development:

```bash
pip install -e .[dev]
```

## Quick Start

```python
import rootlogger as rl

with rl.root(log_level=rl.LogLevel.INFO):

    @rl.scope
    def process(src: str, dest: str, dry_run: bool = False) -> None:
        rl.table({"src": src, "dest": dest, "dry_run": dry_run})
        rl.to("status", "queued", "running")

        for item in rl.progress(range(10), prefix="processed", suffix=" items"):
            pass

    process("input.csv", "output.csv")
```

```
┌ main.py:6 - process()
├────────────────────────────
├ src                 : input.csv
├ dest                : output.csv
├ dry_run             : False
├ status              : queued
├                     -> running
├ processed           : 10 / 10 items
└────────── 00:00:00.012
```

---

## Initialization flow (important)

```python
import rootlogger as rl

def main() -> None:
    with rl.root(log_level=rl.LogLevel.INFO, save_log=False) as log_path:
        rl.info(f"log_path={log_path!r}")

        with rl.scope():
            rl.info("work starts")

if __name__ == "__main__":
    main()
```

Rules:

- call `root()` / `begin_root()` first
- then call `scope()` / `begin_scope()`
- if `scope()` / `begin_scope()` is called before root initialization, a `RuntimeError` is raised
- with context managers (`root`, `scope`), cleanup is automatic

---

## Lifecycle

### Context managers (recommended)

```python
# wrap the whole program
with rl.root(log_level=rl.LogLevel.DEBUG, color=None) as log_path:
    # end_root() is called automatically
    pass

# scope — infer name automatically
with rl.scope():              # infers caller function name
    rl.info("working")

# For explicit names, use begin_scope(name=...) instead.
rl.begin_scope(name="my_task()")
rl.info("working")
rl.end_scope()

# begin_scope()/scope() requires begin_root()/root() first
```

Scope header format depends on root `log_level`:
- `DEBUG`: `relative/path.py:line - scope_name`
- `INFO` or higher: `scope_name`

### Scope decorator

```python
@rl.scope                          # no parentheses (arguments are logged)
def process():
    rl.info("called")

@rl.scope
def classify_args(a: int, b: int):
    rl.val("sum", a + b)

@rl.scope(show_args=False)          # decorator-only: hide argument lines
def copy(src: str, dest: str):
    rl.to("path", src, dest)

@rl.scope(show_args_level=rl.LogLevel.DEBUG)  # decorator-only: per-function level override
def copy_verbose(src: str, dest: str):
    rl.to("path", src, dest)

@rl.scope(name="test")            # decorator-only: override scope header name
def custom_named_scope(src: str, dest: str):
    rl.to("path", src, dest)

@rl.scope(with_line=True)          # boxed header style
def report_section():
    rl.info("section start")

# default argument line level is LogLevel.INFO
# argument line classification can be configured globally
rl.configure(show_args_level=rl.LogLevel.INFO)

# note: show_args/show_args_level are for decorator usage only
# with rl.scope(show_args=.../show_args_level=...) is not supported
```

### Manual (legacy style)

```python
rl.begin_root(log_level=rl.LogLevel.INFO)
rl.begin_scope()
rl.info("hello")
rl.end_scope()
rl.end_root()
```

`begin_root` options:

| Parameter | Default | Description |
|---|---|---|
| `name` | `""` | root scope name (auto default when empty) |
| `save_log` | `True` | set `False` to skip log file creation |
| `dirpath_log` | `""` | output directory for log files |
| `with_line` | `False` | boxed style for root header |
| `log_level` | `LogLevel.INFO` | log level filter |
| `enable_sound` | `True` | start/success/error sound effects |
| `color` | `None` | `None`=auto on TTY, `True`=always, `False`=disabled |

`root(...)` accepts the same options and handles `begin_root()/end_root()` automatically.

`scope` options (decorator form):

| Parameter | Default | Description |
|---|---|---|
| `with_line` | `False` | boxed header style |
| `name` | `None` | decorator-only explicit scope header name |
| `show_args` | `None` | `None` behaves as `True` for decorators |
| `show_args_level` | `None` | per-function override; fallback is `configure(show_args_level=...)` |

---

## Log output

```python
rl.debug("debug message")        # log text in dark gray (tree lines stay default)
rl.info("info message")          # default color
rl.warning("warning message")    # log text in yellow
rl.error("error message")        # log text in red
rl.error("with traceback", exc_info=True)
rl.critical("critical message")  # log text in magenta
rl.critical("critical traceback", exc_info=True)
rl.exception("with traceback")   # current exception + traceback as ERROR
```

## Log level control

```python
rl.set_log_level(rl.LogLevel.WARNING)
rl.debug("hidden")
rl.warning("visible")

current = rl.get_log_level()
rl.val("current_level", current.name)
```

---

## Value display

```python
rl.val("user", "alice")                      # user : alice
rl.val("job_id", 42)                         # job_id : 42
rl.val(my_var)                               # title inferred from source
rl.to("path", "src/a.txt", "dst/a.txt")      # transition output in two lines

# example output shape:
# path                : src/a.txt
#                     -> dst/a.txt

# change val/table separator globally
rl.configure(val_symbol="=")
rl.val("latency", 12.3)                     # latency = 12.3

# change to() transition separator globally
rl.configure(to_symbol="=>")
rl.to("status", "queued", "done")

rl.configure()                               # reset to defaults

# display all dict entries at once
rl.table({"user": "alice", "job_id": 42, "mode": "batch"})
```

Key widths are aligned automatically within each scope.

---

## Progress display

### Iterator wrapper (recommended)

```python
for item in rl.progress(items, prefix="processed", suffix=" files"):
    process(item)

# pass total= explicitly when len() is not available
for item in rl.progress(generator, prefix="read", total=1000):
    process(item)
```

### Manual

```python
rl.progress_start("processed", suffix=" items")
for i in range(n):
    rl.progress_update(i + 1, n)
rl.progress_end(n, n)
```

---

## Visual helpers

```python
rl.nl()              # newline with proper indentation
rl.line()            # separator (line_length chars; default 50)
rl.info("section")   # optional label line
```

**Elapsed time coloring** (when `color` is enabled):
- >= 5s  -> yellow
- >= 30s -> red

---

## Decorator

```python
@rl.catch_exception(reraise=False)  # log exception and return None
def risky():
    ...

@rl.catch_exception(reraise=True)   # log then re-raise (default)
async def async_risky():
    ...

@rl.catch_exception(level=rl.LogLevel.CRITICAL)
def critical_risky():
    ...
```

---

## Introspection

```python
rl.defines()    # list constants (uppercase variables) in the caller's module
rl.variables()  # list variables in the caller's module
rl.functions()  # list functions and methods in the caller's file
```

---

## Customize

```python
rl.configure(
    tree_start=">",
    tree_line_char="-",
    thread_inherit=True,
    show_args_level=rl.LogLevel.INFO,
    align_width=24,
    line_length=80,
    val_symbol="=",
    to_symbol="=>",
    # tree_inside, tree_vertical, tree_end are also available
)

# reset all settings to defaults
rl.configure()
```

---

## Threading

By default, `rl.root()` installs thread inheritance for `threading.Thread`, so child threads inherit the parent logging context automatically.

If you disable it with `configure(thread_inherit=False)`, use `copy_logging_context()` for explicit inheritance.

Calling `rl.to()` / `rl.val()` in a child thread without inherited context still does not raise an error (it falls back to default alignment width).

Explicit inheritance example:

```python
import threading
import rootlogger as rl

def _worker() -> None:
    rl.to("Moved to trash", str(src), str(dst))

with rl.root(save_log=False):
    rl.configure(thread_inherit=False)
    ctx = rl.copy_logging_context()
    t = threading.Thread(target=ctx.run, args=(_worker,))
    t.start()
    t.join()
    rl.configure()  # reset
```

> **Note**: `asyncio` tasks inherit `ContextVar` automatically — no extra steps needed.

---

## API reference

| Category | Functions |
|---|---|
| Lifecycle | `root`, `begin_root`, `end_root` |
| Scope | `scope`, `begin_scope`, `end_scope` |
| Log levels | `debug`, `info`, `warning`, `error`, `critical`, `exception` |
| Value display | `val`, `to`, `table` |
| Visual helpers | `line`, `nl` |
| Progress | `progress`, `progress_start`, `progress_update`, `progress_end` |
| Log level control | `get_log_level`, `set_log_level` |
| Introspection | `defines`, `variables`, `functions` |
| Decorator | `scope`, `catch_exception` |
| Context/threading | `configure`, `copy_logging_context` |
| Help | `help` |
| Constants | `LogLevel`, `__version__` |

### Complete public signatures

```python
# constants
rl.LogLevel
rl.__version__

# lifecycle
rl.root(name="", save_log=True, dirpath_log="", with_line=False,
    log_level=rl.LogLevel.INFO, enable_sound=True, color=None)
rl.begin_root(name="", save_log=True, dirpath_log="", with_line=False,
          log_level=rl.LogLevel.INFO, enable_sound=True, color=None)
rl.end_root()

# scope
rl.scope(...)
rl.begin_scope(name="", with_line=False)
rl.end_scope()

# log levels
rl.debug(*values)
rl.info(*values)
rl.warning(*values)
rl.error(*values, exc_info=False)
rl.critical(*values, exc_info=False)
rl.exception(*values)
rl.set_log_level(level)
rl.get_log_level()

# structure
rl.val(title_or_value, value=?, level=rl.LogLevel.INFO)
rl.to(title, value1, value2, level=rl.LogLevel.INFO)
rl.table(data, level=rl.LogLevel.INFO)
rl.nl()
rl.line(level=rl.LogLevel.INFO)

# progress
rl.progress(iterable, prefix="", suffix="", total=None)
rl.progress_start(prefix="", suffix="")
rl.progress_update(count1, count2=None, newline=False)
rl.progress_end(count1, count2=None)

# decorators / introspection / helper
rl.catch_exception(reraise=True, level=rl.LogLevel.ERROR)
rl.defines()
rl.variables()
rl.functions()
rl.help()

# customization / threading
rl.configure(tree_start=None, tree_inside=None, tree_vertical=None,
         tree_line_char=None, tree_end=None, thread_inherit=None,
         show_args_level=None, align_width=None, line_length=None,
         val_symbol=None, to_symbol=None)
rl.copy_logging_context()
```

---

## Development

```bash
python -m pytest -q
```

Run tests with the helper script:

```bash
python scripts/run_tests.py      # runs pytest -q
python scripts/run_tests.py -q   # same
python scripts/run_tests.py tests/test_rootlogger_output.py  # run specific tests
```
