Metadata-Version: 2.5
Name: tals
Version: 0.4.0
Summary: Time-aware list slicing with datetime and calendar-period bounds
Project-URL: Repository, https://gitlab.com/mrtmednis/tals
License: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: python-dateutil>=2.8
Description-Content-Type: text/markdown

# tals — Time-Aware List Slicer

![tals logo](https://gitlab.com/mrtmednis/tals/-/raw/main/logo1_sm.png)

`tals` makes time-based slicing as natural as ordinary indexing. It extends Python's `[start:end]` syntax with datetime and calendar-period bounds, and works on any list of objects or dicts - it only needs to know which attribute or key holds the timestamp.

---

Filtering a list of objects by time period is something many Python projects do - and it never gets less tedious:

```python
# Without tals
now = datetime(2026, 3, 10)
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = (start + relativedelta(months=1))
february = [e for e in entries if start <= e.created_at < end]
```

`tals` collapses this to a single expressive call:

```python
# With tals
slice_objects(entries, "-1M:0M", "created_at", now)
```

It extends Python's familiar `[start:end]` syntax with datetime and calendar-period bounds. Any list, any object - it only needs to know which attribute or key holds the timestamp.

```python
from tals import slice_objects

slice_objects(entries, "-7d:",    "created_at", now)  # last 7 days
slice_objects(entries, "0M:+1M", "created_at", now)  # this calendar month
slice_objects(entries, "-1W:0W", "created_at", now)  # last full week
slice_objects(entries, "0Y:",    "created_at", now)  # since Jan 1
```

## Installation

```
pip install tals
```

## Overview

Three new bound types extend standard integer indexing:

- **Time-delta bounds** — relative to a reference datetime: `-7d`, `+2h`, `-30m`, `+1w`, `-45s`
- **Calendar-period bounds** — snapped to period boundaries: `0M`, `-1W`, `0Y`
- **Literal timestamp bounds** — an absolute ISO-8601 point in time: `2026-01-01`, `2026-01-01T14:30:00+02:00`

Bounds can be freely mixed: a temporal start can pair with an integer end, or vice versa. The library never calls `datetime.now()` — you supply the reference, so behaviour is always deterministic and testable.

## API

```python
def slice_objects(
    objects: list[Any],
    position: str,
    timestamp_key: str,
    reference_dt: datetime | Mapping[str, datetime],
    week_start: int = 0,
    presorted: bool = False,
    inclusive_end: bool = False,
    default_anchor: str | None = None,
) -> Any | list[Any]:
```

| Parameter | Description |
|-----------|-------------|
| `objects` | List of objects or dicts in any order |
| `position` | Slice expression string (see syntax below) |
| `timestamp_key` | Attribute name (objects) or key (dicts) holding the `datetime` value |
| `reference_dt` | Reference for all relative expressions — never inferred from the system clock. A single `datetime`, or a mapping of anchor name → `datetime` (see [Named anchors](#named-anchors)) |
| `week_start` | First day of the week: `0` = Monday (default), `6` = Sunday |
| `presorted` | Skip sorting if the list is already in ascending timestamp order |
| `inclusive_end` | Make the end bound inclusive (default `False`) |
| `default_anchor` | With a mapping `reference_dt`, the anchor used by bounds without a `name@` prefix |

**Return value:** a single-index expression returns one object (or `None` if out of bounds); a slice expression returns a list (possibly empty).

The list is stable-sorted ascending by `timestamp_key` before slicing. All indices operate on this sorted list.

## Syntax

```
[index]
[start:end]
[start:]
[:end]
[:]
```

### Bound types

| Form | Description | Example |
|------|-------------|---------|
| `0`, `1`, `-1` | Integer index — same semantics as Python | `[-1]` → last object |
| `-7d`, `+2h`, `-30m`, `+1w`, `-45s` | Time-delta (`s`, `m`, `h`, `d`, `w`) — relative to `reference_dt`; a sign is required. `w` is exactly 7 days, not calendar-snapped | `[-7d:]` → last 7 days |
| `0M`, `-1M`, `+1M` | Calendar month — start of the Nth month | `[0M:+1M]` → this month exactly |
| `0W`, `-1W`, `+1W` | Calendar week — start of the Nth week | `[-1W:0W]` → last week exactly |
| `0Y`, `-1Y`, `+1Y` | Calendar year — Jan 1 of the Nth year | `[0Y:+1Y]` → this year exactly |
| `2026-01-01`, `2026-01-01T14:30`, `2026-01-01T14:30:00+02:00` | Literal timestamp — any ISO-8601 date or datetime, accepted by `datetime.fromisoformat` | `[2026-01-01:2026-02-01]` → January exactly |

Lowercase `w` is a plain 7-day delta; uppercase `W` snaps to a calendar week boundary (Monday by default, see `week_start`).

Period `0` is the period containing `reference_dt`; `-1` is the previous period; `+1` is the next.

The sign on a calendar bound is optional: `1M` is the same as `+1M`, and `-0M` is the same as `0M` (zero has no direction). Note that time-delta bounds, unlike calendar bounds, always require a sign.

The sign matters for non-zero offsets, since it picks the direction. With `reference_dt` in March:

| Expression | Start of slice |
|------------|----------------|
| `[-1M:]` | Feb 1 — the start of the *previous* month, so February onward |
| `[1M:]` | Apr 1 — the start of the *next* month, so April onward (excludes all of March) |

Calendar and literal-timestamp bounds are not valid as a single index — they only make sense in a slice.

A literal timestamp with no offset (`2026-01-01T14:30`) is naive; if `reference_dt` is timezone-aware, it inherits `reference_dt`'s timezone, the same way calendar boundaries are computed in `reference_dt`'s timezone. A literal timestamp with an explicit offset or `Z` is aware and used as given — it does not need to match `reference_dt`'s offset, since Python compares differing aware timezones correctly.

### Named anchors

Every relative bound normally resolves against a single `reference_dt`, which
works for "relative to now" but not when the two ends of a slice are measured
from different points in time, for example:

- "from 2 days after the first entry until 1 week before now"
- "from 3 days before Easter until 1 week after Christmas"

Named anchors solve this: you supply several reference datetimes under names,
and each bound picks the one it is measured from. Pass a mapping as
`reference_dt` and tag bounds with `name@`:

```python
slice_objects(
    entries,
    "easter@-3d:christmas@+1w",
    "created_at",
    reference_dt={"easter": easter_2026, "christmas": christmas_2026},
)
# → from 3 days before Easter up to (excluding) 1 week after Christmas
```

Bounds without a prefix resolve against `default_anchor`:

```python
slice_objects(
    entries,
    "+2d:now@-1w",
    "created_at",
    reference_dt={"first_entry": first_entry_dt, "now": now},
    default_anchor="first_entry",
)
# → from 2 days after the first entry until 1 week before now
```

- Anchors are only valid on time-delta and calendar bounds (`now@-7d`, `now@0M`) — not on integer indices or literal timestamps.
- A mapping with an untagged bound and no `default_anchor`, or an unknown anchor name, raises `ValueError` (the message lists the available anchors). Literal timestamps count as untagged: a naive one takes its timezone from `default_anchor`.
- A bare `datetime` works exactly as before and needs no `default_anchor`.
- tals has no calendar or holiday knowledge: you compute `easter_2026` etc. yourself (e.g. with `dateutil.easter`) and pass ordinary datetimes.

### Inclusivity

By default, bounds follow Python's convention: **start is inclusive, end is exclusive**.

| Expression | Meaning |
|------------|---------|
| `[-7d:]` | `timestamp >= reference_dt − 7 days` |
| `[:-7d]` | `timestamp < reference_dt − 7 days` |
| `[-1M:0M]` | `timestamp >= start of last month` and `< start of this month` |

Pass `inclusive_end=True` to include the end boundary:

| Expression | Default (`inclusive_end=False`) | With `inclusive_end=True` |
|------------|---------------------------------|---------------------------|
| `[-1M:0M]` | up to but not including Mar 1 | up to and including Mar 1 |
| `[-7d:-1d]` | up to but not including the -1d mark | up to and including the -1d mark |
| `[0:2]` | indices 0 and 1 | indices 0, 1, and 2 |
| `[:-1]` | all but the last | all items |

### Mixed bounds

A slice can mix bound types. Temporal bounds are resolved to datetime thresholds first, the list is filtered, then integer bounds are applied to the filtered result.

```python
# March entries, excluding the last one
slice_objects(entries, "0M:-1", "created_at", reference_dt)
# → filter to [Mar 1, Apr 1), then apply [:-1]

# From 7 days ago, keep only the first three
slice_objects(entries, "-7d:3", "created_at", reference_dt)
# → filter to [now - 7d, …), then apply [:3]

# Between two known absolute timestamps
slice_objects(entries, "2026-01-01:2026-02-01", "created_at", reference_dt)
# → filter to [Jan 1, Feb 1)
```

## Examples

Given four objects with a `start` attribute and `reference_dt = 2026-03-10`:

| Object | `start` |
|--------|---------|
| A | 2026-01-10 |
| B | 2026-02-05 |
| C | 2026-03-01 |
| D | 2026-03-10 |

```python
slice_objects(objs, "[-1]",      "start", ref)  # → D
slice_objects(objs, "[0]",       "start", ref)  # → A
slice_objects(objs, "[:]",       "start", ref)  # → [A, B, C, D]
slice_objects(objs, "[:-1]",     "start", ref)  # → [A, B, C]
slice_objects(objs, "[0M:]",     "start", ref)  # → [C, D]          March onward
slice_objects(objs, "[0M:+1M]",  "start", ref)  # → [C, D]          March exactly
slice_objects(objs, "[-1M:0M]",  "start", ref)  # → [B]             February exactly
slice_objects(objs, "[-1M:]",    "start", ref)  # → [B, C, D]       Feb 1 onward
slice_objects(objs, "[0Y:+1Y]",  "start", ref)  # → [A, B, C, D]    2026 exactly
slice_objects(objs, "[0M:-1]",   "start", ref)  # → [C]             March, drop last
slice_objects(objs, "[2026-02-01:2026-03-05]", "start", ref)  # → [B, C]  Feb 1 – Mar 5
```

### Inclusive end

Add `inclusive_end=True` when items exactly on the boundary should be included.
A common case is querying a closed interval between two known timestamps:

```python
# Events from Feb 5 through Mar 1 inclusive — useful when Mar 1 is a known event
slice_objects(objs, "-1M:0M", "start", ref)                       # → [B]       Mar 1 excluded
slice_objects(objs, "-1M:0M", "start", ref, inclusive_end=True)   # → [B, C]    Mar 1 included
```

It applies equally to integer ends, where it behaves like Ruby-style range slicing:

```python
slice_objects(objs, "0:2",  "start", ref)                        # → [A, B]     index 2 excluded
slice_objects(objs, "0:2",  "start", ref, inclusive_end=True)    # → [A, B, C]  index 2 included

slice_objects(objs, ":-1",  "start", ref)                        # → [A, B, C]  last excluded
slice_objects(objs, ":-1",  "start", ref, inclusive_end=True)    # → [A, B, C, D]  last included
```

## Timezone handling

`reference_dt` and all object timestamps must be either all timezone-aware or all timezone-naive — mixing the two raises `TypeError`. With named anchors, only the anchors a position actually references are checked. Objects with different (but both aware) timezones are compared correctly by Python and are fully supported.

Calendar boundaries are computed in the timezone of `reference_dt`, so `0M` on a `UTC+02:00` reference resolves to midnight of the 1st in that timezone.

## Out of scope

`tals` is a pure slicing primitive. The following are the caller's responsibility:

- **Pre-filtering** — pass only the subset of objects that should be considered
- **Field extraction** — read attributes from the returned objects
- **Fallback values** — convert `None` or `[]` to domain-specific defaults

## License

MIT
