Metadata-Version: 2.4
Name: initially
Version: 1.0.0
Summary: CLI argument and config-file parsing into dataclasses, plus logging and PRNG bootstrap helpers
Project-URL: Homepage, https://github.com/miriada-io/initially
Project-URL: Repository, https://github.com/miriada-io/initially
Project-URL: Issues, https://github.com/miriada-io/initially/issues
Project-URL: Changelog, https://github.com/miriada-io/initially/blob/master/CHANGELOG.md
Author-email: Miriada <info@miriada.io>
License-Expression: MIT
License-File: LICENSE
Keywords: argparse,bootstrap,cli,config,dataclass,ini,logging,toml
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: exceptly>=1.0.0
Requires-Dist: iffy>=1.0.0
Requires-Dist: no-value>=1.0.0
Requires-Dist: typeful>=1.0.0
Requires-Dist: wireform>=1.0.0
Provides-Extra: dev
Requires-Dist: coverage>=7.0; extra == 'dev'
Requires-Dist: pre-commit>=3.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# initially

[![PyPI](https://img.shields.io/pypi/v/initially.svg?label=PyPI)](https://pypi.org/project/initially/)
[![Python](https://img.shields.io/pypi/pyversions/initially.svg?label=Python)](https://pypi.org/project/initially/)
[![Tests](https://github.com/miriada-io/initially/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/miriada-io/initially/actions/workflows/tests.yml)
[![License](https://img.shields.io/pypi/l/initially.svg?label=License)](https://github.com/miriada-io/initially/blob/master/LICENSE)

Bootstrapping utilities for Python services: parse `argv` and a config file (`.toml`/`.ini`) straight into a dataclass, set up root logging, and a few small helpers around them — without writing the same fifty lines at the top of every `main`.

## Installation

```bash
pip install initially
```

Requires Python 3.11+.

## Why initially?

The first hundred lines of every `if __name__ == "__main__":` look the same:

- read a config file, fall back to defaults, optionally accept `-v key=value` overrides on the CLI;
- shovel the resulting dict into a typed dataclass;
- print a usable error and exit if something is missing instead of dumping a stacktrace;
- configure root logging so library messages are visible.

`initially` is exactly that, factored into one import. It treats your `@dataclass` as the source of truth: field types drive parsing and validation, and the same dataclass shape is also what gets printed back to the user as a sample INI on misuse.

## Quick Start

The recommended layout: one top-level config dataclass per service, a `LogsConfig` field, parse, init logs, run.

```python
from dataclasses import dataclass

from initially import LogsConfig, init_logs, parse_args_as_dataclass_or_exit


@dataclass
class AppConfig:
    @dataclass
    class Database:
        url: str
        timeout: float = 5.0

    logging: LogsConfig
    db: Database


def main() -> None:
    config = parse_args_as_dataclass_or_exit(AppConfig)
    init_logs(config.logging)
    ...


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

Run it with a TOML file:

```bash
python app.py config.toml
```

…or override individual fields without a file:

```bash
python app.py -v db.url=postgres://... -v db.timeout=10 -v logging.level=DEBUG
```

…or mix both — file values are loaded first, then CLI `-v key=value` flags overwrite them.

If a required field is missing or types don't fit, descriptive errors are logged and the process exits with the configured exit code. Run the binary with no arguments at all and you'll additionally see a minimal `config.ini` example derived from your dataclass — handy in CI to see exactly what the binary expects.

### `LogsConfig` as a config field

Putting `logging: LogsConfig` as a field of your top-level config makes the `[logging]` INI section line up with the dataclass automatically:

```ini
[logging]
level = INFO
filename = /var/log/app.log
```

```python
@dataclass
class AppConfig:
    logging: LogsConfig
    ...
```

`init_logs(config.logging)` then takes care of the rest. This is the intended idiom — there is no separate "logging config" path.

## Overview

**Argument parsing:** [`parse_args_as_dataclass_or_exit`](#parse_args_as_dataclass_or_exit) | [`parse_args`](#parse_args--arg) | [`Arg`](#parse_args--arg) | [`parse_args_as_file_with_kwargs`](#parse_args_as_file_with_kwargs)

**Config files:** [`read_file_as_dict`](#read_file_as_dict--read_config) | [`read_config`](#read_file_as_dict--read_config) | [`read_toml_file_by_path`](#read_toml_file_by_path) | [`read_ini_file_by_path`](#read_ini_file_by_path) | [`parse_ini_content`](#parse_ini_content)

**Logging:** [`init_logs`](#init_logs--logsconfig--customformatter) | [`LogsConfig`](#init_logs--logsconfig--customformatter) | [`CustomFormatter`](#init_logs--logsconfig--customformatter)

**Dataclass introspection:** [`get_hint_from_dataclass`](#get_hint_from_dataclass) | [`StructureHint`](#structurehint--fieldhint) | [`FieldHint`](#structurehint--fieldhint) | [`make_ini_from_hint`](#make_ini_from_hint)

**File I/O:** [`read_file_text`](#read_file_text)

**Errors:** [`FileTextReadingFailure`](#read_file_text) | [`ParseError`](#parseerror--iniparseerror) | [`IniParseError`](#parseerror--iniparseerror)

**Other:** [`init_random`](#init_random)

---

## Argument parsing

### `parse_args_as_dataclass_or_exit`

```python
def parse_args_as_dataclass_or_exit(
    dataclass_type: type[DP],
    args: Iterable[str] | None = None,
    flag_name: str | None = None,           # default: '-v'
    key_value_separator: str | None = None, # default: '='
    key_depth_separator: str | None = None, # default: '.'
    exit_code: int = 2,
) -> DP
```

The recommended entry point for the standard case: one config file → one dataclass, optionally with `-v key=value` overrides. Reach for [`parse_args`](#parse_args--arg) instead when you need multiple positional arguments or per-slot custom casters.

Reads an optional positional config file (`.toml` or `.ini`, picked by extension), applies any number of `-v key=value` overrides, and converts the merged `dict` into `dataclass_type`. Nested dataclasses are addressed with dotted keys: `-v db.url=...`.

If the file can't be read, fields are missing, or types don't fit, the function logs descriptive errors via the standard `logging` module and calls `exit(exit_code)`. When neither a file nor any overrides were passed, it also logs a minimal `config.ini` example derived from the dataclass — useful in CI to see what the binary expects.

### `parse_args` & `Arg`

The lower-level positional-argument parser. Use it when you need more than one positional argument, or when you want per-slot custom casters. For the typical case of one config file → one dataclass, prefer [`parse_args_as_dataclass_or_exit`](#parse_args_as_dataclass_or_exit) — it's shorter, validates against your dataclass, and adds `-v key=value` overrides on top.

Each keyword argument to `parse_args` declares one CLI slot, in order. Each value is a cast callable: a plain type (`int`, `float`, your own constructor) or an `Arg` for richer behaviour, including a hint string used in error output.

```python
from dataclasses import dataclass

from initially import Arg, parse_args


@dataclass
class Settings:
    url: str
    timeout: int = 5


# Two positional arguments: a config file and a JSON report
config, report = parse_args(
    config_file=Arg.toml_file_to_dataclass(Settings),
    report_file=Arg.json_file(hint="path to a JSON report"),
)
```

Run it as `python app.py config.toml report.json`.

`parse_args` returns a single value when called with one keyword argument, and a list in the order they were declared otherwise.

`Arg` ships with helpers for common cases:

| Helper                            | Behaviour                                                        |
|-----------------------------------|------------------------------------------------------------------|
| `Arg.json_file(hint=None)`        | argument is a path to a JSON file → parsed as `dict`/`list`      |
| `Arg.ini_file(hint=None)`         | argument is a path to an INI file → parsed as `dict[str, dict]`  |
| `Arg.ini_file_to_dataclass(DC)`   | INI file → `DC` instance; auto-generates a hint from `DC` fields |
| `Arg.toml_file_to_dataclass(DC)`  | same for TOML                                                    |

On error, missing/failed casts are reported via `logging.critical` and `exit(2)` is called.

### `parse_args_as_file_with_kwargs`

```python
def parse_args_as_file_with_kwargs(
    args: Iterable[str] | None = None,
    kwarg_flag_name: str | None = None,        # default: '-v'
    key_value_separator: str | None = None,    # default: '='
) -> tuple[str | None, dict[str, str]]
```

The low-level building block underneath `parse_args_as_dataclass_or_exit`: returns `(config_file_path_or_None, dict_of_overrides)`. Useful when you want to merge the parsed flags into something other than a dataclass.

```python
parse_args_as_file_with_kwargs(args=["./some.ini", "-v", "key=42", "-v", "foo=bar"])
# ('./some.ini', {'key': '42', 'foo': 'bar'})
```

## Config files

### `read_file_as_dict` / `read_config`

```python
def read_file_as_dict(path, encoding=None) -> dict[str, dict[str, str]]
```

Dispatches to `read_toml_file_by_path` or `read_ini_file_by_path` based on the file's suffix; raises `TypeError` for any other extension. `read_config` is a single-argument alias.

### `read_toml_file_by_path`

Reads and parses a `.toml` file via the stdlib `tomllib`. Errors propagate as raised by `tomllib`. The file is read through `read_file_text`, so a missing or unreadable path raises `FileTextReadingFailure`.

### `read_ini_file_by_path`

Reads a `.ini` file and returns `dict[str, dict[str, str]]`. INI parsing problems become `IniParseError`. The file is read through `read_file_text`, so a missing or unreadable path raises `FileTextReadingFailure`.

### `parse_ini_content`

Same as `read_ini_file_by_path`, but operates on an in-memory string.

## Logging

### `init_logs` / `LogsConfig` / `CustomFormatter`

```python
@dataclass
class LogsConfig:
    level: str = "DEBUG"
    filename: str | None = None
    max_file_size_megabytes: int = 10
    max_files_amount: int = 10

def init_logs(settings: LogsConfig | None = None,
              logging_formatter: logging.Formatter | None = None) -> None
```

Configures the root logger. With no `filename`, logs go to `stdout`; with a `filename`, a `RotatingFileHandler` is attached using the configured size/retention. The default `CustomFormatter` rewrites `record.name` to include the process pid and the module name, which makes log lines from forked workers easy to attribute. Pass your own `logging.Formatter` to override the format.

The intended way to wire this in: keep `LogsConfig` as a field of your top-level config dataclass (so `[logging]` in the INI lines up automatically), then call `init_logs(config.logging, logging_formatter=...)` once at startup.

## Dataclass introspection

### `get_hint_from_dataclass`

Walks a dataclass type and returns a `StructureHint` describing fields, their types, and defaults. Nested dataclasses become nested `StructureHint`s. Fields with `init=False` are skipped (they are computed in `__post_init__`, not configured).

### `StructureHint` & `FieldHint`

Frozen dataclasses describing the shape of another dataclass. `FieldHint` carries `name`, `type`, and an optional `default` (which may be a `default_factory` callable). `StructureHint` carries an optional `name` and a sequence of fields/sub-structures.

### `make_ini_from_hint`

Renders a `StructureHint` as a sample INI document. This is what `parse_args_as_dataclass_or_exit` uses internally to build the "MINIMAL config.ini EXAMPLE" hint that is printed when a required field is missing — the example you see in the error logs is generated from your dataclass, so it always matches what the binary actually accepts.

It's also exposed for direct use: when you want to ship an `example_config.ini` alongside your service that stays in sync with the code, render it once from the dataclass:

```python
from initially import get_hint_from_dataclass, make_ini_from_hint

ini_text = make_ini_from_hint(get_hint_from_dataclass(AppConfig))
```

Required fields appear as `name = REQUIRED`; optional fields appear as commented-out lines with the default. Defaults that are callables (e.g. `time.time`) appear as `; name = ...` with a `default=module.qualname` annotation. Pass `add_optional=False` to omit optional fields entirely.

INI itself does not allow more than one nesting level — passing a deeper structure raises `ValueError`.

## File I/O

### `read_file_text`

```python
def read_file_text(path, encoding: str | None = None) -> str
```

Reads a text file, raising `FileTextReadingFailure` with a message that names the failing path and the underlying cause for every failure mode (empty path, bad path type, missing file, not-a-file, decode error, lookup error). Used internally by the INI/TOML readers; exposed because it's frequently useful in its own right.

## Errors

### `ParseError` / `IniParseError`

`ParseError` is the base for parser-level failures; `IniParseError` is its INI-specific subclass.

## Other

### `init_random`

```python
def init_random(seed=NoValue) -> None | int | float | str | bytes | bytearray
```

Seeds `random.seed`. With no argument (or the `NoValue` sentinel), it uses `os.getpid() + time.time()` so two processes started in the same second still get different sequences. Useful when you want unique-per-process startup randomness without rolling your own seeding.

## License

[MIT](https://github.com/miriada-io/initially/blob/master/LICENSE)
