Metadata-Version: 2.4
Name: tals
Version: 0.2.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

![logo1](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 k now which attribute or key holds the timestamp.

---

Filtering a list of objects by time period is something every Python project does — 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

Two new bound types extend standard integer indexing:

- **Time-delta bounds** — relative to a reference datetime: `-7d`, `+2h`, `-30m`
- **Calendar-period bounds** — snapped to period boundaries: `0M`, `-1W`, `0Y`

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,
    week_start: int = 0,
    presorted: bool = False,
    inclusive_end: bool = False,
) -> 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` | Anchor for all relative expressions — never inferred from the system clock |
| `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`) |

**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` | Time-delta — relative to `reference_dt` | `[-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 |

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

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

### 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]
```

## 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
```

### 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`. 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
