automation-entities 0.1.0__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Renken
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.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: automation-entities
3
+ Version: 0.1.0
4
+ Summary: Automate routines in a descriptive way
5
+ Home-page: https://github.com/SudoVim/automation-entities
6
+ Author: Michael Renken
7
+ Author-email: Michael Renken <michaelarenken@gmail.com>
8
+ License-Expression: MIT
9
+ Project-URL: Homepage, https://github.com/SudoVim/automation-entities
10
+ Project-URL: Issues, https://github.com/SudoVim/automation-entities/issues
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Requires-Python: >=3.8, <4
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: author
22
+ Dynamic: home-page
23
+ Dynamic: license-file
24
+ Dynamic: requires-python
25
+
26
+ # Automation Entities
27
+
28
+ automation-entities is a package designed to color automated routines for
29
+ maximum debugability. The purpose is to provide the tools to wrap different
30
+ entities (web browser, REST API, terminal, etc) such that, when they're
31
+ interacted with, it leaves a log trail that describes each interaction in
32
+ enough detail that the user can go in after the fact and figure out what went
33
+ wrong - if anything.
@@ -0,0 +1,8 @@
1
+ # Automation Entities
2
+
3
+ automation-entities is a package designed to color automated routines for
4
+ maximum debugability. The purpose is to provide the tools to wrap different
5
+ entities (web browser, REST API, terminal, etc) such that, when they're
6
+ interacted with, it leaves a log trail that describes each interaction in
7
+ enough detail that the user can go in after the fact and figure out what went
8
+ wrong - if anything.
@@ -0,0 +1,3 @@
1
+ """
2
+ module designed to interact with entities in an automated way
3
+ """
@@ -0,0 +1,232 @@
1
+ """
2
+ The :mod:`automation_entities.context` module is primarily concerned with the
3
+ :class:`Context` class. It contains the basic configuration and logging logic
4
+ that will be expanded upon in the higher up :class:`entities.Entity` class.
5
+ """
6
+
7
+ import collections.abc
8
+ import json
9
+ import os
10
+ import typing
11
+
12
+
13
+ class Subcontext:
14
+ """
15
+ object denoting a position lower down the context stack; this object
16
+ shouldn't be created directly but should be used by calling
17
+ :meth:`Context.subcontext`
18
+
19
+ .. autoattribute:: context
20
+ .. autoattribute:: log_position
21
+ """
22
+
23
+ #: the parent context
24
+ context: "Context"
25
+
26
+ #: the log position to save for the parent context
27
+ log_position: typing.Optional[int]
28
+
29
+ already_entered: bool
30
+
31
+ def __init__(self, context: "Context"):
32
+ self.context = context
33
+ self.log_position = None
34
+ self.already_entered = False
35
+
36
+ def log(self, msg: str) -> None:
37
+ """
38
+ Log given *msg* as a result of an interaction with this subcontext.
39
+ """
40
+ assert (
41
+ self.log_position is not None
42
+ ), "log must only be called in conjunction with __enter__"
43
+ self.context.log(msg)
44
+
45
+ def __enter__(self) -> "Subcontext":
46
+ assert not self.already_entered, "a subcontext may only be entered once"
47
+
48
+ self.log_position = self.context.log_position
49
+ self.already_entered = True
50
+ self.context.log_position += 1
51
+ return self
52
+
53
+ def __exit__(self, *args, **kwds) -> None:
54
+ assert (
55
+ self.log_position is not None
56
+ ), "__exit__ must only be called in conjunction with __enter__"
57
+
58
+ self.context.log_position = self.log_position
59
+ self.log_position = None
60
+
61
+
62
+ class Context:
63
+ """
64
+ The Context is an object that provides a common entrypoint into which
65
+ all information surrounding the current automated routine is sent.
66
+
67
+ :param dict config_defaults: default configuration values to give to
68
+ the :attr:`config` object
69
+
70
+ .. autoattribute:: CONTEXT_DEPTH
71
+
72
+ .. autoattribute:: log_position
73
+ .. autoattribute:: config
74
+
75
+ .. automethod:: log
76
+ .. automethod:: subcontext
77
+ .. automethod:: set_config_file
78
+ """
79
+
80
+ #: the number of spaces to use when logging from this context
81
+ CONTEXT_DEPTH: int = 4
82
+
83
+ #: the current log position
84
+ log_position: int
85
+
86
+ subcontext_class: typing.Type["Subcontext"]
87
+
88
+ #: the configuration represented by this context
89
+ config: "Config"
90
+
91
+ def __init__(
92
+ self,
93
+ config_defaults: typing.Optional[dict] = None,
94
+ subcontext_class: typing.Optional[typing.Type["Subcontext"]] = None,
95
+ ) -> None:
96
+ self.log_position = 0
97
+ self.subcontext_class = subcontext_class or Subcontext
98
+ self.config = Config(defaults=config_defaults)
99
+
100
+ def log(self, message: str) -> None:
101
+ """
102
+ log given *message* to the context
103
+ """
104
+ spaces = " " * (self.log_position * self.CONTEXT_DEPTH)
105
+ print(f"{spaces}{message}")
106
+
107
+ def subcontext(self, message: str) -> Subcontext:
108
+ """
109
+ Log the given *message* to create a new subcontext. This method
110
+ returns a context manager that will increment the
111
+ :attr:`log_position` on *__enter__* and decrement it back on
112
+ *__exit__*::
113
+
114
+ >>> with ctx.subcontext("sub context"):
115
+ ... ctx.log("subcontext message")
116
+ ...
117
+ sub context
118
+ subcontext message
119
+
120
+ :rtype: Subcontext
121
+ :returns: subcontext object
122
+ """
123
+ self.log(message)
124
+ return self.subcontext_class(self)
125
+
126
+ def set_config_file(self, filepath: str) -> None:
127
+ """
128
+ set config file to the given *filepath*
129
+ """
130
+ self.config.set_filepath(filepath)
131
+
132
+
133
+ def patch_dict(original: dict, patch: dict) -> None:
134
+ """
135
+ Patch given *original* dict with the new *patch*. This function
136
+ recursively iterates over all keys in the patch and applies them to
137
+ existing keys in the original. It does not replace a sub-dict. Rather,
138
+ it recursively replaces its keys.
139
+ """
140
+ for k, v in patch.items():
141
+ if k not in original or not isinstance(v, dict):
142
+ original[k] = v
143
+ elif isinstance(v, dict):
144
+ patch_dict(original[k], v)
145
+
146
+
147
+ class Config(collections.abc.MutableMapping):
148
+ """
149
+ The Config object represents the configuration of the current context.
150
+ It's used as a ``dict`` object and can be instantiated with default
151
+ values. If the :meth:`set_filepath` method is called, the :meth:`load`
152
+ and :meth:`persist` methods can be used to load configuration data from
153
+ the filepath and persist it to the filepath, respectively.
154
+
155
+ .. attribute:: filepath
156
+
157
+ ``str`` filepath containing the configuration
158
+
159
+ .. automethod:: patch
160
+ .. automethod:: set_filepath
161
+ .. automethod:: persist
162
+ .. automethod:: load
163
+ """
164
+
165
+ filepath: typing.Optional[str]
166
+ defaults: typing.Optional[dict]
167
+ _data: dict
168
+
169
+ def __init__(self, defaults: typing.Optional[dict] = None):
170
+ self.filepath = None
171
+ self.defaults = defaults
172
+ self._data = {}
173
+
174
+ if self.defaults is not None:
175
+ self.patch(self.defaults)
176
+
177
+ def patch(self, new_vals: dict) -> None:
178
+ """
179
+ patch given original ``dict`` with new values
180
+ """
181
+ patch_dict(self._data, new_vals)
182
+
183
+ def set_filepath(self, filepath: str) -> None:
184
+ """
185
+ set persistence filepath to *filepath*
186
+ """
187
+ self.filepath = filepath
188
+ self.load()
189
+
190
+ def persist(self) -> None:
191
+ """
192
+ persist new config to the file
193
+ """
194
+ assert self.filepath is not None, "filepath must be set before calling persist"
195
+
196
+ with open(self.filepath, "w") as fobj:
197
+ json.dump(
198
+ self._data,
199
+ fobj,
200
+ sort_keys=True,
201
+ indent=4,
202
+ )
203
+
204
+ def load(self) -> None:
205
+ """
206
+ load config from file
207
+ """
208
+ assert self.filepath is not None, "filepath must be set before calling load"
209
+
210
+ if os.path.isfile(self.filepath):
211
+ with open(self.filepath) as fobj:
212
+ self.patch(json.load(fobj))
213
+
214
+ self.persist()
215
+
216
+ def __getitem__(self, key: typing.Any) -> typing.Any:
217
+ return self._data.__getitem__(key)
218
+
219
+ def __setitem__(self, key: typing.Any, val: typing.Any) -> None:
220
+ self._data.__setitem__(key, val)
221
+
222
+ def __delitem__(self, key: typing.Any) -> None:
223
+ self._data.__delitem__(key)
224
+
225
+ def __iter__(self) -> typing.Iterator:
226
+ return self._data.__iter__()
227
+
228
+ def __len__(self) -> int:
229
+ return self._data.__len__()
230
+
231
+
232
+ background = Context()
@@ -0,0 +1,139 @@
1
+ """
2
+ An entity is something that can be interacted with. This abstraction will be
3
+ used as a way to log interactions with remote components.
4
+ """
5
+
6
+ import functools
7
+ import time
8
+ from typing import Any, Callable, Optional, TypeVar
9
+
10
+ from .context import Context, Subcontext
11
+
12
+ D = TypeVar("D", bound=Callable)
13
+
14
+
15
+ def describe(fcn: D) -> D:
16
+ """
17
+ Decorator for :class:`Entity` instance methods that will describe the
18
+ method call to the entity's context on entrance and print its return
19
+ value on exit.
20
+ """
21
+
22
+ @functools.wraps(fcn)
23
+ def inner(self: "Entity", *args, **kwds) -> Any:
24
+ assert isinstance(self, Entity), "received invalid 'self' value type"
25
+
26
+ arg_toks = [repr(a) for a in args] + [f"{k}={repr(v)}" for k, v in kwds.items()]
27
+ arg_str = ", ".join(arg_toks)
28
+ if len(arg_str) > 100:
29
+ arg_str = arg_str[:97] + "..."
30
+ with self.context.subcontext(f"{fcn.__qualname__}({arg_str}):") as sctx:
31
+ ret = fcn(self, *args, **kwds)
32
+ ret_str = repr(ret)
33
+ if len(ret_str) > 100:
34
+ ret_str = ret_str[:97] + "..."
35
+ sctx.log(f"Return: {ret_str}")
36
+ return ret
37
+
38
+ return inner
39
+
40
+
41
+ class SubInteraction(object):
42
+ """
43
+ Context manager that surrounds a sub-interaction of an entity. This
44
+ class shouldn't be instantiated on its own but should instead be
45
+ returned by calling :meth:`Entity.request` or :meth:`Entity.result`.
46
+
47
+ .. automethod:: log
48
+ """
49
+
50
+ context: Context
51
+ entity: "Entity"
52
+
53
+ def __init__(
54
+ self,
55
+ context: Context,
56
+ entity: "Entity",
57
+ delimeter: str,
58
+ message: Optional[str] = None,
59
+ ):
60
+ self.context = context
61
+ self.entity = entity
62
+
63
+ text = delimeter
64
+ if message:
65
+ text += f" {message}"
66
+
67
+ self.subcontext = self.context.subcontext(text)
68
+
69
+ def __enter__(self) -> "SubInteraction":
70
+ self.subcontext.__enter__()
71
+ return self
72
+
73
+ def __exit__(self, *args, **kwds) -> None:
74
+ self.subcontext.__exit__(*args, **kwds)
75
+
76
+ def log(self, msg: str) -> None:
77
+ """
78
+ log given *msg* as a result of an interaction with an entity
79
+ """
80
+ self.subcontext.log(msg)
81
+
82
+
83
+ class Entity(object):
84
+ """
85
+ This class serves as a base for any object who's purpose is to interact
86
+ with an entity for automation purposes. With this class, any interaction
87
+ with the entity can be described in such a way where the automated
88
+ routine can be debugged after the fact without the need to reproduce it.
89
+
90
+ :param Context context: context to associate with this entity
91
+ :param str name: name of this entity to use in logging
92
+
93
+ .. autoattribute:: context
94
+ .. autoattribute:: name
95
+
96
+ .. automethod:: interaction
97
+ .. automethod:: request
98
+ .. automethod:: result
99
+ .. automethod:: sleep
100
+ """
101
+
102
+ #: object representing the relevant context for this entity
103
+ context: Context
104
+
105
+ #: name of the entity
106
+ name: str
107
+
108
+ def __init__(self, context: Context, name: str):
109
+ self.context = context
110
+ self.name = name
111
+
112
+ def interaction(self) -> "Subcontext":
113
+ """
114
+ log an interaction with the entity; can be used as a context manager
115
+ or not
116
+ """
117
+ return self.context.subcontext(f"{self.name}:")
118
+
119
+ def request(self, msg: Optional[str] = None) -> "SubInteraction":
120
+ """
121
+ log an interaction request; use as a context manager to
122
+ return a :class:`SubInteraction` instance
123
+ """
124
+ return SubInteraction(self.context, self, "<<<", message=msg)
125
+
126
+ def result(self, msg: Optional[str] = None) -> "SubInteraction":
127
+ """
128
+ log the result of an interaction; use as a context manager to
129
+ return a :class:`SubInteraction` instance
130
+ """
131
+ return SubInteraction(self.context, self, ">>>", message=msg)
132
+
133
+ def sleep(self, sleep_time: float) -> None:
134
+ """
135
+ Sleep for the given *sleep_time* amount of seconds.
136
+ """
137
+ with self.interaction():
138
+ self.request(f"sleep {sleep_time}")
139
+ time.sleep(sleep_time)
File without changes
@@ -0,0 +1,61 @@
1
+ from typing import Any
2
+
3
+ import requests
4
+
5
+ from .context import Context
6
+ from .entities import Entity
7
+
8
+
9
+ class RestEntity(Entity):
10
+ """
11
+ entity for interacting with a REST API
12
+
13
+ .. attribute:: base_url
14
+
15
+ ``str`` representing the base URL of the REST API endpoint
16
+ """
17
+
18
+ def __init__(self, context: Context, base_url: str) -> None:
19
+ self.context = context
20
+ self.base_url = base_url
21
+ super().__init__(context, self.base_url)
22
+
23
+ def build_url(self, path: str) -> str:
24
+ """
25
+ build URL by appending the given *path* to the end of the
26
+ :attr:`base_url`
27
+ """
28
+ return self.base_url.rstrip("/") + "/" + path.lstrip("/")
29
+
30
+ def new_request(self, method: str, path: str, **kwds: Any) -> requests.Request:
31
+ """
32
+ create and return a new :class:`requests.Request` instance with the
33
+ given parameters
34
+ """
35
+ return requests.Request(method, self.build_url(path), **kwds)
36
+
37
+ def send_request(self, request: requests.PreparedRequest) -> requests.Response:
38
+ """
39
+ send the given :class:`requests.Request` or
40
+ :class:`requests.PreparedRequest`
41
+ """
42
+ with self.interaction():
43
+ self.request(f"{request.method} {request.url}")
44
+
45
+ session = requests.Session()
46
+ resp = session.send(request)
47
+ with self.result() as result:
48
+ result.log(resp.content.decode())
49
+ return resp
50
+
51
+ def assert_send_request(
52
+ self, request: requests.PreparedRequest
53
+ ) -> requests.Response:
54
+ """
55
+ send the given :class:`requests.Request` or
56
+ :class:`requests.PreparedRequest` and assert on its status code
57
+ """
58
+ resp = self.send_request(request)
59
+ if resp.status_code < 200 or resp.status_code >= 400:
60
+ raise AssertionError(f"[{resp.status_code}] {resp.text}")
61
+ return resp
@@ -0,0 +1,105 @@
1
+ """
2
+ The :mod:`utilities` module contains utilities for the automation_entities
3
+ library - namely, the :func:`try_timeout` function.
4
+ """
5
+
6
+ import time
7
+ from typing import Callable, Final, Optional, Tuple, Type, TypeVar
8
+
9
+ #: default value to display for a :class:`SecretString`
10
+ SECRET_STRING_DISPLAY: Final[str] = "'" + ("*" * 10) + "'"
11
+
12
+
13
+ class SecretString(str):
14
+ """
15
+ string that should be displayed as a sequence of asterisks. These will
16
+ not show up in entity logs.
17
+ """
18
+
19
+ def __repr__(self) -> str:
20
+ return SECRET_STRING_DISPLAY
21
+
22
+
23
+ Timeout = Optional[float]
24
+
25
+ #: default timeout value for :func:`try_timeout`
26
+ DEFAULT_TIMEOUT: Final[float] = 60.0
27
+
28
+ #: default step value for :func:`try_timeout`
29
+ DEFAULT_STEP: Final[float] = 1.0
30
+
31
+ #: default step backoff exponent for :func:`try_timeout`
32
+ DEFAULT_STEP_EXP: Final[float] = 2.0
33
+
34
+
35
+ class TryAgain(Exception):
36
+ pass
37
+
38
+
39
+ class TimedOut(AssertionError):
40
+ pass
41
+
42
+
43
+ #: default tuple of exceptions to ignore for :func:`try_timeout`
44
+ DEFAULT_IGNORE_EXCEPTIONS: Final[Tuple] = (TryAgain,)
45
+
46
+ R = TypeVar("R")
47
+
48
+
49
+ def try_timeout(
50
+ fcn: Callable[[], R],
51
+ timeout: Timeout = None,
52
+ step: Optional[float] = None,
53
+ step_exp: Optional[float] = None,
54
+ ignore_exceptions: Optional[Tuple[Type[Exception], ...]] = None,
55
+ retry_action: Optional[Callable[[], None]] = None,
56
+ ) -> R:
57
+ """
58
+ Try running the given function until a timeout is reached.
59
+
60
+ * If the function returns, the return value of the function will be
61
+ returned.
62
+ * If the function raises a :class:`TryAgain` exception, it will be
63
+ retried. Any other exception will be raised up to the calling
64
+ method unless *ignore_exceptions* is specified.
65
+ * If the timeout is reached, a :class:`TimedOut` exception will be
66
+ raised.
67
+
68
+ If arguments and keywords are desired in the function call, it's
69
+ recommended to use the :func:`functools.partial` function to generate a
70
+ function that includes the desired args and keywords.
71
+
72
+ :param fcn: function to call
73
+ :param Timeout timeout: number of seconds (or a fraction thereof) to
74
+ allow for the function to complete
75
+ :param float step: number of seconds (or a fraction thereof) to wait
76
+ between the first and second calls
77
+ :param float step_exp: exponent to use to calculate the next step for
78
+ exponential backoff of retries (or 1 for no exponential backoff)
79
+ :param tuple ignore_exceptions: subclasses of :class:`Exception` to
80
+ indicate that the function should be retried; if this value is
81
+ specified, :class:`TryAgain` needs to be appended if it's also to
82
+ be used to indicate the action should be retried
83
+ param retry_action: function to call before subsequent retries
84
+ """
85
+ timeout = timeout or DEFAULT_TIMEOUT
86
+ step = step or DEFAULT_STEP
87
+ step_exp = step_exp or DEFAULT_STEP_EXP
88
+ ignore_exceptions = ignore_exceptions or (TryAgain,)
89
+ start_time = time.time()
90
+ while True:
91
+ try:
92
+ return fcn()
93
+
94
+ except Exception as e:
95
+ if not isinstance(e, ignore_exceptions):
96
+ raise
97
+
98
+ curr_time = time.time()
99
+ if curr_time < start_time or curr_time > start_time + timeout:
100
+ raise TimedOut("timeout reached")
101
+
102
+ time.sleep(min(step, (start_time + timeout) - curr_time))
103
+ step *= step_exp
104
+ if retry_action is not None:
105
+ retry_action()
@@ -0,0 +1,7 @@
1
+ """
2
+ Module for interacting with web page as a web browser.
3
+ """
4
+
5
+ from .web_browser import Element, WebBrowser
6
+
7
+ __all__ = ["Element", "WebBrowser"]