Skip to content

Commit

Permalink
Updated decorator implementation with parameter for default ID genera…
Browse files Browse the repository at this point in the history
…tion function.
  • Loading branch information
stasdavydov committed Jul 11, 2024
1 parent c514649 commit 6486522
Show file tree
Hide file tree
Showing 5 changed files with 89 additions and 83 deletions.
45 changes: 24 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
Simple fast JSON file storage for Python dataclasses and Pydantic models, thread and multiprocess safe.

----
It's standard to use SQL or NoSQL database servers as data backend, but sometimes it's more
convenient to have data persisted as file(s) locally on backend application side. If you still
need to use SQL for data retrieval the best option is SQLite, but for simple REST APIs it
could be better to work with objects as is. So here we go.

## Installation
```shell
pip install pysdato
```

## Usage
The library is intended to store Python dataclasses or Pydantic models as JSON-files referenced by ID
The library is intended to store Python `dataclasses`, `msqspec.Struct` or Pydantic models as JSON-files referenced by ID
and supports object hierarchy.

Let's say we have `Author` model. Object's ID is key point for persistence -- it will be used as name of
Expand All @@ -23,7 +27,7 @@ from dataclasses import dataclass
import pys

# Initialize storage with path where files will be saved
storage = pys.storage('.storage')
storage = pys.storage('storage.db')

@pys.saveable
@dataclass
Expand Down Expand Up @@ -59,7 +63,7 @@ class Author(BaseModel):
class Book(BaseModel):
title: str

storage = pys.storage('.storage')
storage = pys.storage('storage.db')

# A few books of Leo Tolstoy
leo = Author(name='Leo Tolstoy')
Expand Down Expand Up @@ -98,7 +102,7 @@ class Author(BaseModel):
class Book(BaseModel):
title: str

storage = pys.storage('.storage')
storage = pys.storage('storage.db')

# A few books of Leo Tolstoy
leo = Author(name='Leo Tolstoy')
Expand All @@ -115,7 +119,14 @@ assert war_and_peace in leo_books
assert for_kids in leo_books
```

## Reference
## Storages
Library supports two storages implementation:
- `sqlite_storage()` - SQLite based -- really fast, uses one file for all objects.
- `file_storage()` - JSON file per object storage, it is slower, but saves each object in a separate JSON file.

The default storage is SQLite based.

## Library Reference
```python
import pys

Expand Down Expand Up @@ -144,20 +155,12 @@ storage.destroy()

## Release Notes

### 0.0.5
Performance is dramatically improved with SQLite storage implementation.
- **0.0.6** `saveable` decorator reworked, added `default_id` parameter that can be used for
changing ID generation behaviour. By default, we use `str(id(self))` as ID (and `str(uuid.uuid4())`
for `pys.pydantic.ModelWithID`), but it can be changed now.
- **0.0.5** Performance is dramatically improved with SQLite storage implementation.
Default storage is SQLite storage now.

### 0.0.4
SQLite storage is added.
Support of `msqspec` JSON and structures is added.

### 0.0.3
Benchmark is added, performance is improved.
Fixed dependency set up.

### 0.0.2
Added support for Python 3.x < 3.10

### 0.0.1
Initial public release
- **0.0.4** SQLite storage is added. Support of `msqspec` JSON and structures is added.
- **0.0.3** Benchmark is added, performance is improved. Fixed dependency set up.
- **0.0.2** Added support for Python 3.x < 3.10
- **0.0.1** Initial public release
15 changes: 7 additions & 8 deletions benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,12 @@ class Book(msgspec.Struct):
# T4: 1.25 sec

# SQLite storage is added
# Storage: file.Storage(base_path=benchmark.storage)
# T1: 651.90 ms
# T2: 266.05 ms
# T3: 1428.29 ms
# T4: 1234.20 ms
# T1: 656.80 ms
# T2: 264.35 ms
# T3: 1368.96 ms
# T4: 1182.48 ms
# Storage: sqlite.Storage(base_path=benchmark.db)
# T1: 19.98 ms
# T2: 0.00 ms
# T3: 7.00 ms
# T1: 20.00 ms
# T2: 1.00 ms
# T3: 6.51 ms
# T4: 1.00 ms
91 changes: 54 additions & 37 deletions pys/__init__.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,67 @@
import uuid
import functools
from dataclasses import is_dataclass, asdict
from pathlib import Path
from typing import Union
from typing import Union, Callable, Any

import msgspec

from .base import is_pydantic
from . import file, sqlite


def saveable(cls=None, /, field_as_id: str = 'id'):
def wrapper(cls):
def my_id_or_default(self):
if hasattr(self, field_as_id):
if getattr(self, field_as_id, None) is None:
setattr(self, field_as_id, str(uuid.uuid4()))
return getattr(self, field_as_id)
else:
return str(id(self))

setattr(cls, '__my_id__', my_id_or_default)
if not hasattr(cls, '__json__'):
if issubclass(cls, msgspec.Struct):
setattr(cls, '__json__',
lambda self:
msgspec.json.encode(self).decode(encoding='UTF-8')
)
elif is_dataclass(cls):
setattr(cls, '__json__',
lambda self:
msgspec.json.encode(
asdict(self),
).decode(encoding='UTF-8')
)
elif is_pydantic(cls):
setattr(cls, '__json__', lambda self: self.model_dump_json())
else:
raise NotImplementedError(
f'The class {cls} is not @dataclass nor Pydantic Model and does not have __json__() method.'
f'Please implement __json__() method by yourself.')
return cls

if cls is None:
return wrapper
def saveable(cls=None, *,
field_as_id: str = 'id',
default_id: Callable[[Any], str] = lambda self: str(id(self))):
"""
Decorate the given `cls` with `__my_id__()` and `__json__()` methods
required for persistence.
:param cls: Class to decorate.
:param field_as_id: existing class field to be used as object ID.
:param default_id: Default ID value function (id(self) by default).
:return: Decorated class
"""
if cls:
@functools.wraps(cls, updated=())
class _Persisted(cls):
def __my_id__(self):
"""
Get object ID to be used for persisting. If `field_as_id` is specified
then use this field as ID, otherwise use `id()`
:return: Object's ID
"""
if field_as_id and hasattr(self, field_as_id):
if not getattr(self, field_as_id, None):
setattr(self, field_as_id, default_id(self))
_id = getattr(self, field_as_id)
else:
_id = default_id(self)
if not _id:
raise ValueError('ID shall not be empty')
return _id

def __json__(self):
"""
Get JSON representation of the object
:return: JSON representation
"""
if issubclass(cls, msgspec.Struct):
return msgspec.json.encode(self).decode(encoding='UTF-8')
elif is_dataclass(cls):
return msgspec.json.encode(asdict(self)).decode(encoding='UTF-8')
elif is_pydantic(cls):
return self.model_dump_json()
elif hasattr(cls, '__json__'):
return cls.__json__(self)
else:
raise NotImplementedError(
f'The class {cls} is not msgspec.Struct, @dataclass nor Pydantic Model '
f'and does not have __json__() method. Please implement __json__() method by yourself.')

return _Persisted
else:
return wrapper(cls)
def wrapper(decor_cls):
return saveable(decor_cls, field_as_id=field_as_id)
return wrapper


def file_storage(base_path: Union[str, Path]):
Expand Down
19 changes: 3 additions & 16 deletions pys/pydantic.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
import uuid
from typing import Any

from pydantic import BaseModel

from . import saveable


@saveable(field_as_id='id', default_id=lambda _: str(uuid.uuid4()))
class ModelWithID(BaseModel):
"""
Base class for models with `id` field prefilled with random UUID if not initialized.
"""
id: str = None

def __init__(self, **data: Any) -> None:
"""
Initialize a model
:param data: model fields
"""
super().__init__(**data)
if self.id is None:
self.id = str(uuid.uuid4())

def __my_id__(self):
return self.id

def __json__(self):
return self.model_dump_json()
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
README = (HERE / "README.md").read_text()

setup(name='pysdato',
version='0.0.5',
version='0.0.6',
description='Simple JSON file storage for Python dataclasses and pydantic models, thread and multiprocess safe',
long_description=README,
long_description_content_type="text/markdown",
Expand Down

0 comments on commit 6486522

Please sign in to comment.