Metadata-Version: 2.4
Name: stele1-filesystem
Version: 0.3.7
Summary: Filesystem interface for stele1 climbing catalogs
Author-email: Daniel M <contact@steleclimbing.org>
License-Expression: MIT
Project-URL: Homepage, https://stele1.steleclimbing.org
Project-URL: Repository, https://codeberg.org/stele-climbing/stele1
Project-URL: Issues, https://codeberg.org/stele-climbing/stele1/issues
Keywords: stele1,climbing,guidebook
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Development Status :: 4 - Beta
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: stele1-datatypes<0.4.0,>=0.3.0
Dynamic: license-file

# stele1-filesystem

Read and write [stele1](https://stele1.steleclimbing.org) catalogs on disk.

A catalog is a directory whose layout is defined by the
[stele1 spec](https://stele1.steleclimbing.org/spec/):
one JSON file per record at `{type}/{uuid}.json`,
photo images at `photo/{uuid}_image.{ext}`,
catalog metadata at `stele1.json`.
Your data is never held hostage by this library;
the worst-case recovery tool is `ls` and a text editor.

This package provides two classes with the same verbs:

- `Catalog` speaks validated stele1 datatypes
  (`Climb`, `Area`, `Photo`, `Trail`, `Parking`, `Metadata`).
  It is the one most scripts want.
- `DictCatalog` speaks plain dicts,
  with no validation beyond well-formed JSON
  and filename/uuid agreement.
  It is for recovery, debugging,
  and bring-your-own-classes tooling.

## Use

```python
from uuid import uuid4
from stele1.filesystem import Catalog
from stele1.climb import Climb
from stele1.area import Area

catalog = Catalog('hueco.stele1.d')
if not catalog.exists():
    catalog.create()

area = Area(uuid4())
area.name = 'The Big Time'
area.location = {
    'type': 'approximation',
    'circle': {'latitude': 31.9222, 'longitude': -106.0434,
               'meter_radius': 40},
}
catalog.set_area(area)

climb = Climb(uuid4())
climb.name = 'See Spot Run'
climb.rating = {'difficulty': 'V6',
                'protection': 'many pads and a good spotter'}
climb.length = {'lower_estimate': 7, 'upper_estimate': 8, 'unit': 'meters'}
climb.location = {'latitude': 31.9222, 'longitude': -106.0434,
                  'meter_radius': 15}
climb.tags = ['highball', 'classic']
catalog.set_climb(climb)
```

Nothing persists until a `set_*` call;
records are just objects.
Reading gives the same objects back:

```python
spot = catalog.get_climb(climb.uuid)
spot.name                        # 'See Spot Run'
spot.notes = [{'topic': 'crux',
               'content': 'the hard moves are low; the top is easier'}]
catalog.set_climb(spot)
```

The core loop is read, mutate, write back:

```python
for c in catalog.each_climb():
    if c.tags and 'highball' in c.tags:
        c.fields = {**(c.fields or {}), 'bring': 'many pads'}
        catalog.set_climb(c)
```

Every record type has the same five verbs:

```python
catalog.climb_uuids()            # iterate uuids on disk
catalog.get_climb(uuid)          # -> Climb; KeyError if absent
catalog.set_climb(climb)         # validate and write
catalog.each_climb(on_error=cb)  # yield every valid Climb
catalog.purge_climb(uuid)        # remove, scrubbing photo references
```

`area` mirrors `climb`, including `purge_area`.
`photo`, `trail`, and `parking` have plain `drop_*` instead of `purge_*`:
nothing references them, so there is nothing to purge.

## Errors

Invalid records raise `TypeError` or `ValueError`
at the read or write that touched them,
naming the problem:

```python
climb.length = {'lower_estimate': 9, 'upper_estimate': 7, 'unit': 'meters'}
# ValueError: climb .length lower_estimate must not exceed upper_estimate
```

`each_*` never dies on one bad file:
unreadable or invalid records are skipped,
and reported through `on_error(exception, uuid)` if given.

```python
def report(e, uuid):
    print(f'skipped {uuid}: {e}')

for c in catalog.each_climb(on_error=report):
    ...
```

## Removing records

Climbs and areas can be referenced by photo overlay layers,
so removing them safely means cleaning those references:

```python
catalog.purge_climb(climb.uuid)        # removes the climb AND scrubs
                                       # climb_layers from every photo
catalog.drop_climb_no_purge(uuid)      # removes only the record file,
                                       # leaving references dangling;
                                       # the name is long on purpose
```

Purges are idempotent and silent when the record is already gone.
They touch many files and are not atomic;
re-run to complete after an interruption.

## Photo images

The image itself is a file beside the photo record:

```python
from stele1.photo import Photo

photo = Photo(uuid4())
photo.capture_time = '2026-01'
catalog.set_photo(photo)

catalog.set_photo_image(photo.uuid, 'spot-west-face.jpg')   # from a path
catalog.set_photo_image(photo.uuid, image_bytes, 'jpg')     # from bytes
catalog.photo_image_path(photo.uuid)                        # or None
catalog.drop_photo(photo.uuid)         # removes record and image
```

## Broken catalogs

`Catalog` validates,
and some of its checks are deliberately stricter than the spec
(marked `beyond spec` in the source).
`DictCatalog` reads and writes the same directory as plain dicts,
so any well-formed catalog always loads:

```python
from stele1.filesystem import DictCatalog

raw = DictCatalog('hueco.stele1.d')

data = raw.get_climb(uuid)             # dict, no validation
data['length']['unit'] = 'meters'      # repair by hand
raw.set_climb(data)                    # canonical formatting back out

catalog.get_climb(uuid)                # now validates
```

Constructing a `DictCatalog` is the deliberate act;
`Catalog` offers no unvalidated access.

## Make it yours

Most projects grow their own conventions:
a tag taxonomy, required fields, derived values.
The blessed pattern is a thin wrapper, not a subclass:

```python
ALLOWED_TAGS = {'highball', 'classic', 'lowball', 'roof'}

class HuecoCatalog:
    def __init__(self, path):
        self._c = Catalog(path)

    def get_climb(self, uuid):
        climb = self._c.get_climb(uuid)
        self._check(climb)
        return climb

    def set_climb(self, climb):
        self._check(climb)
        self._c.set_climb(climb)

    def _check(self, climb):
        for tag in climb.tags or ():
            if tag not in ALLOWED_TAGS:
                raise ValueError(f'{climb.uuid}: unknown tag {tag!r}')

    def __getattr__(self, name):       # everything else passes through
        return getattr(self._c, name)
```

Wrappers can rely on:
records are self-contained (no back-reference to the catalog);
all typed reads funnel through `get_*`;
formatting is the catalog's job, never yours.
The documented exceptions: purges and `DictCatalog`
bypass the typed verbs by design.

## Formatting

All writes are canonical:
sorted keys, 2-space indent, UTF-8, trailing newline.
A catalog diffs cleanly under version control
regardless of which tool wrote it.
The formatter is exported for tooling
that writes catalog files without a catalog object:

```python
from stele1.filesystem import dumps_data

dumps_data({'uuid': '...', 'name': 'See Spot Run'})
```

## Install

```
pip install stele1-filesystem
```

Depends only on
[stele1-datatypes](https://pypi.org/project/stele1-datatypes/);
both are stdlib-only.

## Development

Source and issues live at
[codeberg.org/stele-climbing/stele1](https://codeberg.org/stele-climbing/stele1).

## License

MIT
