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.
- automation_entities-0.1.0/LICENSE +21 -0
- automation_entities-0.1.0/PKG-INFO +33 -0
- automation_entities-0.1.0/README.md +8 -0
- automation_entities-0.1.0/automation_entities/__init__.py +3 -0
- automation_entities-0.1.0/automation_entities/context.py +232 -0
- automation_entities-0.1.0/automation_entities/entities.py +139 -0
- automation_entities-0.1.0/automation_entities/py.typed +0 -0
- automation_entities-0.1.0/automation_entities/rest.py +61 -0
- automation_entities-0.1.0/automation_entities/utils.py +105 -0
- automation_entities-0.1.0/automation_entities/web_browser/__init__.py +7 -0
- automation_entities-0.1.0/automation_entities/web_browser/driver.py +95 -0
- automation_entities-0.1.0/automation_entities/web_browser/web_browser.py +703 -0
- automation_entities-0.1.0/automation_entities.egg-info/PKG-INFO +33 -0
- automation_entities-0.1.0/automation_entities.egg-info/SOURCES.txt +17 -0
- automation_entities-0.1.0/automation_entities.egg-info/dependency_links.txt +1 -0
- automation_entities-0.1.0/automation_entities.egg-info/top_level.txt +1 -0
- automation_entities-0.1.0/pyproject.toml +24 -0
- automation_entities-0.1.0/setup.cfg +4 -0
- automation_entities-0.1.0/setup.py +50 -0
|
@@ -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,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()
|