├── .flake8 ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── pypi.yml ├── .gitignore ├── .isort.cfg ├── LICENSE ├── README.md ├── detaorm ├── __init__.py ├── base.py ├── client.py ├── field.py ├── paginator.py ├── py.typed ├── query.py ├── types.py ├── undef.py └── update.py ├── mypy.ini ├── noxfile.py └── pyproject.toml /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend_ignore = E203 3 | exclude=.nox 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | linting: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.8 15 | - name: Install Nox 16 | run: pip install nox 17 | - name: Run Nox 18 | run: nox -s lint 19 | 20 | typechecking: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: 3.8 28 | - name: Install Nox 29 | run: pip install nox 30 | - name: Run Nox 31 | run: nox -s mypy 32 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: pypi 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Python 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: '3.8' 16 | - name: Install Dependencies 17 | run: pip install poetry 18 | - name: Build & Upload 19 | env: 20 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 21 | run: | 22 | poetry config pypi-token.pypi $PYPI_TOKEN 23 | poetry version $(git describe --tags --abbrev=0) 24 | poetry build 25 | poetry publish 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | line_length = 79 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 CircuitSacul 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DetaORM 2 | An async ORM for [DetaBase](https://docs.deta.sh/docs/base/about/). 3 | 4 | [Support](https://discord.gg/dGAzZDaTS9) | [PyPI](https://pypi.org/project/detaorm) | [Documentation](https://github.com/CircuitSacul/DetaORM/wiki) 5 | 6 | ## Example Usage 7 | ```py 8 | from __future__ import annotations 9 | 10 | import asyncio 11 | 12 | from detaorm import Client, Base, Field 13 | 14 | 15 | class User(Base, name="users"): 16 | username: Field[str] = Field() 17 | nicknames: Field[list[str]] = Field(default=[]) 18 | 19 | 20 | async def main() -> None: 21 | client = Client("", bases=[User]) 22 | await client.open() 23 | 24 | new_user = User(username="CircuitSacul") 25 | print(new_user) # > User({"username": "CircuitSacul"}) 26 | inserted_user = await new_user.insert() 27 | print(inserted_user) # > User({"username": "CircuitSacul", "nicknames": []}) 28 | 29 | updated_user = await inserted_user.update(User.nicknames.append(["Circuit", "Sacul"])) 30 | print(updated_user) # > User({"username": "CircuitSacul", "nicknames": ["Circuit", "Sacul"]}) 31 | 32 | page = await User.where(User.nicknames.contains("Sacul"), limit=1) 33 | print(page.items[0]) # > User({"username": "CircuitSacul", "nicknames": ["Circuit", "Sacul"]}) 34 | 35 | 36 | if __name__ == "__main__": 37 | asyncio.run(main()) 38 | ``` 39 | -------------------------------------------------------------------------------- /detaorm/__init__.py: -------------------------------------------------------------------------------- 1 | from detaorm.base import Base 2 | from detaorm.client import Client 3 | from detaorm.field import Field, NestedField 4 | from detaorm.query import And, Node, Or, Query 5 | 6 | __all__ = ( 7 | "Base", 8 | "Client", 9 | "Field", 10 | "And", 11 | "Node", 12 | "Or", 13 | "Query", 14 | "NestedField", 15 | ) 16 | -------------------------------------------------------------------------------- /detaorm/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from datetime import datetime, timedelta 5 | 6 | from detaorm.field import Field 7 | from detaorm.undef import UNDEF 8 | 9 | if t.TYPE_CHECKING: 10 | from detaorm.client import Client, RawPutItemsResponse 11 | from detaorm.paginator import RawPage, RawPaginator 12 | from detaorm.query import Node 13 | from detaorm.update import Update 14 | 15 | __all__ = ("Base", "PutItemsResponse") 16 | 17 | _BASE = t.TypeVar("_BASE", bound="Base") 18 | 19 | 20 | class PutItemsResponse(t.Generic[_BASE]): 21 | def __init__( 22 | self, response: RawPutItemsResponse, model: type[_BASE] 23 | ) -> None: 24 | self.model = model 25 | self.raw = response 26 | 27 | @property 28 | def processed(self) -> list[_BASE]: 29 | return [self.model(**d) for d in self.raw.processed] 30 | 31 | @property 32 | def failed(self) -> list[_BASE]: 33 | return [self.model(**d) for d in self.raw.failed] 34 | 35 | 36 | class Paginator(t.Generic[_BASE]): 37 | def __init__(self, raw: RawPaginator, model: type[_BASE]) -> None: 38 | self.model = model 39 | self.raw = raw 40 | 41 | def __await__(self) -> t.Generator[t.Any, None, Page[_BASE]]: 42 | async def await_page() -> Page[_BASE]: 43 | return Page(await self.raw, self.model) 44 | 45 | return await_page().__await__() 46 | 47 | def __aiter__(self) -> Paginator[_BASE]: 48 | return self 49 | 50 | async def __anext__(self) -> Page[_BASE]: 51 | return Page(await self.raw.__anext__(), self.model) 52 | 53 | 54 | class Page(t.Generic[_BASE]): 55 | def __init__(self, raw: RawPage, model: type[_BASE]) -> None: 56 | self.model = model 57 | self.raw = raw 58 | 59 | @property 60 | def items(self) -> list[_BASE]: 61 | return [self.model(**d) for d in self.raw.items] 62 | 63 | @property 64 | def size(self) -> int: 65 | return self.raw.size 66 | 67 | async def next(self, limit: int | None = None) -> Page[_BASE] | None: 68 | next = await self.raw.next(limit=limit) 69 | if next: 70 | return Page(next, self.model) 71 | return None 72 | 73 | 74 | class Base: 75 | """A Deta "Base", or a single model. 76 | 77 | Args: 78 | client (Client): The DetaORM Client to use. 79 | **values (object): Key-value pairs for this model. 80 | """ 81 | 82 | __base_name__: str 83 | """The name of this Base.""" 84 | _client: Client 85 | _defaults: dict[str, object] 86 | 87 | key: Field[str] = Field() 88 | 89 | def __init_subclass__(cls, name: str | None = None) -> None: 90 | cls.__base_name__ = name or cls.__name__.lower() 91 | cls._defaults = {} 92 | 93 | cls.key.name = "key" 94 | 95 | for key, value in cls.__dict__.items(): 96 | if isinstance(value, Field): 97 | value.name = key 98 | value.qual_name = key 99 | 100 | if value.default is not UNDEF: 101 | cls._defaults[key] = value.default 102 | 103 | def __init__(self, **values: object) -> None: 104 | self.raw = values 105 | 106 | def _with_defaults(self) -> dict[str, object]: 107 | dct = self._defaults.copy() 108 | dct.update(self.raw) 109 | return dct 110 | 111 | # model methods 112 | # these methods further abstract the methods on Base. 113 | async def delete(self) -> None: 114 | return await self.delete_item(self.key) 115 | 116 | async def insert( 117 | self: _BASE, 118 | *, 119 | expire_at: int | datetime | None = None, 120 | expire_in: int | timedelta | None = None, 121 | ) -> _BASE: 122 | return await self.insert_item( 123 | self, expire_at=expire_at, expire_in=expire_in 124 | ) 125 | 126 | async def update( 127 | self: _BASE, 128 | *updates: Update, 129 | set: dict[str, object] | None = None, 130 | increment: dict[str, int] | None = None, 131 | append: dict[str, list[object]] | None = None, 132 | prepend: dict[str, list[object]] | None = None, 133 | delete: list[str] | None = None, 134 | ) -> _BASE: 135 | ud = await self.update_item( 136 | self.key, 137 | *updates, 138 | set=set, 139 | increment=increment, 140 | append=append, 141 | prepend=prepend, 142 | delete=delete, 143 | ) 144 | new_self = type(self)() 145 | new_self.raw = self.raw 146 | 147 | def cd(obj: object) -> dict[str, object]: 148 | assert isinstance(obj, dict) 149 | assert all(isinstance(k, str) for k in obj.keys()) 150 | return obj 151 | 152 | def cl(obj: object) -> list[str]: 153 | assert isinstance(obj, list) 154 | assert all(isinstance(k, str) for k in obj) 155 | return obj 156 | 157 | def traverse( 158 | dct: dict[str, object], key: str 159 | ) -> tuple[dict[str, object], str]: 160 | keys = key.split(".") 161 | 162 | final_key = keys.pop(-1) 163 | final_dict = dct 164 | for k in keys: 165 | final_dict = final_dict.setdefault(k, {}) # type: ignore 166 | 167 | return final_dict, final_key 168 | 169 | for k, v in cd(ud["set"]).items(): 170 | dct, fk = traverse(new_self.raw, k) 171 | dct[fk] = v 172 | 173 | for k, v in cd(ud["increment"]).items(): 174 | dct, fk = traverse(new_self.raw, k) 175 | if fk not in dct: 176 | continue 177 | 178 | assert isinstance(v, int) 179 | orig = dct[fk] 180 | assert isinstance(orig, int) 181 | dct[fk] = orig + v 182 | 183 | for k, v in cd(ud["append"]).items(): 184 | dct, fk = traverse(new_self.raw, k) 185 | if fk not in dct: 186 | continue 187 | 188 | assert isinstance(v, list) 189 | orig = dct[fk] 190 | assert isinstance(orig, list) 191 | dct[fk] = orig + v 192 | 193 | for k, v in cd(ud["prepend"]).items(): 194 | dct, fk = traverse(new_self.raw, k) 195 | if fk not in dct: 196 | continue 197 | 198 | assert isinstance(v, list) 199 | orig = dct[fk] 200 | assert isinstance(orig, list) 201 | dct[fk] = v + orig 202 | 203 | for k in cl(ud["delete"]): 204 | dct, fk = traverse(new_self.raw, k) 205 | dct.pop(fk) 206 | 207 | return new_self 208 | 209 | # abstracted api methods 210 | # these methods abstract the methods on Client. 211 | @classmethod 212 | async def get(cls: type[_BASE], key: str) -> _BASE: 213 | return cls(**await cls._client.get_item(cls.__base_name__, key)) 214 | 215 | @classmethod 216 | def where( 217 | cls: type[_BASE], 218 | query: Node | t.Sequence[t.Mapping[str, object]] | None = None, 219 | limit: int = 0, 220 | last: str | None = None, 221 | ) -> Paginator[_BASE]: 222 | return Paginator( 223 | cls._client.query_items( 224 | cls.__base_name__, query=query, limit=limit, last=last 225 | ), 226 | cls, 227 | ) 228 | 229 | # these methods abstract the methods on Client, but are abstracted 230 | # further on this model. 231 | @classmethod 232 | async def insert_many( 233 | cls: type[_BASE], 234 | items: t.Sequence[_BASE], 235 | *, 236 | expire_at: int | datetime | None = None, 237 | expire_in: int | timedelta | None = None, 238 | ) -> PutItemsResponse[_BASE]: 239 | return PutItemsResponse( 240 | await cls._client.put_items( 241 | cls.__base_name__, 242 | [i._with_defaults() for i in items], 243 | expire_at=expire_at, 244 | expire_in=expire_in, 245 | ), 246 | cls, 247 | ) 248 | 249 | @classmethod 250 | async def delete_item(cls, key: str) -> None: 251 | await cls._client.delete_item(cls.__base_name__, key) 252 | 253 | @classmethod 254 | async def insert_item( 255 | cls: type[_BASE], 256 | item: _BASE, 257 | *, 258 | expire_at: int | datetime | None = None, 259 | expire_in: int | timedelta | None = None, 260 | ) -> _BASE: 261 | return cls( 262 | **await cls._client.insert_item( 263 | cls.__base_name__, 264 | item._with_defaults(), 265 | expire_at=expire_at, 266 | expire_in=expire_in, 267 | ) 268 | ) 269 | 270 | @classmethod 271 | async def update_item( 272 | cls, 273 | key: str, 274 | *updates: Update, 275 | set: dict[str, object] | None = None, 276 | increment: dict[str, int] | None = None, 277 | append: dict[str, list[object]] | None = None, 278 | prepend: dict[str, list[object]] | None = None, 279 | delete: list[str] | None = None, 280 | ) -> dict[str, object]: 281 | return await cls._client.update_item( 282 | cls.__base_name__, 283 | key, 284 | *updates, 285 | set=set, 286 | increment=increment, 287 | append=append, 288 | prepend=prepend, 289 | delete=delete, 290 | ) 291 | 292 | # magic 293 | def __repr__(self) -> str: 294 | return f"{type(self).__name__}({self.raw!r})" 295 | -------------------------------------------------------------------------------- /detaorm/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | import typing as t 6 | from datetime import datetime, timedelta, timezone 7 | 8 | import aiohttp 9 | 10 | from detaorm.paginator import RawPage, RawPaginator 11 | from detaorm.query import Node 12 | from detaorm.types import RAW_ITEM 13 | 14 | if t.TYPE_CHECKING: 15 | from detaorm.base import Base 16 | from detaorm.update import Update 17 | 18 | __all__ = ("Client", "RawPutItemsResponse") 19 | 20 | _LOG = logging.getLogger(__name__) 21 | 22 | 23 | def _with_ttl( 24 | dct: dict[str, object], 25 | expire_at: int | datetime | None, 26 | expire_in: int | timedelta | None, 27 | ) -> dict[str, object]: 28 | if expire_in and expire_at: 29 | raise ValueError("You cannot specify 'expire_at' and 'expire_in'.") 30 | 31 | if expire_in: 32 | if isinstance(expire_in, int): 33 | expire_in = timedelta(seconds=expire_in) 34 | 35 | expire_at = datetime.now(timezone.utc) + expire_in 36 | 37 | if isinstance(expire_at, datetime): 38 | expire_at = int(expire_at.timestamp()) 39 | 40 | if expire_at: 41 | dct = dct.copy() 42 | dct["__expires"] = expire_at 43 | 44 | return dct 45 | 46 | 47 | class RawPutItemsResponse: 48 | def __init__( 49 | self, processed: list[RAW_ITEM], failed: list[RAW_ITEM] 50 | ) -> None: 51 | self.processed = processed 52 | self.failed = failed 53 | 54 | 55 | class Client: 56 | """A client for communicating with the DetaBase API. 57 | 58 | Args: 59 | project_key: The project key. 60 | bases: All of the models that will be used. 61 | """ 62 | 63 | api_base_url = "https://database.deta.sh" 64 | api_version = "v1" 65 | 66 | def __init__( 67 | self, project_key: str | None = None, *, bases: t.Iterable[Base] 68 | ) -> None: 69 | for b in bases: 70 | b._client = self 71 | 72 | project_key = project_key or os.getenv("DETA_PROJECT_KEY") 73 | if not project_key: 74 | raise ValueError( 75 | "No project key provided. Please set a project key as an env " 76 | "variable, or pass it as an arg to Client." 77 | ) 78 | 79 | self.project_key = project_key 80 | self.project_id = project_key.split("_")[0] 81 | self.__session: aiohttp.ClientSession | None = None 82 | 83 | @property 84 | def ready(self) -> bool: 85 | """Returns true if the client is ready to be used.""" 86 | 87 | return not (self.__session is None or self.__session.closed) 88 | 89 | @property 90 | def _session(self) -> aiohttp.ClientSession: 91 | if not self.__session or self.__session.closed: 92 | raise RuntimeError("Client has not been opened yet.") 93 | return self.__session 94 | 95 | async def open(self) -> None: 96 | """Prepares the client to be used.""" 97 | 98 | if self.ready: 99 | _LOG.warning("Client is already opened.") 100 | return 101 | 102 | self.__session = aiohttp.ClientSession( 103 | self.api_base_url, 104 | headers={ 105 | "X-API-Key": self.project_key, 106 | "Content-Type": "application/json", 107 | }, 108 | raise_for_status=True, 109 | ) 110 | 111 | async def close(self) -> None: 112 | """Closes the aiohttp session.""" 113 | 114 | if not self.ready: 115 | _LOG.warning("Client is not open.") 116 | return 117 | 118 | await self._session.close() 119 | self.__session = None 120 | 121 | # api methods 122 | async def put_items( 123 | self, 124 | base_name: str, 125 | items: t.Sequence[RAW_ITEM], 126 | *, 127 | expire_at: int | datetime | None = None, 128 | expire_in: int | timedelta | None = None, 129 | ) -> RawPutItemsResponse: 130 | """Stores multiple items in a single request. 131 | 132 | This request overwrites an item if the key already exists. 133 | 134 | Args: 135 | base_name: The name of the Base. 136 | items: A list of items to insert. 137 | expire_at: A Unix timestamp that these items should expire at. 138 | expire_in: The number of seconds until this item expires.""" 139 | 140 | if len(items) > 25: 141 | _LOG.warning("Only 25 items can be inserted at a time.") 142 | 143 | items = [_with_ttl(i, expire_at, expire_in) for i in items] 144 | url = self._build_url(base_name, "items") 145 | resp = await self._session.put(url, json={"items": items}) 146 | 147 | data: dict[str, dict[str, list[RAW_ITEM]]] = await resp.json() 148 | processed = data.get("processed", {}).get("items", []) 149 | failed = data.get("failed", {}).get("items", []) 150 | return RawPutItemsResponse(processed, failed) 151 | 152 | async def get_item(self, base_name: str, key: str) -> RAW_ITEM: 153 | """Get a stored item. 154 | 155 | Args: 156 | base_name: The name of the Base. 157 | key: The key of the item to get. 158 | 159 | Returns: 160 | A mapping of fields to values.""" 161 | 162 | url = self._build_url(base_name, f"items/{key}") 163 | resp = await self._session.get(url) 164 | return t.cast(RAW_ITEM, await resp.json()) 165 | 166 | async def delete_item(self, base_name: str, key: str) -> None: 167 | """Delete a stored item. 168 | 169 | Args: 170 | base_name: The name of the Base. 171 | key: The key of the item to delete.""" 172 | 173 | url = self._build_url(base_name, f"items/{key}") 174 | await self._session.delete(url) 175 | 176 | async def insert_item( 177 | self, 178 | base_name: str, 179 | item: RAW_ITEM, 180 | *, 181 | expire_at: int | datetime | None = None, 182 | expire_in: int | timedelta | None = None, 183 | ) -> RAW_ITEM: 184 | """Creates a new item only if no item with the same key exists. 185 | 186 | Args: 187 | base_name: The name of the Base. 188 | item: The item to insert. 189 | expire_at: The time at which the item should expire (UTC timetamp). 190 | expire_in: The time until this item should expire.""" 191 | 192 | json = {"item": _with_ttl(item, expire_at, expire_in)} 193 | url = self._build_url(base_name, "items") 194 | resp = await self._session.post(url, json=json) 195 | return t.cast(RAW_ITEM, await resp.json()) 196 | 197 | async def update_item( 198 | self, 199 | base_name: str, 200 | key: str, 201 | *updates: Update, 202 | set: dict[str, object] | None = None, 203 | increment: dict[str, int] | None = None, 204 | append: dict[str, list[object]] | None = None, 205 | prepend: dict[str, list[object]] | None = None, 206 | delete: list[str] | None = None, 207 | ) -> dict[str, object]: 208 | """Update an item. 209 | 210 | Args: 211 | base_name: The name of the Base. 212 | key: The key of the item to update. 213 | *updates: Update objects. 214 | set: A mapping of fields to set. 215 | increment: A mapping of fields to increment. 216 | append: A mapping of fields to append items to. 217 | prepend: A mapping of fields to prepend items to. 218 | delete: A list of fields to delete. 219 | 220 | Returns: 221 | The generated update query. 222 | """ 223 | 224 | def join(left: object, right: object) -> object: 225 | if isinstance(left, t.Mapping): 226 | assert isinstance(right, t.Mapping) 227 | return {**left, **right} 228 | elif isinstance(left, t.Sequence): 229 | assert isinstance(right, t.Sequence) 230 | return [*left, *right] 231 | else: 232 | raise TypeError 233 | 234 | data = { 235 | "set": set or {}, 236 | "increment": increment or {}, 237 | "append": append or {}, 238 | "prepend": prepend or {}, 239 | "delete": delete or [], 240 | } 241 | 242 | for update in updates: 243 | if (val := data.get(update.field)) is not None: 244 | data[update.field] = join(val, update.payload) 245 | else: 246 | raise ValueError(f"Unexpected field {update.field}.") 247 | 248 | url = self._build_url(base_name, f"items/{key}") 249 | await self._session.patch(url, json=data) 250 | return data 251 | 252 | def query_items( 253 | self, 254 | base_name: str, 255 | query: Node | t.Sequence[t.Mapping[str, object]] | None = None, 256 | limit: int = 0, 257 | last: str | None = None, 258 | ) -> RawPaginator: 259 | """Query items. 260 | 261 | Args: 262 | base_name: The name of the Base. 263 | query: The query. Defaults to None. 264 | limit: The maximum page size. Defaults to 0 (no limit). 265 | last: The last key of the last page. Defaults to None. 266 | 267 | Returns: 268 | A RawPage. Use RawPage.items to see the items, and RawPage.next() 269 | to get the next page. 270 | """ 271 | 272 | async def first_page( 273 | base_name: str, 274 | query: Node | t.Sequence[t.Mapping[str, object]] | None = None, 275 | limit: int = 0, 276 | last: str | None = None, 277 | ) -> RawPage: 278 | if isinstance(query, Node): 279 | query = query.deta_query() 280 | 281 | data: dict[str, object] = {} 282 | if query: 283 | data["query"] = query 284 | if limit: 285 | data["limit"] = limit 286 | if last: 287 | data["last"] = last 288 | 289 | url = self._build_url(base_name, "query") 290 | 291 | resp = await self._session.post(url, json=data) 292 | resp_data = await resp.json() 293 | return RawPage( 294 | client=self, 295 | base_name=base_name, 296 | query=query, 297 | size=resp_data["paging"]["size"], 298 | limit=limit, 299 | last=resp_data["paging"].get("last"), 300 | items=resp_data["items"], 301 | ) 302 | 303 | return RawPaginator(first_page(base_name, query, limit, last)) 304 | 305 | def _build_url(self, base_name: str, relative_path: str) -> str: 306 | return ( 307 | f"/{self.api_version}/{self.project_id}/{base_name}/" 308 | f"{relative_path}" 309 | ) 310 | -------------------------------------------------------------------------------- /detaorm/field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from detaorm import update 6 | from detaorm.query import Query 7 | from detaorm.undef import UNDEF, UndefOr 8 | 9 | if t.TYPE_CHECKING: 10 | from detaorm.base import Base 11 | 12 | __all__ = ("Field",) 13 | 14 | _SELF = t.TypeVar("_SELF", bound="Field[t.Any]") 15 | _TYPE = t.TypeVar("_TYPE") 16 | 17 | 18 | class _Op: 19 | def __init__(self, op: str | None) -> None: 20 | self.op = f"?{op}" if op else "" 21 | 22 | def __get__( 23 | self, inst: object, cls: type[object] 24 | ) -> t.Callable[[object], Query]: 25 | def op_func(other: object) -> Query: 26 | assert isinstance(inst, Field) 27 | return Query(inst.qual_name + self.op, other) 28 | 29 | return op_func 30 | 31 | 32 | class Field(t.Generic[_TYPE]): 33 | """Represent a single field on a Base.""" 34 | 35 | name: str 36 | """The name of the field.""" 37 | 38 | def __init__(self, default: UndefOr[_TYPE] = UNDEF) -> None: 39 | if default is not UNDEF: 40 | self._default = default 41 | 42 | @property 43 | def default(self) -> UndefOr[_TYPE]: 44 | return self._default if hasattr(self, "_default") else UNDEF 45 | 46 | @property 47 | def qual_name(self) -> str: 48 | """The qualified name of this field.""" 49 | return self.__qual_name 50 | 51 | @qual_name.setter 52 | def qual_name(self, val: str) -> None: 53 | self.__qual_name = val 54 | 55 | for k, v in self.__class__.__dict__.items(): 56 | if isinstance(v, Field): 57 | v.name = k 58 | v.qual_name = f"{self.qual_name}.{k}" 59 | 60 | def __init_subclass__(cls) -> None: 61 | for k, v in cls.__dict__.items(): 62 | if isinstance(v, Field): 63 | v.name = k 64 | 65 | @t.overload 66 | def __get__(self: _SELF, inst: None, cls: t.Any) -> _SELF: 67 | ... 68 | 69 | @t.overload 70 | def __get__(self, inst: Base | NestedField, cls: t.Any) -> _TYPE: 71 | ... 72 | 73 | def __get__( 74 | self: _SELF, inst: Base | NestedField | None, cls: t.Any 75 | ) -> _SELF | _TYPE: 76 | if isinstance(inst, NestedField) and not isinstance( 77 | inst, NestedFieldInst 78 | ): 79 | return self 80 | 81 | if inst: 82 | return t.cast(_TYPE, inst.raw[self.name]) 83 | return self 84 | 85 | # query ops 86 | eq = _Op(None) 87 | neq = _Op("ne") 88 | gt = _Op("gt") 89 | lt = _Op("lt") 90 | lte = _Op("lte") 91 | gte = _Op("gte") 92 | prefix = _Op("pfx") 93 | range = _Op("range") 94 | contains = _Op("contains") 95 | not_contains = _Op("not_contains") 96 | 97 | # updates 98 | def set(self, value: object) -> update.SetUpdate: 99 | return update.SetUpdate({self.qual_name: value}) 100 | 101 | def increment(self, value: int) -> update.IncrementUpdate: 102 | return update.IncrementUpdate({self.qual_name: value}) 103 | 104 | def append(self, values: list[object]) -> update.AppendUpdate: 105 | return update.AppendUpdate({self.qual_name: values}) 106 | 107 | def prepend(self, values: list[object]) -> update.PrependUpdate: 108 | return update.PrependUpdate({self.qual_name: values}) 109 | 110 | def delete(self) -> update.DeleteUpdate: 111 | return update.DeleteUpdate([self.qual_name]) 112 | 113 | # magic 114 | __eq__ = eq # type: ignore 115 | __ne__ = neq # type: ignore 116 | __gt__ = gt 117 | __lt__ = lt 118 | __ge__ = gte 119 | __le__ = lte 120 | 121 | 122 | class NestedField(Field[t.Dict[str, object]]): 123 | def __init__(self) -> None: 124 | super().__init__() 125 | 126 | @property 127 | def default(self) -> UndefOr[dict[str, object]]: 128 | if hasattr(self, "_default"): 129 | return self._default 130 | 131 | defaults: dict[str, object] = {} 132 | for k, v in type(self).__dict__.items(): 133 | if not isinstance(v, Field): 134 | continue 135 | 136 | if v.default is not UNDEF: 137 | defaults[k] = v.default 138 | 139 | return defaults or UNDEF 140 | 141 | @property 142 | def raw(self) -> dict[str, object]: 143 | raise NotImplementedError 144 | 145 | def __get__( # type: ignore 146 | self: _SELF, inst: None | Base | NestedField, cls: t.Any 147 | ) -> _SELF: 148 | if isinstance(inst, NestedField) and not isinstance( 149 | inst, NestedFieldInst 150 | ): 151 | return self 152 | 153 | if inst: 154 | return NestedFieldInst(self, inst) # type: ignore 155 | return self 156 | 157 | 158 | class NestedFieldInst: 159 | def __init__(self, field: NestedField, base: Base) -> None: 160 | self.field = field 161 | self.base = base 162 | 163 | @property 164 | def raw(self) -> dict[str, object]: 165 | return self.base.raw[self.field.name] # type: ignore 166 | 167 | def __getattr__(self, key: str) -> object: 168 | return getattr(self.field, key).__get__(self, type(self)) 169 | -------------------------------------------------------------------------------- /detaorm/paginator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from detaorm.types import RAW_ITEM 6 | 7 | if t.TYPE_CHECKING: 8 | from detaorm.client import Client 9 | 10 | __all__ = ("RawPaginator", "RawPage") 11 | 12 | 13 | class RawPaginator: 14 | def __init__(self, first_page: t.Awaitable[RawPage]) -> None: 15 | self.__first_page: t.Awaitable[RawPage] | None = first_page 16 | self.__current_page: RawPage | None = None 17 | 18 | def __aiter__(self) -> RawPaginator: 19 | return self 20 | 21 | def __await__(self) -> t.Generator[t.Any, None, RawPage]: 22 | assert self.__first_page 23 | return self.__first_page.__await__() 24 | 25 | async def __anext__(self) -> RawPage: 26 | if self.__first_page: 27 | self.__current_page = await self.__first_page 28 | self.__first_page = None 29 | return self.__current_page 30 | 31 | assert self.__current_page 32 | self.__current_page = await self.__current_page.next() 33 | if self.__current_page is None: 34 | raise StopAsyncIteration 35 | return self.__current_page 36 | 37 | 38 | class RawPage: 39 | def __init__( 40 | self, 41 | client: Client, 42 | base_name: str, 43 | query: t.Sequence[t.Mapping[str, object]] | None, 44 | limit: int, 45 | size: int, 46 | last: str | None, 47 | items: t.Sequence[RAW_ITEM], 48 | ) -> None: 49 | self._base_name = base_name 50 | self._query = query 51 | self._client = client 52 | self._limit = limit 53 | self._last = last 54 | self.size = size 55 | self.items = items 56 | 57 | async def next(self, limit: int | None = None) -> RawPage | None: 58 | if not self._last: 59 | return None 60 | 61 | return await self._client.query_items( 62 | self._base_name, 63 | self._query, 64 | limit=limit if limit is not None else self._limit, 65 | last=self._last, 66 | ) 67 | -------------------------------------------------------------------------------- /detaorm/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circuitsacul/DetaORM/6b12a0c6f05150c1aac71c946f71b8eaeecc81d2/detaorm/py.typed -------------------------------------------------------------------------------- /detaorm/query.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | __all__ = ("And", "Or", "Query", "Node") 6 | 7 | 8 | class Node: 9 | @classmethod 10 | def _filter_nodes(cls, nodes: t.Sequence[Node]) -> t.Sequence[Node]: 11 | final: list[Node] = [] 12 | for n in nodes: 13 | if isinstance(n, cls): 14 | final.extend(n.nodes) 15 | else: 16 | final.append(n) 17 | return final 18 | 19 | @property 20 | def nodes(self) -> t.Sequence[Node]: 21 | raise NotImplementedError 22 | 23 | def deta_query(self) -> list[dict[str, object]]: 24 | result: list[dict[str, object]] = [] 25 | tree = self._flatten() 26 | if not isinstance(tree, Or): 27 | tree = Or(tree) 28 | for node in tree.nodes: 29 | assert isinstance(node, (Query, And)) 30 | assert all(isinstance(q, Query) for q in node.nodes) 31 | result.append( 32 | {q.key: q.value for q in t.cast(t.Sequence[Query], node.nodes)} 33 | ) 34 | return result 35 | 36 | def _flatten(self) -> Node: 37 | raise NotImplementedError 38 | 39 | def _and(self, other: Node) -> Node: 40 | raise NotImplementedError 41 | 42 | def __and__(self, other: Node) -> Node: 43 | return And(self, other) 44 | 45 | def __or__(self, other: Node) -> Node: 46 | return Or(self, other) 47 | 48 | 49 | class And(Node): 50 | def __init__(self, *nodes: Node) -> None: 51 | self._nodes = self._filter_nodes(nodes) 52 | 53 | @property 54 | def nodes(self) -> t.Sequence[Node]: 55 | return self._nodes 56 | 57 | def _flatten(self) -> Node: 58 | final: Node | None = None 59 | for _node in self.nodes: 60 | node = _node._flatten() 61 | if final: 62 | final = final._and(node) 63 | else: 64 | final = node 65 | return final or Or() 66 | 67 | def _and(self, other: Node) -> Node: 68 | if isinstance(other, Query): 69 | return And(*self.nodes, other) 70 | elif isinstance(other, And): 71 | return And(*self.nodes, *other.nodes) 72 | else: # Or 73 | return other._and(self) 74 | 75 | def __repr__(self) -> str: 76 | return "({})".format(" AND ".join(str(n) for n in self.nodes)) 77 | 78 | 79 | class Or(Node): 80 | def __init__(self, *nodes: Node) -> None: 81 | self._nodes = self._filter_nodes(nodes) 82 | 83 | @property 84 | def nodes(self) -> t.Sequence[Node]: 85 | return self._nodes 86 | 87 | def _flatten(self) -> Or: 88 | return Or(*(n._flatten() for n in self.nodes)) 89 | 90 | def _and(self, other: Node) -> Or: 91 | return Or(*(n._and(other) for n in self.nodes)) 92 | 93 | def __repr__(self) -> str: 94 | return "({})".format(" OR ".join(str(n) for n in self.nodes)) 95 | 96 | 97 | class Query(Node): 98 | def __init__(self, key: str, value: object) -> None: 99 | self.key = key 100 | self.value = value 101 | 102 | @property 103 | def nodes(self) -> t.Sequence[Node]: 104 | return (self,) 105 | 106 | def _flatten(self) -> Node: 107 | return self 108 | 109 | def _and(self, other: Node) -> Node: 110 | if isinstance(other, Query): 111 | return And(self, other) 112 | return other._and(self) 113 | 114 | def __repr__(self) -> str: 115 | return f"{self.key}={self.value}" 116 | -------------------------------------------------------------------------------- /detaorm/types.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | RAW_ITEM = t.Dict[str, object] 4 | -------------------------------------------------------------------------------- /detaorm/undef.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum import Enum 3 | 4 | __all__ = ("Undef", "UNDEF", "UndefOr") 5 | 6 | 7 | class Undef(Enum): 8 | UNDEF = "UNDEFINED" 9 | 10 | 11 | UNDEF = Undef.UNDEF 12 | _T = t.TypeVar("_T") 13 | UndefOr = t.Union[Undef, _T] 14 | -------------------------------------------------------------------------------- /detaorm/update.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | __all___ = ( 6 | "Updaate", 7 | "SetUpdate", 8 | "IncrementUpdate", 9 | "AppendUpdate", 10 | "PrependUpdate", 11 | "DeleteUpdate", 12 | ) 13 | 14 | 15 | class Update: 16 | field: str 17 | payload: object 18 | 19 | 20 | @dataclass 21 | class SetUpdate: 22 | field = "set" 23 | payload: dict[str, object] 24 | 25 | 26 | @dataclass 27 | class IncrementUpdate: 28 | field = "increment" 29 | payload: dict[str, int] 30 | 31 | 32 | @dataclass 33 | class AppendUpdate: 34 | field = "append" 35 | payload: dict[str, list[object]] 36 | 37 | 38 | @dataclass 39 | class PrependUpdate: 40 | field = "prepend" 41 | payload: dict[str, list[object]] 42 | 43 | 44 | @dataclass 45 | class DeleteUpdate: 46 | field = "delete" 47 | payload: list[str] 48 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | warn_return_any=True 3 | warn_unused_configs=True 4 | strict=True 5 | 6 | [mypy-nox.*] 7 | ignore_missing_imports=True 8 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | 4 | @nox.session 5 | def mypy(session: nox.Session) -> None: 6 | session.install("poetry") 7 | session.run("poetry", "install") 8 | 9 | session.run("mypy", ".") 10 | 11 | 12 | @nox.session(name="apply-lint") 13 | def apply_lint(session: nox.Session) -> None: 14 | session.install("black") 15 | session.install("isort") 16 | session.run("black", ".") 17 | session.run("isort", ".") 18 | 19 | 20 | @nox.session 21 | def lint(session: nox.Session) -> None: 22 | session.install("black") 23 | session.install("flake8") 24 | session.install("isort") 25 | session.run("black", ".", "--check") 26 | session.run("flake8") 27 | session.run("isort", ".", "--check") 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length=79 3 | skip-magic-trailing-comma=true 4 | 5 | [tool.poetry] 6 | name = "detaorm" 7 | version = "0" 8 | description = "An async ORM for DetaBase." 9 | authors = ["CircuitSacul "] 10 | license = "MIT" 11 | readme = "README.md" 12 | 13 | [tool.poetry.dependencies] 14 | python = "^3.8" 15 | aiohttp = "^3.8.1" 16 | 17 | [tool.poetry.group.dev.dependencies] 18 | black = "^23.3.0" 19 | isort = "^5.12.0" 20 | flake8 = "^5.0.4" 21 | mypy = "^1.3.0" 22 | nox = "^2023.4.22" 23 | 24 | [build-system] 25 | requires = ["poetry-core"] 26 | build-backend = "poetry.core.masonry.api" 27 | --------------------------------------------------------------------------------