Metadata-Version: 2.4
Name: bear-epoch-time
Version: 1.3.6
Summary: Elegant epoch timestamp handling with timezone-aware operations, fluent API, and smart defaults for Python developers who hate datetime complexity.
Author-email: chaz <bright.lid5647@fastmail.com>
Requires-Python: >=3.12
Requires-Dist: lazy-bear>=0.0.12
Requires-Dist: tzdata>=2025.2
Requires-Dist: tzlocal>=5.3.1
Provides-Extra: pydantic
Requires-Dist: pydantic>=2.13.4; extra == 'pydantic'
Description-Content-Type: text/markdown

# Bear Epoch Time

A lightweight Python library for working with epoch timestamps. `EpochTimestamp` inherits from `int`, so it slots into any code that expects an integer — but layers on a fluent, precision-aware API for arithmetic, timezone conversion, formatting, and component access without wrestling `datetime`.

**Key Features:**
- `EpochTimestamp` inherits from `int` — usable anywhere an `int` is
- Precision-aware: works in seconds, **milliseconds (default)**, microseconds, or nanoseconds
- UTC-first with explicit timezone conversions
- Fluent, chainable API for arithmetic and component access
- Instant-based comparisons — the same moment is equal across precisions
- Serialization built in: compact bytes, pickle, and optional Pydantic v2 support
- Full comparison support with `datetime`, `date`, `time`, `int`, and `float`
- High-resolution `Timer` / `async_timer` helpers with auto display-unit picking

**Requirements:** Python 3.12+

## Installation

```bash
pip install bear-epoch-time
# or with uv
uv pip install bear-epoch-time
```

## Quick Start

```python
from bear_epoch_time import EpochTimestamp, Precision, now

# Current UTC timestamp (default precision: milliseconds)
ts = EpochTimestamp.now()
print(ts)              # 1734567890123
print(ts.to_seconds)   # 1734567890
print(ts.date_str())   # "12-18-2025"

# Module-level shortcuts for the common cases
ts_ms = now()                          # current ms
ts_ns = now(p=Precision.NANOSECONDS)    # current ns
```

## Precision

Bear Epoch Time supports four precisions via the `Precision` enum. Internally, every conversion routes through nanoseconds so round-trips are lossless across precisions:

```python
from bear_epoch_time import Precision

Precision.SECONDS       # 1 unit = 1s
Precision.MILLISECONDS  # 1 unit = 1ms      ← default
Precision.MICROSECONDS  # 1 unit = 1μs
Precision.NANOSECONDS   # 1 unit = 1ns
```

The default precision used across the library is exported as `DEFAULT_PRECISION`. Most factory methods accept a `precision=` keyword to override it per-call:

```python
from bear_epoch_time import EpochTimestamp, Precision, DEFAULT_PRECISION

print(DEFAULT_PRECISION)  # Precision.MILLISECONDS

ts_s  = EpochTimestamp.now(p=Precision.SECONDS)
ts_us = EpochTimestamp.now(p=Precision.MICROSECONDS)
```

`Precision` values can also be passed as strings (`"s"`, `"ms"`, `"us"`, `"ns"`, or their full names).

## Creating Timestamps

### From the current time

```python
from bear_epoch_time import EpochTimestamp, Precision

# Class methods
ts_ms = EpochTimestamp.now()                       # ms (default)
ts_s  = EpochTimestamp.now(p=Precision.SECONDS)
ts_ns = EpochTimestamp.now(p="ns")                 # strings work too
```

### From other types

```python
from datetime import datetime, date
from bear_epoch_time import EpochTimestamp, Precision

# From datetime (timezone-aware preferred; naive datetimes are treated as UTC)
dt = datetime(2025, 12, 25, 10, 30, 0)
ts = EpochTimestamp.from_datetime(dt)
ts = EpochTimestamp.from_datetime(dt, precision=Precision.NANOSECONDS)

# From a date (midnight in the configured class timezone)
ts = EpochTimestamp.from_date(date(2025, 12, 25))

# From seconds since epoch
ts = EpochTimestamp.from_seconds(1734567890)

# From an ISO 8601 string
ts = EpochTimestamp.from_iso_string("2025-12-25T10:30:00+00:00")

# From a custom format string
ts = EpochTimestamp.from_dt_string("12-25-2025 10:30 AM", fmt="%m-%d-%Y %I:%M %p")
```

### Precision-targeted shortcuts

When you know which precision you want, there are dedicated constructors:

```python
from bear_epoch_time import EpochTimestamp, Precision

# Create at the named precision; if v is None, current time is used.
EpochTimestamp.create_as_seconds()                                # now in seconds
EpochTimestamp.create_as_milliseconds(1734567890, inp=Precision.SECONDS)
EpochTimestamp.create_as_microseconds()
EpochTimestamp.create_as_nanoseconds()

# Also available as module-level helpers
from bear_epoch_time import to_seconds, to_milliseconds, to_microseconds, to_nanoseconds
ts = to_seconds(1.5, inp=Precision.SECONDS)   # 1.5s -> stored as 1 (seconds precision)
```

### Converting between precisions

```python
from bear_epoch_time import EpochTimestamp, Precision

ts = EpochTimestamp.now(p=Precision.MILLISECONDS)

# Build a new instance at a different precision
ts_ns = EpochTimestamp.create(v=int(ts), inp=Precision.MILLISECONDS, outp=Precision.NANOSECONDS)

# Or just the numeric conversion, returned as (value, precision, ns_value)
value, p, ns = EpochTimestamp.to_precision(v=1500, inp=Precision.MILLISECONDS, outp=Precision.SECONDS)
# value == 1, p == Precision.SECONDS, ns == 1_500_000_000

value, p, ns = EpochTimestamp.to_precision(
    v=1500, inp=Precision.MILLISECONDS, outp=Precision.SECONDS, as_float=True
)
# value == 1.5
```

## Time Arithmetic

### Adding and Subtracting

```python
from datetime import timedelta
from bear_epoch_time import EpochTimestamp

now = EpochTimestamp.now()

# Add time components (down to nanoseconds)
future = now.add(days=7, hours=3, minutes=30)
fine   = now.add(microseconds=500, nanoseconds=250)

# Subtract
past = now.subtract(days=1, hours=12)

# Use a timedelta
two_weeks_later = now.add(delta=timedelta(weeks=2))

# Negative values work for subtraction-via-add
yesterday = now.add(days=-1)
```

### Differences

```python
from bear_epoch_time import EpochTimestamp, Precision

ts1 = EpochTimestamp.now()
ts2 = ts1.add(days=5, hours=3)

# diff() returns an int in the requested precision (defaults to ms)
diff_ms = ts1.diff(ts2)
diff_s  = ts1.diff(ts2, precision=Precision.SECONDS)

# Or get a timedelta
delta = ts1.diff(ts2, as_timedelta=True)

# time_since() returns a float in human-friendly units
days    = ts1.time_since(ts2, unit="d")
hours   = ts1.time_since(ts2, unit="h")
minutes = ts1.time_since(ts2, unit="m")

# since() returns the absolute difference as another EpochTimestamp
delta_ts = ts2.since(ts1, precision=Precision.MILLISECONDS)
```

## Day Boundaries

```python
from zoneinfo import ZoneInfo
from bear_epoch_time import EpochTimestamp

now = EpochTimestamp.now()

# Start/end of day in UTC
start = now.start_of_day()
end   = now.end_of_day()

# In a specific timezone
pacific = ZoneInfo("America/Los_Angeles")
start_pacific = now.start_of_day(tz=pacific)
end_pacific   = now.end_of_day(tz=pacific)
```

## String Formatting

```python
from bear_epoch_time import EpochTimestamp

ts = EpochTimestamp.now()

# Default formatted strings
ts.to_string()  # "12-18-2025 03:30 PM PST"
ts.date_str()   # "12-18-2025"
ts.time_str()   # "03:30 PM"

# Custom strftime format
ts.to_string(fmt="%A, %B %d, %Y")  # "Thursday, December 18, 2025"

# Ordinal day (custom %Do code)
ts.to_string(fmt="%B %Do, %Y")     # "December 18th, 2025"

# ISO format
ts.to_iso                    # "2025-12-18T23:30:00+00:00"
ts.get_iso_string(sep=" ")   # "2025-12-18 23:30:00+00:00"

# Template formatting with $variables
ts.format("$month_name $day, $year")  # "December 18, 2025"
ts.format("$day_name at $time")       # "Thursday at 03:30 PM"
```

**Template variables:** `$epoch`, `$seconds`, `$milliseconds`, `$iso`, `$date`, `$time`, `$datetime`, `$year`, `$month`, `$day`, `$hour`, `$minute`, `$week`, `$isoweekday`, `$day_of_week`, `$day_of_year`, `$month_name`, `$day_name`

## Accessing Components

```python
from bear_epoch_time import EpochTimestamp

ts = EpochTimestamp.now()

# Date components
ts.year          # 2025
ts.month         # 12
ts.day           # 18
ts.week          # ISO week number (1-53)

# Time components
ts.hour          # 15
ts.minute        # 30
ts.second        # 45
ts.millisecond   # 0-999
ts.microsecond   # 0-999999

# Day information
ts.day_of_week   # 0-6 (Monday=0)
ts.iso_weekday   # 1-7 (Monday=1)
ts.day_of_year   # 1-366
ts.day_name      # "Thursday"
ts.month_name    # "December"

# Conversions (all cached on first access)
ts.to_datetime       # datetime (UTC)
ts.to_seconds        # int (epoch seconds)
ts.to_milliseconds   # int (epoch ms)
ts.microseconds      # int (epoch μs)
ts.ns_value          # int (epoch ns — the internal currency)
ts.date              # date
ts.time              # time
ts.to_iso            # ISO 8601 string
ts.to_duration       # "675M 28d 2h 12m 28s" (human-readable)
```

For fractional output, call the method forms:

```python
ts.seconds(as_float=True)        # 1734567890.123456
ts.ms(as_float=True)
ts.to_microseconds(as_float=True)
```

## Replacing Components

```python
from bear_epoch_time import EpochTimestamp

ts = EpochTimestamp.now()

new_year      = ts.replace(year=2026)
noon          = ts.replace(hour=12, minute=0, second=0)
first_of_mo   = ts.replace(day=1)
```

## Finding Future Times

```python
from bear_epoch_time import EpochTimestamp

now = EpochTimestamp.now()

# Next occurrence of a weekday (case-insensitive)
next_monday = now.next_weekday("Monday")
next_friday = now.next_weekday("friday")

# Next occurrence of a specific time
next_9am     = now.next_time_of_day(hour=9)
next_meeting = now.next_time_of_day(hour=14, minute=30)
```

## Comparisons

`EpochTimestamp` compares cleanly against `EpochTimestamp`, `datetime`, `date`, `time`, `int`, and `float`:

```python
from datetime import datetime, date
from bear_epoch_time import EpochTimestamp

ts = EpochTimestamp.now()

ts < datetime.now()
ts == date.today()           # compares the date portion only
ts > 1734567890000           # raw int comparison
bool(ts)                     # False only for the zero placeholder
```

Comparisons between two `EpochTimestamp` instances are **instant-based** — they
compare the underlying nanosecond value, so the same moment is equal regardless of
the precision each was created at:

```python
from bear_epoch_time import EpochTimestamp, Precision

a = EpochTimestamp.from_seconds(1, precision=Precision.SECONDS)       # stored as 1
b = EpochTimestamp.from_seconds(1, precision=Precision.NANOSECONDS)   # stored as 1_000_000_000

a == b           # True — same instant
hash(a) == hash(b)  # True — hashing is also instant-based
```

A bare `int` still compares against the raw stored value (the int-subclass behavior),
so `EpochTimestamp(1500, Precision.MILLISECONDS) == 1500`.

## Serialization

Every timestamp round-trips losslessly through its `(ns_value, precision, True)` tuple —
the nanosecond value preserves the exact instant, and the precision preserves how it
should be viewed. That single tuple form backs pickling, compact byte packing, and
Pydantic support.

```python
from bear_epoch_time import EpochTimestamp, Precision

ts = EpochTimestamp.now(p=Precision.MICROSECONDS)

# Canonical tuple form
ts.to_tuple()                # (1779860420000000, MICROSECONDS, True)

# Compact 10-byte struct packing (uint64 ns, uint8 precision, bool flag)
blob = ts.serialize()        # b'...'
EpochTimestamp.deserialize(blob) == ts   # True

# Pickle works out of the box (via __reduce__)
import pickle
pickle.loads(pickle.dumps(ts)) == ts     # True
```

### Pydantic

`EpochTimestamp` provides a Pydantic v2 core schema, so it works as a model field with
no extra config. It accepts an existing `EpochTimestamp`, a bare `int`, or the serialized
tuple, and dumps to the `(ns_value, precision, True)` form:

```python
from pydantic import BaseModel, Field
from bear_epoch_time import EpochTimestamp

class Event(BaseModel):
    created: EpochTimestamp = Field(default_factory=EpochTimestamp.now)

event = Event(created=EpochTimestamp.now())
dumped = event.model_dump_json()
restored = Event.model_validate_json(dumped)
restored.created == event.created   # True — instant and precision preserved
```

> Pydantic is an optional dependency; the schema hook only activates when Pydantic calls it.

## Placeholder Values

Zero acts as a "no timestamp set" sentinel:

```python
from bear_epoch_time import EpochTimestamp

placeholder = EpochTimestamp(0)

if placeholder.is_default:
    print("No timestamp set")

if not placeholder:
    print("Same idea via bool")
```

## Class Configuration

Customize global defaults that every `EpochTimestamp` instance picks up:

```python
from zoneinfo import ZoneInfo
from bear_epoch_time import EpochTimestamp

EpochTimestamp.set_timezone(ZoneInfo("America/New_York"))
EpochTimestamp.set_date_format("%Y-%m-%d")
EpochTimestamp.set_time_format("%H:%M:%S")
EpochTimestamp.set_full_format("%Y-%m-%d %H:%M:%S %Z")

# repr style: "int" (default), "object", or "datetime"
EpochTimestamp.set_repr_style("datetime")
```

## Module-Level Conveniences

For the most common one-liners, the top-level package exposes a few helpers:

```python
from bear_epoch_time import now, day, month, Precision

now()                              # EpochTimestamp at default precision
now(p=Precision.SECONDS)            # ...at a different precision

day()                              # current weekday as int (0=Monday .. 6=Sunday)
day(as_str=True)                   # "wednesday"

month()                            # current month as int
month(as_str=True)                 # "May"
```

## TimeTools Helper

For instance-bound, timezone-aware operations:

```python
from bear_epoch_time import TimeTools

tools = TimeTools()  # defaults to the local timezone

start, end = tools.get_day_range()              # today's bounds as EpochTimestamps
tools.is_same_day(ts1, ts2)
tools.is_multi_day("12-18-2025 11:00 PM", "12-19-2025 01:00 AM")

# Quick conversions
tools.datetime_to_timestamp(datetime_obj)
tools.string_to_timestamp("12-18-2025 03:30 PM")
tools.timestamp_to_string(ts)
tools.timestamp_to_datetime(ts)
```

## TimeConverter Utilities

Parse and format duration strings:

```python
from bear_epoch_time import TimeConverter

TimeConverter.parse_to_seconds("2d 3h 15m")     # 183300.0
TimeConverter.parse_to_seconds("1M 5d")         # ~35 days in seconds

TimeConverter.format_seconds(183300)                          # "2d 3h 15m"
TimeConverter.format_seconds(90061.5, show_subseconds=True)   # "1d 1h 1m 1s 500ms"
```

The free-function equivalents (`parse_to_seconds`, `parse_to_milliseconds`, `format_seconds`, `format_milliseconds`, `format_since`, `time_since`, `from_timedelta`, `to_timedelta`, `delta_to_ms`) are also available at the top level.

## Timer Utilities

Context managers and decorators for high-resolution timing, backed by `perf_counter_ns()`:

```python
from bear_epoch_time import timer, create_timer, Precision

# Basic context manager — defaults to recording in milliseconds
with timer(name="my_operation", console=print) as t:
    do_work()
print(f"Took {t.seconds:.2f}s")

# Pick the recording precision
with timer(name="hot_path", console=print, precision=Precision.NANOSECONDS) as t:
    do_hot_thing()
# t.nanoseconds, t.microseconds, t.milliseconds, t.seconds all available

# Decorator factory
@create_timer(console=print, precision=Precision.MICROSECONDS)
def my_function():
    ...
```

### Auto display precision

`display_precision="auto"` keeps your **recording** precision high while picking the most human-readable **display** unit at format time. Great for log lines where the same timer might measure microseconds *or* multi-second jobs:

```python
from bear_epoch_time import timer, Precision

with timer(name="any", console=print, precision=Precision.NANOSECONDS, display_precision="auto") as t:
    do_thing()

# Outputs the most readable unit for the actual duration:
#   <any> Elapsed time:   2.125000 microsecond   (fast)
#   <any> Elapsed time: 505.044667 millisecond   (medium)
#   <any> Elapsed time:   1.250000 second        (slow)
```

Or pin the display unit explicitly:

```python
with timer(precision="ns", display_precision="ms", console=print):
    ...
```

### Async timers

```python
from bear_epoch_time import async_timer, create_async_timer

async with async_timer(name="async_op", console=print) as t:
    await some_async_work()

@create_async_timer(console=print)
async def my_async_function():
    ...
```

## Limitations

`EpochTimestamp` is designed for UTC timestamps in the recent past and future. It is **not** intended for:

- Historical dates before the Unix epoch (1970-01-01)
- The kind of high-precision interval timing that wall-clock-based epochs can't guarantee — use the `Timer` helpers (which use `perf_counter_ns()`) for that
- Dates far enough in the future that leap-second handling becomes significant

This is a utility library for working with epoch timestamps in a more human-friendly way.
