scruby 0.17.0__py3-none-any.whl → 0.27.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scruby/errors.py CHANGED
@@ -1,3 +1,7 @@
1
+ # Scruby - Asynchronous library for building and managing a hybrid database, by scheme of key-value.
2
+ # Copyright (c) 2025 Gennady Kostyunin
3
+ # SPDX-License-Identifier: MIT
4
+ #
1
5
  """Scruby Exceptions."""
2
6
 
3
7
  from __future__ import annotations
@@ -5,6 +9,8 @@ from __future__ import annotations
5
9
  __all__ = (
6
10
  "ScrubyException",
7
11
  "MetadataValueError",
12
+ "KeyAlreadyExistsError",
13
+ "KeyNotExistsError",
8
14
  )
9
15
 
10
16
 
@@ -26,7 +32,7 @@ class MetadataValueError(ScrubyException):
26
32
  class KeyAlreadyExistsError(ScrubyException):
27
33
  """Exception is raised if the key already exists."""
28
34
 
29
- def __init__(self) -> None:
35
+ def __init__(self) -> None: # noqa: D107
30
36
  self.message = "The key already exists."
31
37
  super().__init__(self.message)
32
38
 
@@ -34,6 +40,6 @@ class KeyAlreadyExistsError(ScrubyException):
34
40
  class KeyNotExistsError(ScrubyException):
35
41
  """Exception is raised If the key is not exists."""
36
42
 
37
- def __init__(self) -> None:
43
+ def __init__(self) -> None: # noqa: D107
38
44
  self.message = "The key not exists."
39
45
  super().__init__(self.message)
@@ -0,0 +1,25 @@
1
+ # Scruby - Asynchronous library for building and managing a hybrid database, by scheme of key-value.
2
+ # Copyright (c) 2025 Gennady Kostyunin
3
+ # SPDX-License-Identifier: MIT
4
+ #
5
+ """Mixins."""
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = (
10
+ "Collection",
11
+ "Count",
12
+ "CustomTask",
13
+ "Delete",
14
+ "Find",
15
+ "Keys",
16
+ "Update",
17
+ )
18
+
19
+ from scruby.mixins.collection import Collection
20
+ from scruby.mixins.count import Count
21
+ from scruby.mixins.custom_task import CustomTask
22
+ from scruby.mixins.delete import Delete
23
+ from scruby.mixins.docs import Keys
24
+ from scruby.mixins.find import Find
25
+ from scruby.mixins.update import Update
@@ -0,0 +1,50 @@
1
+ # Scruby - Asynchronous library for building and managing a hybrid database, by scheme of key-value.
2
+ # Copyright (c) 2025 Gennady Kostyunin
3
+ # SPDX-License-Identifier: MIT
4
+ #
5
+ """Methods for working with collections."""
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ("Collection",)
10
+
11
+ from shutil import rmtree
12
+
13
+ from anyio import Path, to_thread
14
+
15
+ from scruby import settings
16
+
17
+
18
+ class Collection:
19
+ """Methods for working with collections."""
20
+
21
+ def collection_name(self) -> str:
22
+ """Get collection name.
23
+
24
+ Returns:
25
+ Collection name.
26
+ """
27
+ return self._class_model.__name__
28
+
29
+ @staticmethod
30
+ async def collection_list() -> list[str]:
31
+ """Get collection list."""
32
+ target_directory = Path(settings.DB_ROOT)
33
+ # Get all entries in the directory
34
+ all_entries = Path.iterdir(target_directory)
35
+ directory_names: list[str] = [entry.name async for entry in all_entries]
36
+ return directory_names
37
+
38
+ @staticmethod
39
+ async def delete_collection(name: str) -> None:
40
+ """Asynchronous method for deleting a collection by its name.
41
+
42
+ Args:
43
+ name (str): Collection name.
44
+
45
+ Returns:
46
+ None.
47
+ """
48
+ target_directory = f"{settings.DB_ROOT}/{name}"
49
+ await to_thread.run_sync(rmtree, target_directory) # pyrefly: ignore[bad-argument-type]
50
+ return
scruby/mixins/count.py ADDED
@@ -0,0 +1,62 @@
1
+ # Scruby - Asynchronous library for building and managing a hybrid database, by scheme of key-value.
2
+ # Copyright (c) 2025 Gennady Kostyunin
3
+ # SPDX-License-Identifier: MIT
4
+ #
5
+ """Methods for counting the number of documents."""
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ("Count",)
10
+
11
+ import concurrent.futures
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+
16
+ class Count:
17
+ """Methods for counting the number of documents."""
18
+
19
+ async def estimated_document_count(self) -> int:
20
+ """Get an estimate of the number of documents in this collection using collection metadata.
21
+
22
+ Returns:
23
+ The number of documents.
24
+ """
25
+ meta = await self.get_meta()
26
+ return meta.counter_documents
27
+
28
+ async def count_documents(
29
+ self,
30
+ filter_fn: Callable,
31
+ ) -> int:
32
+ """Count the number of documents a matching the filter in this collection.
33
+
34
+ The search is based on the effect of a quantum loop.
35
+ The search effectiveness depends on the number of processor threads.
36
+ Ideally, hundreds and even thousands of threads are required.
37
+
38
+ Args:
39
+ filter_fn: A function that execute the conditions of filtering.
40
+
41
+ Returns:
42
+ The number of documents.
43
+ """
44
+ branch_numbers: range = range(1, self._max_branch_number)
45
+ search_task_fn: Callable = self._task_find
46
+ hash_reduce_left: int = self._hash_reduce_left
47
+ db_root: str = self._db_root
48
+ class_model: Any = self._class_model
49
+ counter: int = 0
50
+ with concurrent.futures.ThreadPoolExecutor(self._max_workers) as executor:
51
+ for branch_number in branch_numbers:
52
+ future = executor.submit(
53
+ search_task_fn,
54
+ branch_number,
55
+ filter_fn,
56
+ hash_reduce_left,
57
+ db_root,
58
+ class_model,
59
+ )
60
+ if await future.result() is not None:
61
+ counter += 1
62
+ return counter
@@ -0,0 +1,75 @@
1
+ # Scruby - Asynchronous library for building and managing a hybrid database, by scheme of key-value.
2
+ # Copyright (c) 2025 Gennady Kostyunin
3
+ # SPDX-License-Identifier: MIT
4
+ #
5
+ """Quantum methods for running custom tasks."""
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ("CustomTask",)
10
+
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ import orjson
15
+ from anyio import Path
16
+
17
+
18
+ class CustomTask:
19
+ """Quantum methods for running custom tasks."""
20
+
21
+ @staticmethod
22
+ async def _task_get_docs(
23
+ branch_number: int,
24
+ hash_reduce_left: int,
25
+ db_root: str,
26
+ class_model: Any,
27
+ ) -> list[Any]:
28
+ """Get documents for custom task.
29
+
30
+ This method is for internal use.
31
+
32
+ Returns:
33
+ List of documents.
34
+ """
35
+ branch_number_as_hash: str = f"{branch_number:08x}"[hash_reduce_left:]
36
+ separated_hash: str = "/".join(list(branch_number_as_hash))
37
+ leaf_path: Path = Path(
38
+ *(
39
+ db_root,
40
+ class_model.__name__,
41
+ separated_hash,
42
+ "leaf.json",
43
+ ),
44
+ )
45
+ docs: list[Any] = []
46
+ if await leaf_path.exists():
47
+ data_json: bytes = await leaf_path.read_bytes()
48
+ data: dict[str, str] = orjson.loads(data_json) or {}
49
+ for _, val in data.items():
50
+ docs.append(class_model.model_validate_json(val))
51
+ return docs
52
+
53
+ async def run_custom_task(self, custom_task_fn: Callable, limit_docs: int = 1000) -> Any:
54
+ """Running custom task.
55
+
56
+ This method running a task created on the basis of a quantum loop.
57
+ Effectiveness running task depends on the number of processor threads.
58
+ Ideally, hundreds and even thousands of threads are required.
59
+
60
+ Args:
61
+ custom_task_fn: A function that execute the custom task.
62
+ limit_docs: Limiting the number of documents. By default = 1000.
63
+
64
+ Returns:
65
+ The result of a custom task.
66
+ """
67
+ kwargs = {
68
+ "get_docs_fn": self._task_get_docs,
69
+ "branch_numbers": range(1, self._max_branch_number),
70
+ "hash_reduce_left": self._hash_reduce_left,
71
+ "db_root": self._db_root,
72
+ "class_model": self._class_model,
73
+ "limit_docs": limit_docs,
74
+ }
75
+ return await custom_task_fn(**kwargs)
@@ -0,0 +1,96 @@
1
+ # Scruby - Asynchronous library for building and managing a hybrid database, by scheme of key-value.
2
+ # Copyright (c) 2025 Gennady Kostyunin
3
+ # SPDX-License-Identifier: MIT
4
+ #
5
+ """Methods for deleting documents."""
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ("Delete",)
10
+
11
+ import concurrent.futures
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ import orjson
16
+ from anyio import Path
17
+
18
+
19
+ class Delete:
20
+ """Methods for deleting documents."""
21
+
22
+ @staticmethod
23
+ async def _task_delete(
24
+ branch_number: int,
25
+ filter_fn: Callable,
26
+ hash_reduce_left: int,
27
+ db_root: str,
28
+ class_model: Any,
29
+ ) -> int:
30
+ """Task for find and delete documents.
31
+
32
+ This method is for internal use.
33
+
34
+ Returns:
35
+ The number of deleted documents.
36
+ """
37
+ branch_number_as_hash: str = f"{branch_number:08x}"[hash_reduce_left:]
38
+ separated_hash: str = "/".join(list(branch_number_as_hash))
39
+ leaf_path: Path = Path(
40
+ *(
41
+ db_root,
42
+ class_model.__name__,
43
+ separated_hash,
44
+ "leaf.json",
45
+ ),
46
+ )
47
+ counter: int = 0
48
+ if await leaf_path.exists():
49
+ data_json: bytes = await leaf_path.read_bytes()
50
+ data: dict[str, str] = orjson.loads(data_json) or {}
51
+ new_state: dict[str, str] = {}
52
+ for key, val in data.items():
53
+ doc = class_model.model_validate_json(val)
54
+ if filter_fn(doc):
55
+ counter -= 1
56
+ else:
57
+ new_state[key] = val
58
+ await leaf_path.write_bytes(orjson.dumps(new_state))
59
+ return counter
60
+
61
+ async def delete_many(
62
+ self,
63
+ filter_fn: Callable,
64
+ ) -> int:
65
+ """Delete one or more documents matching the filter.
66
+
67
+ The search is based on the effect of a quantum loop.
68
+ The search effectiveness depends on the number of processor threads.
69
+ Ideally, hundreds and even thousands of threads are required.
70
+
71
+ Args:
72
+ filter_fn: A function that execute the conditions of filtering.
73
+
74
+ Returns:
75
+ The number of deleted documents.
76
+ """
77
+ branch_numbers: range = range(1, self._max_branch_number)
78
+ search_task_fn: Callable = self._task_delete
79
+ hash_reduce_left: int = self._hash_reduce_left
80
+ db_root: str = self._db_root
81
+ class_model: Any = self._class_model
82
+ counter: int = 0
83
+ with concurrent.futures.ThreadPoolExecutor(self._max_workers) as executor:
84
+ for branch_number in branch_numbers:
85
+ future = executor.submit(
86
+ search_task_fn,
87
+ branch_number,
88
+ filter_fn,
89
+ hash_reduce_left,
90
+ db_root,
91
+ class_model,
92
+ )
93
+ counter += await future.result()
94
+ if counter < 0:
95
+ await self._counter_documents(counter)
96
+ return abs(counter)
scruby/mixins/docs.py ADDED
@@ -0,0 +1,168 @@
1
+ # Scruby - Asynchronous library for building and managing a hybrid database, by scheme of key-value.
2
+ # Copyright (c) 2025 Gennady Kostyunin
3
+ # SPDX-License-Identifier: MIT
4
+ #
5
+ """Methods for working with keys."""
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ("Keys",)
10
+
11
+ import logging
12
+ from typing import Any
13
+
14
+ import orjson
15
+
16
+ from scruby.errors import (
17
+ KeyAlreadyExistsError,
18
+ KeyNotExistsError,
19
+ )
20
+
21
+
22
+ class Keys:
23
+ """Methods for working with keys."""
24
+
25
+ async def add_doc(self, doc: Any) -> None:
26
+ """Asynchronous method for adding document to collection.
27
+
28
+ Args:
29
+ doc: Value of key. Type, derived from `BaseModel`.
30
+
31
+ Returns:
32
+ None.
33
+ """
34
+ # Check if the Model matches the collection
35
+ if not isinstance(doc, self._class_model):
36
+ doc_class_name = doc.__class__.__name__
37
+ collection_name = self._class_model.__name__
38
+ msg = (
39
+ f"(add_doc) Parameter `doc` => Model `{doc_class_name}` does not match collection `{collection_name}`!"
40
+ )
41
+ logging.error(msg)
42
+ raise TypeError(msg)
43
+ # The path to cell of collection.
44
+ leaf_path, prepared_key = await self._get_leaf_path(doc.key)
45
+ doc_json: str = doc.model_dump_json()
46
+ # Write key-value to collection.
47
+ if await leaf_path.exists():
48
+ # Add new key.
49
+ data_json: bytes = await leaf_path.read_bytes()
50
+ data: dict = orjson.loads(data_json) or {}
51
+ try:
52
+ data[prepared_key]
53
+ except KeyError:
54
+ data[prepared_key] = doc_json
55
+ await leaf_path.write_bytes(orjson.dumps(data))
56
+ else:
57
+ err = KeyAlreadyExistsError()
58
+ logging.error(err.message)
59
+ raise err
60
+ else:
61
+ # Add new document to a blank leaf.
62
+ await leaf_path.write_bytes(orjson.dumps({prepared_key: doc_json}))
63
+ await self._counter_documents(1)
64
+
65
+ async def update_doc(self, doc: Any) -> None:
66
+ """Asynchronous method for updating key to collection.
67
+
68
+ Args:
69
+ doc: Value of key. Type `BaseModel`.
70
+
71
+ Returns:
72
+ None.
73
+ """
74
+ # Check if the Model matches the collection
75
+ if not isinstance(doc, self._class_model):
76
+ doc_class_name = doc.__class__.__name__
77
+ collection_name = self._class_model.__name__
78
+ msg = (
79
+ f"(update_doc) Parameter `doc` => Model `{doc_class_name}` "
80
+ f"does not match collection `{collection_name}`!"
81
+ )
82
+ logging.error(msg)
83
+ raise TypeError(msg)
84
+ # The path to cell of collection.
85
+ leaf_path, prepared_key = await self._get_leaf_path(doc.key)
86
+ doc_json: str = doc.model_dump_json()
87
+ # Update the existing key.
88
+ if await leaf_path.exists():
89
+ # Update the existing key.
90
+ data_json: bytes = await leaf_path.read_bytes()
91
+ data: dict = orjson.loads(data_json) or {}
92
+ try:
93
+ data[prepared_key]
94
+ data[prepared_key] = doc_json
95
+ await leaf_path.write_bytes(orjson.dumps(data))
96
+ except KeyError:
97
+ err = KeyNotExistsError()
98
+ logging.error(err.message)
99
+ raise err from None
100
+ else:
101
+ logging.error("The key not exists.")
102
+ raise KeyError()
103
+
104
+ async def get_key(self, key: str) -> Any:
105
+ """Asynchronous method for getting value of key from collection.
106
+
107
+ Args:
108
+ key: Key name.
109
+
110
+ Returns:
111
+ Value of key or KeyError.
112
+ """
113
+ # The path to the database cell.
114
+ leaf_path, prepared_key = await self._get_leaf_path(key)
115
+ # Get value of key.
116
+ if await leaf_path.exists():
117
+ data_json: bytes = await leaf_path.read_bytes()
118
+ data: dict = orjson.loads(data_json) or {}
119
+ obj: Any = self._class_model.model_validate_json(data[prepared_key])
120
+ return obj
121
+ msg: str = "`get_key` - The unacceptable key value."
122
+ logging.error(msg)
123
+ raise KeyError()
124
+
125
+ async def has_key(self, key: str) -> bool:
126
+ """Asynchronous method for checking presence of key in collection.
127
+
128
+ Args:
129
+ key: Key name.
130
+
131
+ Returns:
132
+ True, if the key is present.
133
+ """
134
+ # Get path to cell of collection.
135
+ leaf_path, prepared_key = await self._get_leaf_path(key)
136
+ # Checking whether there is a key.
137
+ if await leaf_path.exists():
138
+ data_json: bytes = await leaf_path.read_bytes()
139
+ data: dict = orjson.loads(data_json) or {}
140
+ try:
141
+ data[prepared_key]
142
+ return True
143
+ except KeyError:
144
+ return False
145
+ return False
146
+
147
+ async def delete_key(self, key: str) -> None:
148
+ """Asynchronous method for deleting key from collection.
149
+
150
+ Args:
151
+ key: Key name.
152
+
153
+ Returns:
154
+ None.
155
+ """
156
+ # The path to the database cell.
157
+ leaf_path, prepared_key = await self._get_leaf_path(key)
158
+ # Deleting key.
159
+ if await leaf_path.exists():
160
+ data_json: bytes = await leaf_path.read_bytes()
161
+ data: dict = orjson.loads(data_json) or {}
162
+ del data[prepared_key]
163
+ await leaf_path.write_bytes(orjson.dumps(data))
164
+ await self._counter_documents(-1)
165
+ return
166
+ msg: str = "`delete_key` - The unacceptable key value."
167
+ logging.error(msg)
168
+ raise KeyError()
scruby/mixins/find.py ADDED
@@ -0,0 +1,149 @@
1
+ # Scruby - Asynchronous library for building and managing a hybrid database, by scheme of key-value.
2
+ # Copyright (c) 2025 Gennady Kostyunin
3
+ # SPDX-License-Identifier: MIT
4
+ #
5
+ """Quantum methods for searching documents."""
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ("Find",)
10
+
11
+ import concurrent.futures
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ import orjson
16
+ from anyio import Path
17
+
18
+
19
+ class Find:
20
+ """Quantum methods for searching documents."""
21
+
22
+ @staticmethod
23
+ async def _task_find(
24
+ branch_number: int,
25
+ filter_fn: Callable,
26
+ hash_reduce_left: str,
27
+ db_root: str,
28
+ class_model: Any,
29
+ filter_is_checking: bool = True,
30
+ ) -> list[Any] | None:
31
+ """Task for find documents.
32
+
33
+ This method is for internal use.
34
+
35
+ Returns:
36
+ List of documents or None.
37
+ """
38
+ branch_number_as_hash: str = f"{branch_number:08x}"[hash_reduce_left:]
39
+ separated_hash: str = "/".join(list(branch_number_as_hash))
40
+ leaf_path: Path = Path(
41
+ *(
42
+ db_root,
43
+ class_model.__name__,
44
+ separated_hash,
45
+ "leaf.json",
46
+ ),
47
+ )
48
+ docs: list[Any] = []
49
+ if await leaf_path.exists():
50
+ data_json: bytes = await leaf_path.read_bytes()
51
+ data: dict[str, str] = orjson.loads(data_json) or {}
52
+ for _, val in data.items():
53
+ doc = class_model.model_validate_json(val)
54
+ if not filter_is_checking or filter_fn(doc):
55
+ docs.append(doc)
56
+ return docs or None
57
+
58
+ async def find_one(
59
+ self,
60
+ filter_fn: Callable,
61
+ ) -> Any | None:
62
+ """Finds a single document matching the filter.
63
+
64
+ The search is based on the effect of a quantum loop.
65
+ The search effectiveness depends on the number of processor threads.
66
+ Ideally, hundreds and even thousands of threads are required.
67
+
68
+ Args:
69
+ filter_fn (Callable): A function that execute the conditions of filtering.
70
+
71
+ Returns:
72
+ Document or None.
73
+ """
74
+ branch_numbers: range = range(1, self._max_branch_number)
75
+ search_task_fn: Callable = self._task_find
76
+ hash_reduce_left: int = self._hash_reduce_left
77
+ db_root: str = self._db_root
78
+ class_model: Any = self._class_model
79
+ with concurrent.futures.ThreadPoolExecutor(self._max_workers) as executor:
80
+ for branch_number in branch_numbers:
81
+ future = executor.submit(
82
+ search_task_fn,
83
+ branch_number,
84
+ filter_fn,
85
+ hash_reduce_left,
86
+ db_root,
87
+ class_model,
88
+ )
89
+ docs = await future.result()
90
+ if docs is not None:
91
+ return docs[0]
92
+ return None
93
+
94
+ async def find_many(
95
+ self,
96
+ filter_fn: Callable = lambda _: True,
97
+ limit_docs: int = 1000,
98
+ page_number: int = 1,
99
+ ) -> list[Any] | None:
100
+ """Finds one or more documents matching the filter.
101
+
102
+ The search is based on the effect of a quantum loop.
103
+ The search effectiveness depends on the number of processor threads.
104
+ Ideally, hundreds and even thousands of threads are required.
105
+
106
+ Args:
107
+ filter_fn (Callable): A function that execute the conditions of filtering.
108
+ By default it searches for all documents.
109
+ limit_docs (int): Limiting the number of documents. By default = 1000.
110
+ page_number (int): For pagination output. By default = 1.
111
+ Number of documents per page = limit_docs.
112
+
113
+ Returns:
114
+ List of documents or None.
115
+ """
116
+ branch_numbers: range = range(1, self._max_branch_number)
117
+ search_task_fn: Callable = self._task_find
118
+ hash_reduce_left: int = self._hash_reduce_left
119
+ db_root: str = self._db_root
120
+ class_model: Any = self._class_model
121
+ counter: int = 0
122
+ number_docs_skippe: int = limit_docs * (page_number - 1) if page_number > 1 else 0
123
+ result: list[Any] = []
124
+ filter_is_checking: bool = False
125
+ with concurrent.futures.ThreadPoolExecutor(self._max_workers) as executor:
126
+ for branch_number in branch_numbers:
127
+ if number_docs_skippe == 0 and counter >= limit_docs:
128
+ return result[:limit_docs]
129
+ future = executor.submit(
130
+ search_task_fn,
131
+ branch_number,
132
+ filter_fn,
133
+ hash_reduce_left,
134
+ db_root,
135
+ class_model,
136
+ filter_is_checking,
137
+ )
138
+ docs = await future.result()
139
+ if docs is not None:
140
+ for doc in docs:
141
+ if number_docs_skippe == 0:
142
+ if counter >= limit_docs:
143
+ return result[:limit_docs]
144
+ if filter_fn(doc):
145
+ result.append(doc)
146
+ counter += 1
147
+ else:
148
+ number_docs_skippe -= 1
149
+ return result or None