workercommon 0.4.1__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ Copyright 2020 Neil E. Hodges
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: workercommon
3
+ Version: 0.4.1
4
+ Summary: Support code for various projects
5
+ Author-email: "Neil E. Hodges" <47hasbegun@gmail.com>
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Repository, https://gitgud.io/takeshitakenji/workercommon
8
+ Project-URL: Issues, https://gitgud.io/takeshitakenji/workercommon/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE.TXT
16
+ Requires-Dist: psycopg2
17
+ Requires-Dist: pika
18
+ Dynamic: license-file
19
+
20
+ # Requirements
21
+
22
+ 1. Python 3.8
23
+ 2. A Unix-like OS for `fcntl` support
24
+ 3. [psycopg2](https://www.psycopg.org/)
25
+ 4. [Pika](https://pika.readthedocs.io/en/stable/)
@@ -0,0 +1,6 @@
1
+ # Requirements
2
+
3
+ 1. Python 3.8
4
+ 2. A Unix-like OS for `fcntl` support
5
+ 3. [psycopg2](https://www.psycopg.org/)
6
+ 4. [Pika](https://pika.readthedocs.io/en/stable/)
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+
6
+ [project]
7
+ name = "workercommon"
8
+ authors = [
9
+ {name = "Neil E. Hodges", email = "47hasbegun@gmail.com"}
10
+ ]
11
+ description = "Support code for various projects"
12
+ readme = "README.md"
13
+ requires-python = ">= 3.8"
14
+ license = "BSD-3-Clause"
15
+ dependencies = [
16
+ "psycopg2",
17
+ "pika",
18
+ ]
19
+ version = "0.4.1"
20
+ license-files = ["LICENSE.TXT"]
21
+ classifiers = [
22
+ "Programming Language :: Python :: 3",
23
+ "Operating System :: OS Independent",
24
+ "Development Status :: 3 - Alpha",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ ]
27
+
28
+ [project.urls]
29
+ Repository = "https://gitgud.io/takeshitakenji/workercommon"
30
+ Issues = "https://gitgud.io/takeshitakenji/workercommon/issues"
31
+
32
+ [tool.setuptools]
33
+ zip-safe = false
34
+ packages = [
35
+ "workercommon",
36
+ "workercommon.database",
37
+ ]
38
+ include-package-data = true
39
+
40
+ [tool.setuptools.package-data]
41
+ "workercommon" = ["py.typed"]
42
+ "workercommon.database" = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+
4
+ if sys.version_info < (3, 8):
5
+ raise RuntimeError("At least Python 3.8 is required")
6
+
7
+ from . import config, locking, rabbitmqueue, worker, commands, connectionpool, background
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+
4
+ if sys.version_info < (3, 8):
5
+ raise RuntimeError("At least Python 3.8 is required")
6
+
7
+ import logging, os, asyncio
8
+ from typing import Optional, Callable, Union, Dict
9
+ from threading import Thread, Lock
10
+ from time import sleep
11
+ from uuid import uuid1, UUID
12
+ from asyncio.events import AbstractEventLoop
13
+
14
+
15
+ class BackgroundThread(Thread):
16
+ def __init__(self, niceness: int = 0):
17
+ super().__init__()
18
+ self.lock = Lock()
19
+ self.loop: Optional[AbstractEventLoop] = None
20
+ self.handles: Dict[UUID, asyncio.Handle] = {}
21
+ self.niceness = niceness
22
+
23
+ def submit(self, func: Callable[[], None], delay: Union[int, float]) -> None:
24
+ with self.lock:
25
+ if not self.loop:
26
+ raise RuntimeError("Loop has already been stopped")
27
+
28
+ while not self.loop.is_running() and not self.loop.is_closed():
29
+ sleep(0.25)
30
+ if self.loop.is_closed() or not self.loop.is_running():
31
+ raise RuntimeError("Loop has already been stopped")
32
+
33
+ self.loop.call_soon_threadsafe(self.submit_inner, func, delay)
34
+
35
+ def submit_inner(self, func: Callable[[], None], delay: Union[int, float]) -> UUID:
36
+ with self.lock:
37
+ if not self.loop:
38
+ raise RuntimeError("Loop has already been stopped")
39
+
40
+ handle_id = uuid1()
41
+
42
+ def task():
43
+ try:
44
+ func()
45
+ finally:
46
+ with self.lock:
47
+ try:
48
+ del self.handles[handle_id]
49
+ except KeyError:
50
+ pass
51
+
52
+ logging.debug(f"Submitting task {handle_id} to be run later ({delay})")
53
+ self.handles[handle_id] = self.loop.call_later(delay, task)
54
+ return handle_id
55
+
56
+ def stop(self) -> None:
57
+ with self.lock:
58
+ for task in self.handles.values():
59
+ task.cancel()
60
+ if self.loop is not None:
61
+ self.loop.stop()
62
+
63
+ def run(self) -> None:
64
+ os.nice(self.niceness)
65
+ with self.lock:
66
+ loop = self.loop = asyncio.new_event_loop()
67
+
68
+ try:
69
+ loop.run_forever()
70
+ finally:
71
+ with self.lock:
72
+ self.loop = None
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+
4
+ if sys.version_info < (3, 5):
5
+ raise RuntimeError("At least Python 3.5 is required")
6
+
7
+ from functools import partial
8
+ from typing import Generator, Tuple, Any
9
+
10
+
11
+ class CommandElement(object):
12
+ def __init__(self, name: str, exclusive_lock: bool, function: Any):
13
+ self.name, self.function, self.exclusive_lock = name, function, exclusive_lock
14
+
15
+ def __call__(self, *args, **kwargs):
16
+ self.function(*args, **kwargs)
17
+
18
+
19
+ def Command(name: str, exclusive_lock: bool = True):
20
+ return partial(CommandElement, name, exclusive_lock)
21
+
22
+
23
+ class Commands(object):
24
+ @classmethod
25
+ def get_commands(cls) -> Generator[Tuple[str, CommandElement], None, None]:
26
+ for key in dir(cls):
27
+ value = getattr(cls, key)
28
+ if isinstance(value, CommandElement):
29
+ yield value.name, value
30
+
31
+ def load_command(self, name: str):
32
+ for cmd_name, value in self.get_commands():
33
+ if name == cmd_name:
34
+ return partial(value, self)
35
+ else:
36
+ raise ValueError("Unknown command: %s" % name)
37
+
38
+ def run_command(self, name: str, *args):
39
+ self.load_command(name)(*args)
40
+
41
+ def close(self):
42
+ pass
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+
4
+ if sys.version_info < (3, 8):
5
+ raise RuntimeError("At least Python 3.8 is required")
6
+
7
+ from configparser import ConfigParser
8
+ import logging
9
+ from logging.handlers import WatchedFileHandler
10
+ from typing import Tuple, Optional, Callable
11
+ from pathlib import Path
12
+
13
+
14
+ class Configuration(object):
15
+ def __init__(self, filename: str):
16
+ self.parser = self.create_parser()
17
+ self.set_base_config(self.parser)
18
+ self.parser.read(filename)
19
+ self.check_all_values(self.parser)
20
+
21
+ def create_parser(self) -> ConfigParser:
22
+ return ConfigParser()
23
+
24
+ def set_base_config(self, parser: ConfigParser) -> None:
25
+ pass
26
+
27
+ def check_all_values(self, parser: ConfigParser) -> None:
28
+ pass
29
+
30
+ @staticmethod
31
+ def dir_exists(s: str) -> bool:
32
+ return bool(s and Path(s).is_dir())
33
+
34
+ def check_value(
35
+ self, section: str, key: str, validator: Callable[[str], bool]
36
+ ) -> None:
37
+ value: Optional[str] = self[section, key]
38
+ try:
39
+ if value is None or not validator(value):
40
+ raise ValueError
41
+ except:
42
+ raise ValueError(f"Invalid {section}/{key} value: {value}")
43
+
44
+ def __getitem__(self, parts: Tuple[str, str]) -> Optional[str]:
45
+ section, key = parts
46
+
47
+ try:
48
+ value = self.parser[section][key]
49
+ return value if value else None
50
+ except KeyError:
51
+ return None
52
+
53
+ def get_strict(self, section: str, key: str) -> str:
54
+ value = self[section, key]
55
+ if value is None:
56
+ raise ValueError(f"Missing {section}/{key} value")
57
+
58
+ return value
59
+
60
+ def get(self, section: str, key: str, default: str) -> str:
61
+ value = self[section, key]
62
+ return value if value is not None else default
63
+
64
+ def configure_logging(self, *handlers) -> None:
65
+ level = self["Logging", "level"]
66
+ if not level:
67
+ raise RuntimeError("Logging level should have already been validated")
68
+
69
+ log_format = self["Logging", "format"]
70
+ if not log_format:
71
+ log_format = "[%(asctime)s] [%(levelname)s] [%(module)s] [%(filename)s:%(lineno)d %(funcName)s] %(message)s"
72
+
73
+ if handlers:
74
+ logging.basicConfig(level=level, format=log_format, handlers=handlers)
75
+ else:
76
+ fname = self["Logging", "file"]
77
+ if fname:
78
+ handler = WatchedFileHandler(fname)
79
+ logging.basicConfig(level=level, format=log_format, handlers=[handler])
80
+ else:
81
+ logging.basicConfig(level=level, format=log_format, stream=sys.stderr)
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ import sys, os
3
+
4
+ if sys.version_info < (3, 8):
5
+ raise RuntimeError("At least Python 3.8 is required")
6
+
7
+ import logging
8
+ from typing import Callable, Any, Dict, Optional
9
+ from functools import lru_cache
10
+ from threading import Lock
11
+
12
+
13
+ class Closeable(object):
14
+ def close(self) -> None:
15
+ raise NotImplementedError
16
+
17
+
18
+ class BaseConnectionPool(object):
19
+ def acquire(self) -> Closeable:
20
+ raise NotImplementedError
21
+
22
+ def release(self, connection: Closeable) -> None:
23
+ raise NotImplementedError
24
+
25
+ def error(self, connection: Closeable) -> None:
26
+ raise NotImplementedError
27
+
28
+
29
+ class ConnectionHandle(object):
30
+ def __init__(self, pool: BaseConnectionPool):
31
+ self.pool = pool
32
+ self.connection: Optional[Closeable] = None
33
+
34
+ def __enter__(self):
35
+ if self.connection is not None:
36
+ raise RuntimeError("Already acquired a connection")
37
+
38
+ self.connection = self.pool.acquire()
39
+ return self.connection
40
+
41
+ def __exit__(self, type, value, traceback):
42
+ if self.connection is None:
43
+ raise RuntimeError("No connection has been acquired")
44
+ try:
45
+ if value is not None:
46
+ self.pool.error(self.connection)
47
+ else:
48
+ self.pool.release(self.connection)
49
+ finally:
50
+ self.connection = None
51
+
52
+
53
+ class ConnectionPool(BaseConnectionPool):
54
+ def __init__(self, connection_src: Callable[[], Closeable]):
55
+ self.connection_src = connection_src
56
+ self.lock = Lock()
57
+ self.connections: Dict[Closeable, bool] = {}
58
+
59
+ def acquire(self) -> Closeable:
60
+ with self.lock:
61
+ for connection, in_use in self.connections.items():
62
+ if not in_use:
63
+ break
64
+ else:
65
+ connection = self.connection_src()
66
+
67
+ self.connections[connection] = True
68
+ return connection
69
+
70
+ def release(self, connection: Closeable) -> None:
71
+ with self.lock:
72
+ if connection not in self.connections:
73
+ raise RuntimeError(f"Unknown connection: {connection}")
74
+ self.connections[connection] = False
75
+
76
+ def error(self, connection: Closeable) -> None:
77
+ with self.lock:
78
+ if connection not in self.connections:
79
+ raise RuntimeError(f"Unknown connection: {connection}")
80
+ try:
81
+ connection.close()
82
+ except:
83
+ logging.exception(f"Failed to close {connection}")
84
+ del self.connections[connection]
85
+
86
+ def close(self) -> None:
87
+ with self.lock:
88
+ for connection in self.connections.keys():
89
+ try:
90
+ connection.close()
91
+ except:
92
+ logging.exception(f"Failed to close {connection}")
93
+
94
+ self.connections.clear()
95
+
96
+ def __call__(self) -> ConnectionHandle:
97
+ return ConnectionHandle(self)
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+
4
+ if sys.version_info < (3, 8):
5
+ raise RuntimeError("At least Python 3.8 is required")
6
+
7
+ from .base import *
8
+
9
+ try:
10
+ from .pg import (
11
+ Cursor as PgCursor,
12
+ Connection as PgConnection,
13
+ BaseTable as PgBaseTable,
14
+ )
15
+ except ImportError:
16
+ pass
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+
4
+ if sys.version_info < (3, 8):
5
+ raise RuntimeError("At least Python 3.8 is required")
6
+
7
+ from typing import Any, Iterator, Dict, List, Sequence, Optional, Iterable, Union, Tuple
8
+ import weakref
9
+
10
+
11
+ class BaseConnection(object):
12
+ def get(self) -> Any:
13
+ raise NotImplementedError
14
+
15
+ def commit(self) -> None:
16
+ raise NotImplementedError
17
+
18
+ def rollback(self) -> None:
19
+ raise NotImplementedError
20
+
21
+ def close(self) -> None:
22
+ raise NotImplementedError
23
+
24
+
25
+ class Cursor(object):
26
+ def get(self) -> Any:
27
+ raise NotImplementedError
28
+
29
+ def execute_direct(self, query, *args) -> Any:
30
+ raise NotImplementedError
31
+
32
+ def execute(self, query, *args) -> Iterator[Dict[str, Any]]:
33
+ raise NotImplementedError
34
+
35
+ def __iter__(self) -> Iterator[Dict[str, Any]]:
36
+ raise NotImplementedError
37
+
38
+ @property
39
+ def rowcount(self) -> int:
40
+ raise NotImplementedError
41
+
42
+ @property
43
+ def columns(self) -> List[str]:
44
+ raise NotImplementedError
45
+
46
+ def __enter__(self) -> Any:
47
+ raise NotImplementedError
48
+
49
+ def __exit__(self, type, value, traceback) -> None:
50
+ raise NotImplementedError
51
+
52
+
53
+ class Connection(BaseConnection):
54
+ def cursor(self) -> Cursor:
55
+ raise NotImplementedError
56
+
57
+
58
+ class BaseTable(object):
59
+ ORDERINGS = frozenset(["ASC", "DESC"])
60
+
61
+ def __init__(
62
+ self,
63
+ name: str,
64
+ primary_key: str,
65
+ columns: Sequence[str],
66
+ ordering: Optional[str] = "ASC",
67
+ ):
68
+ if not name:
69
+ raise ValueError("Invalid name: {name}")
70
+
71
+ if not columns:
72
+ raise ValueError(f"Invalid column list: {columns}")
73
+
74
+ if not primary_key:
75
+ raise ValueError(f"Invalid column list: {primary_key}")
76
+
77
+ self.columns = frozenset(columns)
78
+ if primary_key not in columns:
79
+ raise ValueError(f"{primary_key} is not in columns: {self.columns}")
80
+
81
+ self.name, self.primary_key = name, primary_key
82
+
83
+ self.ordering = ordering if ordering in self.ORDERINGS else "ASC"
84
+
85
+ def validate(self, columns: Iterable[str]) -> None:
86
+ invalid_columns = {col for col in columns if col not in self.columns}
87
+ if invalid_columns:
88
+ raise ValueError(f"Invalid columns: {invalid_columns}")
89
+
90
+ @staticmethod
91
+ def unpack(
92
+ items: Union[Dict[str, Any], List[Tuple[str, Any]]]
93
+ ) -> Tuple[Tuple[str], Tuple[Any]]:
94
+ if isinstance(items, dict):
95
+ columns, values = zip(*items.items())
96
+ else:
97
+ columns, values = zip(*items)
98
+ return columns, values
99
+
100
+ def insert(self, base_cursor: Cursor, **input_values: Any) -> Any:
101
+ raise NotImplementedError
102
+
103
+ def upsert_on_column(
104
+ self,
105
+ base_cursor: Cursor,
106
+ column_name: str,
107
+ column_value: Any,
108
+ **input_values: Any,
109
+ ) -> Any:
110
+ raise NotImplementedError
111
+
112
+ def upsert(self, base_cursor: Cursor, column_value: Any, **input_values: Any) -> Any:
113
+ return self.upsert_on_column(
114
+ base_cursor, self.primary_key, column_value, **input_values
115
+ )
116
+
117
+ def select_by_columns(
118
+ self, base_cursor: Cursor, limit: Optional[int] = None, **input_values: Any
119
+ ) -> Iterator[Dict[str, Any]]:
120
+ raise NotImplementedError
121
+
122
+ def select_custom_where(
123
+ self, base_cursor: Cursor, where: str, *values
124
+ ) -> Iterator[Dict[str, Any]]:
125
+ raise NotImplementedError
126
+
127
+ def select_by_id(self, base_cursor: Cursor, pkey_value: Any) -> Dict[str, Any]:
128
+ raise NotImplementedError
129
+
130
+ def delete(self, base_cursor: Cursor, pkey_value: Any) -> bool:
131
+ raise NotImplementedError
132
+
133
+
134
+ class TableCursor(object):
135
+ def __init__(self, cursor: Cursor, table: BaseTable):
136
+ self.cursor, self.table = weakref.ref(cursor), table
137
+
138
+ def get_cursor(self) -> Cursor:
139
+ cursor = self.cursor()
140
+ if cursor is None:
141
+ raise RuntimeError("Cursor has gone out of scope")
142
+ return cursor
143
+
144
+ def insert(self, **input_values: Any) -> Any:
145
+ return self.table.insert(self.get_cursor(), **input_values)
146
+
147
+ def upsert_on_column(
148
+ self, column_name: str, column_value: Any, **input_values: Any
149
+ ) -> Any:
150
+ return self.table.upsert_on_column(
151
+ self.get_cursor(), column_name, column_value, **input_values
152
+ )
153
+
154
+ def upsert(self, pkey_value: Any, **input_values: Any) -> Any:
155
+ return self.table.upsert(self.get_cursor(), pkey_value, **input_values)
156
+
157
+ def __setitem__(self, key: Any, value: Dict[str, Any]) -> Any:
158
+ self.upsert(key, **value)
159
+
160
+ def where(
161
+ self, limit: Optional[int], **input_values: Any
162
+ ) -> Iterator[Dict[str, Any]]:
163
+ return self.table.select_by_columns(self.get_cursor(), limit, **input_values)
164
+
165
+ def custom_where(self, where: str, *values) -> Iterator[Dict[str, Any]]:
166
+ return self.table.select_custom_where(self.get_cursor(), where, *values)
167
+
168
+ def __getitem__(self, key: Any) -> Dict[str, Any]:
169
+ return self.table.select_by_id(self.get_cursor(), key)
170
+
171
+ def __delitem__(self, key: Any) -> None:
172
+ if not self.table.delete(self.get_cursor(), key):
173
+ raise KeyError(key)