automation-traverse 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_traverse-0.1.0/PKG-INFO +23 -0
- automation_traverse-0.1.0/README.md +5 -0
- automation_traverse-0.1.0/automation_traverse/__init__.py +1 -0
- automation_traverse-0.1.0/automation_traverse/asserter.py +48 -0
- automation_traverse-0.1.0/automation_traverse/context.py +202 -0
- automation_traverse-0.1.0/automation_traverse/discover.py +85 -0
- automation_traverse-0.1.0/automation_traverse/emitters/__init__.py +3 -0
- automation_traverse-0.1.0/automation_traverse/emitters/emitter.py +152 -0
- automation_traverse-0.1.0/automation_traverse/emitters/simple_log.py +79 -0
- automation_traverse-0.1.0/automation_traverse/emitters/terminal.py +67 -0
- automation_traverse-0.1.0/automation_traverse/emitters/test_emitter.py +35 -0
- automation_traverse-0.1.0/automation_traverse/emitters/test_simple_log.py +78 -0
- automation_traverse-0.1.0/automation_traverse/emitters/test_terminal.py +54 -0
- automation_traverse-0.1.0/automation_traverse/runner.py +697 -0
- automation_traverse-0.1.0/automation_traverse/task.py +471 -0
- automation_traverse-0.1.0/automation_traverse/test_context/__init__.py +0 -0
- automation_traverse-0.1.0/automation_traverse/test_context/test_traverse_context/__init__.py +0 -0
- automation_traverse-0.1.0/automation_traverse/test_context/test_traverse_context/common.py +10 -0
- automation_traverse-0.1.0/automation_traverse/test_context/test_traverse_context/test_add_emitter.py +12 -0
- automation_traverse-0.1.0/automation_traverse/test_context/test_traverse_context/test_pop_subcontext.py +17 -0
- automation_traverse-0.1.0/automation_traverse/test_context/test_traverse_context/test_subcontext.py +31 -0
- automation_traverse-0.1.0/automation_traverse/test_context/test_traverse_file.py +63 -0
- automation_traverse-0.1.0/automation_traverse/test_context/test_traverse_subcontext.py +38 -0
- automation_traverse-0.1.0/automation_traverse/test_discover/__init__.py +0 -0
- automation_traverse-0.1.0/automation_traverse/test_discover/test_import_module.py +62 -0
- automation_traverse-0.1.0/automation_traverse.egg-info/PKG-INFO +23 -0
- automation_traverse-0.1.0/automation_traverse.egg-info/SOURCES.txt +29 -0
- automation_traverse-0.1.0/automation_traverse.egg-info/dependency_links.txt +1 -0
- automation_traverse-0.1.0/automation_traverse.egg-info/top_level.txt +1 -0
- automation_traverse-0.1.0/pyproject.toml +24 -0
- automation_traverse-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: automation-traverse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automate tasks in graph format
|
|
5
|
+
Author-email: Michael Renken <michaelarenken@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SudoVim/automation-traverse
|
|
8
|
+
Project-URL: Issues, https://github.com/SudoVim/automation-traverse/issues
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# automation-traverse
|
|
20
|
+
|
|
21
|
+
Project for setting up a graph of nodes that can be traversed as part of a
|
|
22
|
+
suite of automated tasks. This project builds on top of the
|
|
23
|
+
"automation-entities" project.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
module pertaining to asserting
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pprint
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
from automation_entities.context import Context
|
|
9
|
+
from automation_entities.entities import Entity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DictAsserter(Entity):
|
|
13
|
+
"""
|
|
14
|
+
asserter for asserting on ``dict`` keys and values
|
|
15
|
+
|
|
16
|
+
.. attribute:: val
|
|
17
|
+
|
|
18
|
+
``dict`` representing the dict values
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
val: Dict[Any, Any]
|
|
22
|
+
|
|
23
|
+
def __init__(self, context: Context, val: Dict[Any, Any]) -> None:
|
|
24
|
+
self.val = val
|
|
25
|
+
super().__init__(context, pprint.pformat(val))
|
|
26
|
+
|
|
27
|
+
def get_value(self, key: Any) -> Any:
|
|
28
|
+
"""
|
|
29
|
+
get and return the value corresponding to the given *key*
|
|
30
|
+
"""
|
|
31
|
+
with self.interaction():
|
|
32
|
+
_ = self.request(f"get_value {key}")
|
|
33
|
+
|
|
34
|
+
if key not in self.val:
|
|
35
|
+
raise AssertionError(f"{self.val} does not contain key {key}")
|
|
36
|
+
|
|
37
|
+
with self.result() as result:
|
|
38
|
+
val = self.val[key]
|
|
39
|
+
result.log(f"Val: {pprint.pformat(val)}")
|
|
40
|
+
|
|
41
|
+
if isinstance(val, dict):
|
|
42
|
+
val = self.__class__(
|
|
43
|
+
self.context, val # pyright: ignore[reportUnknownArgumentType]
|
|
44
|
+
)
|
|
45
|
+
return val
|
|
46
|
+
|
|
47
|
+
def __getitem__(self, key: Any) -> Any:
|
|
48
|
+
return self.get_value(key)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from typing import IO, Any, Dict, Generic, List, Optional, Type
|
|
2
|
+
|
|
3
|
+
from automation_entities.context import Context, Subcontext
|
|
4
|
+
from typing_extensions import override
|
|
5
|
+
|
|
6
|
+
from .emitters import Emitter, LogLevel, T
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TraverseContext(Context, Generic[T]):
|
|
10
|
+
"""
|
|
11
|
+
:class:`automation_entities.context.Context` that supports multiple
|
|
12
|
+
:class:`Emitter` s.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
emitters: List[Emitter[T]]
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
config_defaults: Optional[ # pyright: ignore[reportUnknownParameterType]
|
|
20
|
+
dict # pyright: ignore[reportMissingTypeArgument]
|
|
21
|
+
] = None,
|
|
22
|
+
subcontext_class: Optional[Type[Subcontext]] = None,
|
|
23
|
+
):
|
|
24
|
+
super().__init__( # pyright: ignore[reportUnknownMemberType]
|
|
25
|
+
config_defaults, subcontext_class
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
self.emitters = []
|
|
29
|
+
|
|
30
|
+
def add_emitter(self, emitter: Emitter[T]) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Add the given *emitter* to the :attr:`emitters` list.
|
|
33
|
+
"""
|
|
34
|
+
self.emitters.append(emitter)
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
def subcontext(
|
|
38
|
+
self, message: str, log_level: LogLevel = LogLevel.INFO
|
|
39
|
+
) -> "TraverseSubcontext[T]":
|
|
40
|
+
"""
|
|
41
|
+
subcontext implementation that creates a :class:`TraverseSubcontext`
|
|
42
|
+
subcontext rather than the original.
|
|
43
|
+
"""
|
|
44
|
+
for emitter in self.emitters:
|
|
45
|
+
emitter.log_message(log_level, message)
|
|
46
|
+
|
|
47
|
+
return TraverseSubcontext(self, self)
|
|
48
|
+
|
|
49
|
+
def pop_subcontext(self, context_level: int) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Pop all emitters back to the given *context_level*.
|
|
52
|
+
"""
|
|
53
|
+
for emitter in self.emitters:
|
|
54
|
+
emitter.pop_subcontext(context_level)
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
def log(self, message: str) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Log the given *message* as info.
|
|
60
|
+
"""
|
|
61
|
+
self.log_info(message)
|
|
62
|
+
|
|
63
|
+
def log_file(
|
|
64
|
+
self, description: str, extension: str, mode: str = "w"
|
|
65
|
+
) -> "TraverseFile":
|
|
66
|
+
"""
|
|
67
|
+
Log the given *description* and create a file of the given *extension*
|
|
68
|
+
and *mode*.
|
|
69
|
+
"""
|
|
70
|
+
return TraverseFile(
|
|
71
|
+
[e.log_file(description, extension, mode) for e in self.emitters],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def log_message(self, log_level: LogLevel, message: str) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Log the given *message* at the given *log_level* to all configured
|
|
77
|
+
emitters. For more information on *log_level*, see
|
|
78
|
+
:meth:`emitters.Emitter.log_message`.
|
|
79
|
+
"""
|
|
80
|
+
for emitter in self.emitters:
|
|
81
|
+
emitter.log_message(log_level, message)
|
|
82
|
+
|
|
83
|
+
def log_debug(self, message: str) -> None:
|
|
84
|
+
"""
|
|
85
|
+
log the given *message* at debug level.
|
|
86
|
+
"""
|
|
87
|
+
self.log_message(LogLevel.DEBUG, message)
|
|
88
|
+
|
|
89
|
+
def log_procedure(self, message: str) -> None:
|
|
90
|
+
"""
|
|
91
|
+
log the given *message* at procedure level.
|
|
92
|
+
"""
|
|
93
|
+
self.log_message(LogLevel.PROCEDURE, message)
|
|
94
|
+
|
|
95
|
+
def log_info(self, message: str) -> None:
|
|
96
|
+
"""
|
|
97
|
+
log the given *message* at info level.
|
|
98
|
+
"""
|
|
99
|
+
self.log_message(LogLevel.INFO, message)
|
|
100
|
+
|
|
101
|
+
def log_skip(self, message: str) -> None:
|
|
102
|
+
"""
|
|
103
|
+
log the given *message* at skip level.
|
|
104
|
+
"""
|
|
105
|
+
self.log_message(LogLevel.SKIP, message)
|
|
106
|
+
|
|
107
|
+
def log_success(self, message: str) -> None:
|
|
108
|
+
"""
|
|
109
|
+
log the given *message* at success level.
|
|
110
|
+
"""
|
|
111
|
+
self.log_message(LogLevel.SUCCESS, message)
|
|
112
|
+
|
|
113
|
+
def log_error(self, message: str) -> None:
|
|
114
|
+
"""
|
|
115
|
+
log the given *message* at error level.
|
|
116
|
+
"""
|
|
117
|
+
self.log_message(LogLevel.ERROR, message)
|
|
118
|
+
|
|
119
|
+
def log_fail(self, message: str) -> None:
|
|
120
|
+
"""
|
|
121
|
+
log the given *message* at fail level.
|
|
122
|
+
"""
|
|
123
|
+
self.log_message(LogLevel.FAIL, message)
|
|
124
|
+
|
|
125
|
+
def log_catastrophic(self, message: str) -> None:
|
|
126
|
+
"""
|
|
127
|
+
log the given *message* at catastrophic level.
|
|
128
|
+
"""
|
|
129
|
+
self.log_message(LogLevel.CATASTROPHIC, message)
|
|
130
|
+
|
|
131
|
+
def log_response(self, task: T, response: Dict[str, Any]) -> None:
|
|
132
|
+
"""
|
|
133
|
+
log the given *response* for the given *task*
|
|
134
|
+
"""
|
|
135
|
+
for emitter in self.emitters:
|
|
136
|
+
emitter.log_response(task, response)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TraverseSubcontext(Subcontext, Generic[T]):
|
|
140
|
+
"""
|
|
141
|
+
:class:`automation_entities.context.Subcontext` that supports multiple
|
|
142
|
+
:class:`automation_traverse.emitters.Emitter` s.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
tranverse_context: "TraverseContext[T]"
|
|
146
|
+
|
|
147
|
+
def __init__(self, context: "Context", tranverse_context: "TraverseContext[T]"):
|
|
148
|
+
super().__init__(context)
|
|
149
|
+
|
|
150
|
+
self.tranverse_context = tranverse_context
|
|
151
|
+
|
|
152
|
+
@override
|
|
153
|
+
def __enter__(self) -> "TraverseSubcontext[T]":
|
|
154
|
+
_ = super().__enter__()
|
|
155
|
+
|
|
156
|
+
for emitter in self.tranverse_context.emitters:
|
|
157
|
+
emitter.subcontext()
|
|
158
|
+
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
@override
|
|
162
|
+
def __exit__(self, *args: Any, **kwds: Any) -> None:
|
|
163
|
+
super().__exit__(*args, **kwds) # pyright: ignore[reportUnknownMemberType]
|
|
164
|
+
|
|
165
|
+
self.tranverse_context.pop_subcontext(self.tranverse_context.log_position)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TraverseFile:
|
|
169
|
+
"""
|
|
170
|
+
File-like object that multiplexes multiple file-like objects into one
|
|
171
|
+
object.
|
|
172
|
+
|
|
173
|
+
.. autoattribute:: fobjs
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
#: file objects wrapped by this class
|
|
177
|
+
fobjs: List[IO[str]]
|
|
178
|
+
|
|
179
|
+
def __init__(self, fobjs: List[IO[str]]) -> None:
|
|
180
|
+
self.fobjs = fobjs
|
|
181
|
+
|
|
182
|
+
def __enter__(self) -> "TraverseFile":
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def __exit__(self, *args: Any, **kwds: Any) -> None:
|
|
186
|
+
self.close()
|
|
187
|
+
|
|
188
|
+
def write(self, data: str) -> int:
|
|
189
|
+
"""
|
|
190
|
+
Write *data* to all :attr:`fobjs`
|
|
191
|
+
"""
|
|
192
|
+
if not self.fobjs:
|
|
193
|
+
return len(data)
|
|
194
|
+
|
|
195
|
+
return min(fobj.write(data) for fobj in self.fobjs)
|
|
196
|
+
|
|
197
|
+
def close(self) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Close all :attr:`fobjs`
|
|
200
|
+
"""
|
|
201
|
+
for fobj in self.fobjs:
|
|
202
|
+
fobj.close()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
module for discovering objects in Python
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Any, Dict, Iterator, Tuple, Type
|
|
9
|
+
|
|
10
|
+
from .task import Task
|
|
11
|
+
|
|
12
|
+
ImportedModuleKey = Tuple[str, str]
|
|
13
|
+
|
|
14
|
+
imported_modules: Dict[ImportedModuleKey, ModuleType] = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def import_module(pypath: str, filepath: str) -> ModuleType:
|
|
18
|
+
"""
|
|
19
|
+
Import and return the module at the given *pypath* and *filepath*
|
|
20
|
+
"""
|
|
21
|
+
key = (pypath, filepath)
|
|
22
|
+
if key not in imported_modules:
|
|
23
|
+
spec = importlib.util.spec_from_file_location(pypath, filepath)
|
|
24
|
+
if spec is None or spec.loader is None:
|
|
25
|
+
raise ValueError(f"module {key} not found")
|
|
26
|
+
|
|
27
|
+
new_module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(new_module)
|
|
29
|
+
imported_modules[key] = new_module
|
|
30
|
+
|
|
31
|
+
return imported_modules[key]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def walk_modules(start_point: str) -> Iterator[Tuple[str, ModuleType]]:
|
|
35
|
+
"""
|
|
36
|
+
walk over all modules from the given *start_point*
|
|
37
|
+
"""
|
|
38
|
+
start_point = os.path.abspath(start_point)
|
|
39
|
+
for root, _, files in os.walk(start_point):
|
|
40
|
+
relpath = os.path.relpath(root, os.path.dirname(start_point))
|
|
41
|
+
module_path = tuple(relpath.split(os.path.sep))
|
|
42
|
+
|
|
43
|
+
for fname in files:
|
|
44
|
+
if fname == "__init__.py":
|
|
45
|
+
filepath = os.path.join(root, fname)
|
|
46
|
+
pypath = ".".join(module_path)
|
|
47
|
+
new_module = import_module(pypath, filepath)
|
|
48
|
+
yield pypath, new_module
|
|
49
|
+
|
|
50
|
+
elif fname.endswith("__.py"):
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
elif fname.endswith(".py"):
|
|
54
|
+
filepath = os.path.join(root, fname)
|
|
55
|
+
pypath = ".".join(module_path + (fname[:-3],))
|
|
56
|
+
new_module = import_module(pypath, filepath)
|
|
57
|
+
yield pypath, new_module
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def walk_object(obj: Any) -> Iterator[Any]:
|
|
61
|
+
"""
|
|
62
|
+
Walk over the given object for its members
|
|
63
|
+
"""
|
|
64
|
+
for attr in dir(obj):
|
|
65
|
+
yield attr, obj.__getattribute__(attr)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def walk_tasks(
|
|
69
|
+
start_point: str,
|
|
70
|
+
discover_all: bool = False,
|
|
71
|
+
) -> Iterator[Tuple[str, str, Type[Task]]]:
|
|
72
|
+
"""
|
|
73
|
+
Walk over a module and return its tasks
|
|
74
|
+
|
|
75
|
+
:param bool discover_all: whether or not to discover tasks not marked
|
|
76
|
+
as discoverable
|
|
77
|
+
"""
|
|
78
|
+
for modulepath, module in walk_modules(start_point):
|
|
79
|
+
for objname, obj in walk_object(module):
|
|
80
|
+
if (
|
|
81
|
+
isinstance(obj, type)
|
|
82
|
+
and issubclass(obj, Task)
|
|
83
|
+
and (discover_all or obj.DISCOVER)
|
|
84
|
+
):
|
|
85
|
+
yield modulepath, objname, obj
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import os
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import IO, Any, Dict, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LogLevel(Enum):
|
|
8
|
+
"""
|
|
9
|
+
Enum indicating the different valid log levels when logging messages to an
|
|
10
|
+
emitter.
|
|
11
|
+
|
|
12
|
+
.. autoattribute:: DEBUG
|
|
13
|
+
.. autoattribute:: PROCEDURE
|
|
14
|
+
.. autoattribute:: INFO
|
|
15
|
+
.. autoattribute:: SKIP
|
|
16
|
+
.. autoattribute:: SUCCESS
|
|
17
|
+
.. autoattribute:: ERROR
|
|
18
|
+
.. autoattribute:: FAIL
|
|
19
|
+
.. autoattribute:: CATASTROPHIC
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
#: Messages meant to provide deeper insight that isn't always necessary
|
|
23
|
+
DEBUG = "debug"
|
|
24
|
+
|
|
25
|
+
#: Messages pertaining to the traversal procedure
|
|
26
|
+
PROCEDURE = "procedure"
|
|
27
|
+
|
|
28
|
+
#: Informational messages about what's happening in the task
|
|
29
|
+
INFO = "info"
|
|
30
|
+
|
|
31
|
+
#: Message indicating text related to a task skip
|
|
32
|
+
SKIP = "skip"
|
|
33
|
+
|
|
34
|
+
#: Message indicating some level of success
|
|
35
|
+
SUCCESS = "success"
|
|
36
|
+
|
|
37
|
+
#: Something unexpected happened which may not be recoverable
|
|
38
|
+
ERROR = "error"
|
|
39
|
+
|
|
40
|
+
#: A specific condition in the task has failed and is likely recoverable
|
|
41
|
+
FAIL = "fail"
|
|
42
|
+
|
|
43
|
+
#: Something unrecoverrable happened that requires human attention
|
|
44
|
+
CATASTROPHIC = "catastrophic"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
T = TypeVar("T")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Emitter(Generic[T]):
|
|
51
|
+
"""
|
|
52
|
+
An emitter is an abstraction that exists to determine how various debug
|
|
53
|
+
information is to be passed on to the user. This is done through the
|
|
54
|
+
various hooks that are required to be implememented.
|
|
55
|
+
|
|
56
|
+
.. attribute:: tasks
|
|
57
|
+
|
|
58
|
+
:var:`T`\\s run under this emitter
|
|
59
|
+
|
|
60
|
+
.. attribute:: context_level
|
|
61
|
+
|
|
62
|
+
`int` context level starting from 0
|
|
63
|
+
|
|
64
|
+
.. automethod:: subcontext
|
|
65
|
+
.. automethod:: pop_subcontext
|
|
66
|
+
.. automethod:: log_message
|
|
67
|
+
.. automethod:: log_file
|
|
68
|
+
.. automethod:: finalize
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
tasks: "collections.OrderedDict[T, None]"
|
|
72
|
+
|
|
73
|
+
#: Context level starting from 0
|
|
74
|
+
context_level: int
|
|
75
|
+
|
|
76
|
+
def __init__(self):
|
|
77
|
+
self.context_level = 0
|
|
78
|
+
self.tasks = collections.OrderedDict()
|
|
79
|
+
|
|
80
|
+
def start_task(self, instance: T) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Hook that exists to inject some behavior in event of a new test
|
|
83
|
+
*instance* starting.
|
|
84
|
+
"""
|
|
85
|
+
self.tasks[instance] = None
|
|
86
|
+
|
|
87
|
+
def end_task(self, instance: T) -> None: # pyright: ignore[reportUnusedParameter]
|
|
88
|
+
"""
|
|
89
|
+
Hook that exists to inject some behavior in event of a new test
|
|
90
|
+
*instance* ending.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def subcontext(self) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Enter into a new subcontext. The idea behind this method is to allow
|
|
96
|
+
emitters to nest output inside of other output in an effort to be able
|
|
97
|
+
to collapse and expand text information.
|
|
98
|
+
|
|
99
|
+
If overridden by a subclass, be sure to call this original method
|
|
100
|
+
to retain the default functionality.
|
|
101
|
+
"""
|
|
102
|
+
self.context_level += 1
|
|
103
|
+
|
|
104
|
+
def pop_subcontext(self, context_level: int) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Pop subcontext to the given *context_level*, which starts from 0 before
|
|
107
|
+
the first :meth:`subcontext` call.
|
|
108
|
+
|
|
109
|
+
If overridden by a subclass, be sure to call this original method
|
|
110
|
+
to retain the default functionality.
|
|
111
|
+
"""
|
|
112
|
+
self.context_level = context_level
|
|
113
|
+
|
|
114
|
+
def log_message(
|
|
115
|
+
self,
|
|
116
|
+
log_level: LogLevel, # pyright: ignore[reportUnusedParameter]
|
|
117
|
+
message: str, # pyright: ignore[reportUnusedParameter]
|
|
118
|
+
end: str = "\n", # pyright: ignore[reportUnusedParameter]
|
|
119
|
+
) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Log the given *message* at the given *log_level*. If a multi-line
|
|
122
|
+
message is given, it's expected to be handled appropriately by the
|
|
123
|
+
emitter.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def log_file(
|
|
127
|
+
self,
|
|
128
|
+
description: str, # pyright: ignore[reportUnusedParameter]
|
|
129
|
+
extension: str, # pyright: ignore[reportUnusedParameter]
|
|
130
|
+
mode: str = "w",
|
|
131
|
+
) -> IO[str]:
|
|
132
|
+
"""
|
|
133
|
+
Create a file object with the given *description* and file *extension*.
|
|
134
|
+
This object will be used by the caller. It's up to the caller to close
|
|
135
|
+
it when done.
|
|
136
|
+
"""
|
|
137
|
+
return open(os.devnull, mode)
|
|
138
|
+
|
|
139
|
+
def log_response(
|
|
140
|
+
self,
|
|
141
|
+
task: T, # pyright: ignore[reportUnusedParameter]
|
|
142
|
+
response: Dict[str, Any], # pyright: ignore[reportUnusedParameter]
|
|
143
|
+
) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Log the given *response* for a task.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def finalize(self):
|
|
149
|
+
"""
|
|
150
|
+
Hook for finalizing the emitter in case there's something that needs to
|
|
151
|
+
be cleaned up.
|
|
152
|
+
"""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import colorama
|
|
6
|
+
from typing_extensions import override
|
|
7
|
+
|
|
8
|
+
from .emitter import Emitter, LogLevel, T
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SimpleLogEmitter(Emitter[T]):
|
|
12
|
+
"""
|
|
13
|
+
The SimpleLogEmitter is an abstract :class:`Emitter` implementation that
|
|
14
|
+
allows for subclasses to simply implement a single :meth:`emit` method
|
|
15
|
+
to determine where the data goes. The *use_color* argument defaults to
|
|
16
|
+
``True`` and determines whether or not log messages should be emitted
|
|
17
|
+
with color encoding.
|
|
18
|
+
|
|
19
|
+
:param bool use_color: whether or not to output in color (default
|
|
20
|
+
``True``)
|
|
21
|
+
:param int context_level_spaces: number of spaces per context level
|
|
22
|
+
(default 4)
|
|
23
|
+
|
|
24
|
+
.. automethod:: emit
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
use_color: bool
|
|
28
|
+
context_level_spaces: int
|
|
29
|
+
|
|
30
|
+
DEFAULT_CONTEXT_LEVEL_SPACES: int = 4
|
|
31
|
+
|
|
32
|
+
LOG_LEVEL_COLORS: Mapping[LogLevel, str] = collections.defaultdict(
|
|
33
|
+
lambda: colorama.Style.RESET_ALL,
|
|
34
|
+
{
|
|
35
|
+
LogLevel.DEBUG: colorama.Fore.WHITE + colorama.Style.DIM,
|
|
36
|
+
LogLevel.PROCEDURE: colorama.Fore.BLUE,
|
|
37
|
+
LogLevel.INFO: colorama.Fore.WHITE,
|
|
38
|
+
LogLevel.SKIP: colorama.Fore.MAGENTA + colorama.Style.BRIGHT,
|
|
39
|
+
LogLevel.SUCCESS: colorama.Fore.GREEN,
|
|
40
|
+
LogLevel.ERROR: colorama.Fore.RED + colorama.Style.BRIGHT,
|
|
41
|
+
LogLevel.FAIL: colorama.Fore.RED,
|
|
42
|
+
LogLevel.CATASTROPHIC: colorama.Fore.CYAN + colorama.Style.BRIGHT,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
use_color: bool = True,
|
|
49
|
+
context_level_spaces: Optional[int] = None,
|
|
50
|
+
):
|
|
51
|
+
super().__init__()
|
|
52
|
+
|
|
53
|
+
self.use_color = use_color
|
|
54
|
+
self.context_level_spaces = (
|
|
55
|
+
context_level_spaces or self.DEFAULT_CONTEXT_LEVEL_SPACES
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@override
|
|
59
|
+
def log_message(self, log_level: LogLevel, message: str, end: str = "\n") -> None:
|
|
60
|
+
"""
|
|
61
|
+
Specific information of :meth:`Emitter.log_message` that calls
|
|
62
|
+
:meth:`emit` with color-encoded text.
|
|
63
|
+
"""
|
|
64
|
+
spaces = " " * (self.context_level * self.context_level_spaces)
|
|
65
|
+
color_pre_tag = ""
|
|
66
|
+
color_post_tag = ""
|
|
67
|
+
if self.use_color:
|
|
68
|
+
color_pre_tag = self.LOG_LEVEL_COLORS[log_level]
|
|
69
|
+
color_post_tag = colorama.Style.RESET_ALL
|
|
70
|
+
|
|
71
|
+
for line in message.splitlines() if end == "\n" else message.split(end):
|
|
72
|
+
line = line.rstrip()
|
|
73
|
+
self.emit(f"{spaces}{color_pre_tag}{line}{color_post_tag}{end}")
|
|
74
|
+
|
|
75
|
+
def emit(self, message: str) -> None: # pyright: ignore[reportUnusedParameter]
|
|
76
|
+
"""
|
|
77
|
+
Emit the given *message*. This should be implemented by sub-class
|
|
78
|
+
implementations.
|
|
79
|
+
"""
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import pprint
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, Dict, Optional, TextIO, Union
|
|
5
|
+
|
|
6
|
+
from typing_extensions import override
|
|
7
|
+
|
|
8
|
+
from .emitter import LogLevel, T
|
|
9
|
+
from .simple_log import SimpleLogEmitter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TerminalEmitter(SimpleLogEmitter[T]):
|
|
13
|
+
"""
|
|
14
|
+
:class:`SimpleLogEmitter` implementation that logs output to the terminal
|
|
15
|
+
or to the given *fobj*.
|
|
16
|
+
|
|
17
|
+
:param bool use_color: whether or not to output in color (default
|
|
18
|
+
``True``)
|
|
19
|
+
:param bool flush: whether or not to flush to the file after every
|
|
20
|
+
message
|
|
21
|
+
:param fobj: file-like object to log to (default :data:`sys.stdout`)
|
|
22
|
+
:param int context_level_spaces: number of spaces per context level
|
|
23
|
+
(default 4)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
flush: bool
|
|
27
|
+
fobj: Union[io.IOBase, TextIO]
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
use_color: bool = True,
|
|
32
|
+
flush: bool = True,
|
|
33
|
+
fobj: Optional[io.IOBase] = None,
|
|
34
|
+
context_level_spaces: Optional[int] = None,
|
|
35
|
+
):
|
|
36
|
+
super().__init__(
|
|
37
|
+
use_color=use_color,
|
|
38
|
+
context_level_spaces=context_level_spaces,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self.flush = flush
|
|
42
|
+
self.fobj = fobj or sys.stdout
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
def emit(self, message: str) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Implementation of :meth:`SimpleLogEmitter.emit` that emits the given
|
|
48
|
+
*message* text to the configured :attr:`fobj`.
|
|
49
|
+
"""
|
|
50
|
+
_ = self.fobj.write(message)
|
|
51
|
+
if self.flush:
|
|
52
|
+
self.fobj.flush()
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
def log_response(self, task: T, response: Dict[str, Any]) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Log the given *response* for a task.
|
|
58
|
+
"""
|
|
59
|
+
self.log_message(
|
|
60
|
+
LogLevel.PROCEDURE,
|
|
61
|
+
"\n".join(
|
|
62
|
+
[
|
|
63
|
+
"Response for task:",
|
|
64
|
+
pprint.pformat(response),
|
|
65
|
+
]
|
|
66
|
+
),
|
|
67
|
+
)
|