devopsdriver 0.1.52__tar.gz → 0.1.54__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.
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/PKG-INFO +7 -7
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/README.md +2 -2
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/__init__.py +1 -2
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/__init__.py +1 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/azureobject.py +0 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/builds/client.py +0 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/clients.py +0 -2
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/client.py +0 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/log.py +1 -3
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/pipeline.py +0 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/run.py +0 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/timestamp.py +0 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/workitem/client.py +0 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/workitem/wiql.py +10 -2
- devopsdriver-0.1.54/devopsdriver/dataobject.py +214 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/github/client.py +0 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/manage_settings.py +3 -4
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/sendmail.py +0 -2
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/settings.py +99 -8
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/template.py +0 -1
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/PKG-INFO +7 -7
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/requires.txt +4 -4
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/pyproject.toml +4 -4
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_workitem_wiql.py +43 -1
- devopsdriver-0.1.54/tests/test_dataobject.py +253 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_settings.py +242 -7
- devopsdriver-0.1.52/devopsdriver/dataobject.py +0 -64
- devopsdriver-0.1.52/tests/test_dataobject.py +0 -29
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/LICENSE +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/builds/__init__.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/builds/build.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/__init__.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/workitem/__init__.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/github/__init__.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/templates/manage_settings.txt.mako +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/SOURCES.txt +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/dependency_links.txt +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/entry_points.txt +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/top_level.txt +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/setup.cfg +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_azureobject.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_build.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_build_client.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_clients.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_pipeline.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_pipeline_client.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_pipeline_run.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_timestamp.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_workitem_client.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_manage_settings.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_sendmail.py +0 -0
- {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_template.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devopsdriver
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.54
|
|
4
4
|
Summary: DevOps tools
|
|
5
5
|
Author-email: Marc Page <marcallenpage@gmail.com>
|
|
6
6
|
License: This is free and unencumbered software released into the public domain.
|
|
@@ -47,11 +47,11 @@ Requires-Python: >=3.10
|
|
|
47
47
|
Description-Content-Type: text/markdown
|
|
48
48
|
License-File: LICENSE
|
|
49
49
|
Requires-Dist: PyYAML==6.0.3
|
|
50
|
-
Requires-Dist: keyring==25.
|
|
51
|
-
Requires-Dist: setuptools==
|
|
50
|
+
Requires-Dist: keyring==25.7.0
|
|
51
|
+
Requires-Dist: setuptools==82.0.1
|
|
52
52
|
Requires-Dist: azure-devops==7.1.0b4
|
|
53
|
-
Requires-Dist: Mako==1.3.
|
|
54
|
-
Requires-Dist: PyGithub==2.
|
|
53
|
+
Requires-Dist: Mako==1.3.11
|
|
54
|
+
Requires-Dist: PyGithub==2.9.1
|
|
55
55
|
Provides-Extra: dev
|
|
56
56
|
Requires-Dist: black>=24.3.0; extra == "dev"
|
|
57
57
|
Requires-Dist: pylint>=3.1.0; extra == "dev"
|
|
@@ -64,7 +64,7 @@ Dynamic: license-file
|
|
|
64
64
|
# devops-driver
|
|
65
65
|
|
|
66
66
|

|
|
67
|
-
[](https://pypi.org/project/devopsdriver/0.1.54/)
|
|
68
68
|
[](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
|
|
69
69
|
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
70
70
|
[](http://makeapullrequest.com)
|
|
@@ -75,7 +75,7 @@ Dynamic: license-file
|
|
|
75
75
|
[](https://github.com/marcpage/devops-driver)
|
|
76
76
|
|
|
77
77
|
[](https://github.com/marcpage/devops-driver/actions/workflows/pr.yml)
|
|
78
|
-
[](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
|
|
79
79
|
[](https://github.com/marcpage/devops-driver/issues)
|
|
80
80
|
[](https://github.com/marcpage/devops-driver/pulls)
|
|
81
81
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# devops-driver
|
|
2
2
|
|
|
3
3
|

|
|
4
|
-
[](https://pypi.org/project/devopsdriver/0.1.54/)
|
|
5
5
|
[](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
|
|
6
6
|
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
7
7
|
[](http://makeapullrequest.com)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://github.com/marcpage/devops-driver)
|
|
13
13
|
|
|
14
14
|
[](https://github.com/marcpage/devops-driver/actions/workflows/pr.yml)
|
|
15
|
-
[](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
|
|
16
16
|
[](https://github.com/marcpage/devops-driver/issues)
|
|
17
17
|
[](https://github.com/marcpage/devops-driver/pulls)
|
|
18
18
|
|
|
@@ -6,6 +6,6 @@ from .clients import Azure
|
|
|
6
6
|
from .timestamp import Timestamp
|
|
7
7
|
|
|
8
8
|
from .workitem.wiql import Wiql, Value, Field
|
|
9
|
-
from .workitem.wiql import Ascending, Descending, And, Or, In, NotIn
|
|
9
|
+
from .workitem.wiql import Ascending, Descending, And, Or, In, NotIn, Under
|
|
10
10
|
from .workitem.wiql import Equal, NotEqual, LessThanOrEqual, GreaterThanOrEqual
|
|
11
11
|
from .workitem.wiql import IsEmpty, IsNotEmpty, LessThan, GreaterThan
|
|
@@ -7,7 +7,6 @@ API Documented here:
|
|
|
7
7
|
https://github.com/microsoft/azure-devops-python-api
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
|
|
11
10
|
from azure.devops.connection import Connection as AzureConnection
|
|
12
11
|
from msrest.authentication import BasicAuthentication as MSBasicAuthentication
|
|
13
12
|
|
|
@@ -16,7 +15,6 @@ from devopsdriver.azdo.workitem.client import Client as WIClient
|
|
|
16
15
|
from devopsdriver.azdo.pipeline.client import Client as PLClient
|
|
17
16
|
from devopsdriver.azdo.builds.client import Client as BClient
|
|
18
17
|
|
|
19
|
-
|
|
20
18
|
# for testing
|
|
21
19
|
CONNECTION = AzureConnection
|
|
22
20
|
AUTHENTICATION = MSBasicAuthentication
|
|
@@ -3,15 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
"""Azure Pipeline Run Log"""
|
|
5
5
|
|
|
6
|
-
|
|
7
6
|
from azure.devops.v7_1.pipelines.models import Log as AzureLog
|
|
8
7
|
|
|
9
8
|
from requests import get as get_url
|
|
10
9
|
|
|
11
10
|
from devopsdriver.azdo.azureobject import AzureObject
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
GET_URL = get_url
|
|
12
|
+
GET_URL = get_url # pylint: disable=invalid-name
|
|
15
13
|
|
|
16
14
|
|
|
17
15
|
class Log(AzureObject): # pylint: disable=too-few-public-methods
|
|
@@ -19,7 +19,6 @@ ORDER BY [System.ChangedDate] DESC
|
|
|
19
19
|
ASOF '02-11-2020'
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
-
|
|
23
22
|
from datetime import datetime, date
|
|
24
23
|
|
|
25
24
|
|
|
@@ -145,6 +144,13 @@ class Compare: # pylint: disable=too-few-public-methods
|
|
|
145
144
|
return f"{str(self.left)} {self.operator} {str(self.right)}"
|
|
146
145
|
|
|
147
146
|
|
|
147
|
+
class Under(Compare): # pylint: disable=too-few-public-methods
|
|
148
|
+
"""checks for under, like area path or iteration path"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, field_name: Field | str, value: Value | str):
|
|
151
|
+
super().__init__(field_name, value, "UNDER")
|
|
152
|
+
|
|
153
|
+
|
|
148
154
|
class In(Compare): # pylint: disable=too-few-public-methods
|
|
149
155
|
"""checks for field in a list of values"""
|
|
150
156
|
|
|
@@ -294,7 +300,9 @@ class Wiql:
|
|
|
294
300
|
return self
|
|
295
301
|
|
|
296
302
|
def mode(self, results_mode: str):
|
|
297
|
-
"""Sets the mode for link queries
|
|
303
|
+
"""Sets the mode for link queries
|
|
304
|
+
results_mode may be one of: MustContain, MayContain, DoesNotContain, Recursive, None
|
|
305
|
+
"""
|
|
298
306
|
self.mode_type = results_mode
|
|
299
307
|
return self
|
|
300
308
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Data Objects"""
|
|
4
|
+
|
|
5
|
+
from json import dumps
|
|
6
|
+
from re import fullmatch
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DataObject: # pylint: disable=too-few-public-methods
|
|
11
|
+
"""dict like object with fuzzy field matching"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, data: dict):
|
|
14
|
+
self.data = data
|
|
15
|
+
|
|
16
|
+
def _matches_field(self, name: str, field: str) -> bool:
|
|
17
|
+
name = name.lower()
|
|
18
|
+
field = field.lower()
|
|
19
|
+
|
|
20
|
+
if name == field:
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
if name == field.replace(".", "_"):
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
if name == field.split(".")[-1]:
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
def _parse_value(self, data: Any) -> Any:
|
|
32
|
+
if isinstance(data, dict):
|
|
33
|
+
return DataObject._Dict(self, data)
|
|
34
|
+
|
|
35
|
+
if isinstance(data, list):
|
|
36
|
+
return [self._parse_value(d) for d in data]
|
|
37
|
+
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
def _get_field(self, name: str, data: dict) -> Any:
|
|
41
|
+
assert name and data, f"name = {name} data = {data}"
|
|
42
|
+
found = [f for f in data if self._matches_field(name, f)]
|
|
43
|
+
assert len(found) in {0, 1}, found
|
|
44
|
+
|
|
45
|
+
if len(found) == 1:
|
|
46
|
+
return self._parse_value(data[found[0]])
|
|
47
|
+
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def __getattr__(self, name: str) -> Any:
|
|
51
|
+
return self._get_field(name, self.data)
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
return dumps(self.data, indent=2)
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
return dumps(self.data, indent=2)
|
|
58
|
+
|
|
59
|
+
def lookup(self, path: str) -> Any:
|
|
60
|
+
"""
|
|
61
|
+
Resolves a custom path expression against the underlying data.
|
|
62
|
+
|
|
63
|
+
Supported syntax:
|
|
64
|
+
/key/subkey
|
|
65
|
+
.first
|
|
66
|
+
.last
|
|
67
|
+
.split(delimiter)
|
|
68
|
+
/(path=value)
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
/id
|
|
72
|
+
/fields/System.Title
|
|
73
|
+
/relations/(/attributes/name=Parent).first/url.split(/).last
|
|
74
|
+
/relations/(/attributes/name=Child)/url.split(/).last
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
tokens = self._tokenize(path)
|
|
78
|
+
current = self.data
|
|
79
|
+
|
|
80
|
+
for token in tokens:
|
|
81
|
+
current = self._apply_token(current, token)
|
|
82
|
+
|
|
83
|
+
return current
|
|
84
|
+
|
|
85
|
+
def _tokenize(self, path: str) -> list[str]:
|
|
86
|
+
"""Splits a path into tokens while respecting parentheses."""
|
|
87
|
+
|
|
88
|
+
path = path.strip("/")
|
|
89
|
+
|
|
90
|
+
tokens: list[str] = []
|
|
91
|
+
current: list[str] = []
|
|
92
|
+
depth = 0
|
|
93
|
+
|
|
94
|
+
for char in path:
|
|
95
|
+
if char == "/" and depth == 0:
|
|
96
|
+
if current:
|
|
97
|
+
tokens.append("".join(current))
|
|
98
|
+
current = []
|
|
99
|
+
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if char == "(":
|
|
103
|
+
depth += 1
|
|
104
|
+
elif char == ")":
|
|
105
|
+
depth -= 1
|
|
106
|
+
|
|
107
|
+
current.append(char)
|
|
108
|
+
|
|
109
|
+
if current:
|
|
110
|
+
tokens.append("".join(current))
|
|
111
|
+
|
|
112
|
+
return tokens
|
|
113
|
+
|
|
114
|
+
def _apply_token(self, value: Any, token: str) -> Any:
|
|
115
|
+
"""
|
|
116
|
+
Applies a token to the current value.
|
|
117
|
+
|
|
118
|
+
Supports chained expressions like:
|
|
119
|
+
(...).first
|
|
120
|
+
url.split(/).last.int
|
|
121
|
+
But maintains tokens like:
|
|
122
|
+
System.Title
|
|
123
|
+
"""
|
|
124
|
+
segments = token.split(".")
|
|
125
|
+
parts = []
|
|
126
|
+
|
|
127
|
+
while segments[-1] in ("first", "last", "int") or segments[-1].startswith(
|
|
128
|
+
"split"
|
|
129
|
+
):
|
|
130
|
+
parts.insert(0, segments.pop())
|
|
131
|
+
|
|
132
|
+
parts.insert(0, ".".join(segments))
|
|
133
|
+
current = value
|
|
134
|
+
group = False
|
|
135
|
+
|
|
136
|
+
for part in parts:
|
|
137
|
+
current, group = self._apply_part(current, part, group)
|
|
138
|
+
|
|
139
|
+
return current
|
|
140
|
+
|
|
141
|
+
def _apply_part( # pylint: disable=too-many-return-statements
|
|
142
|
+
self, value: Any, part: str, group: bool = False
|
|
143
|
+
) -> tuple[Any, bool]:
|
|
144
|
+
"""Applies a single operation."""
|
|
145
|
+
if part.startswith("(") and part.endswith(")"): # List filter
|
|
146
|
+
return self._filter_list(value, part[1:-1]), group
|
|
147
|
+
|
|
148
|
+
if part == "first": # first
|
|
149
|
+
if group:
|
|
150
|
+
return [v[0] for v in value], group
|
|
151
|
+
|
|
152
|
+
return value[0], False
|
|
153
|
+
|
|
154
|
+
if part == "last": # last
|
|
155
|
+
if group:
|
|
156
|
+
return [v[-1] for v in value], group
|
|
157
|
+
|
|
158
|
+
return value[-1], False
|
|
159
|
+
|
|
160
|
+
if part == "int": # int
|
|
161
|
+
if group:
|
|
162
|
+
return [int(v) for v in value], group
|
|
163
|
+
|
|
164
|
+
return int(value), group
|
|
165
|
+
|
|
166
|
+
# split(delimiter)
|
|
167
|
+
split_only_match = fullmatch(r"split\((.*?)\)", part)
|
|
168
|
+
|
|
169
|
+
if split_only_match:
|
|
170
|
+
delimiter = split_only_match.group(1)
|
|
171
|
+
|
|
172
|
+
if isinstance(value, list):
|
|
173
|
+
return [item.split(delimiter) for item in value], group
|
|
174
|
+
|
|
175
|
+
return value.split(delimiter), group
|
|
176
|
+
|
|
177
|
+
if isinstance(value, dict): # Dictionary lookup
|
|
178
|
+
return value[part], group
|
|
179
|
+
|
|
180
|
+
if isinstance(value, list): # Apply lookup to all items in a list
|
|
181
|
+
return [self._apply_part(item, part)[0] for item in value], True
|
|
182
|
+
|
|
183
|
+
raise ValueError(f"Cannot apply '{part}' to value: {value}")
|
|
184
|
+
|
|
185
|
+
def _filter_list(self, items: list[dict], expression: str) -> list[Any]:
|
|
186
|
+
"""
|
|
187
|
+
Filters a list using an expression.
|
|
188
|
+
|
|
189
|
+
Expression format:
|
|
190
|
+
/path=value
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
/attributes/name=Parent
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
path_expr, expected = expression.split("=", 1)
|
|
197
|
+
|
|
198
|
+
result = []
|
|
199
|
+
|
|
200
|
+
for item in items:
|
|
201
|
+
actual = DataObject(item).lookup(path_expr)
|
|
202
|
+
|
|
203
|
+
if str(actual) == expected:
|
|
204
|
+
result.append(item)
|
|
205
|
+
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
class _Dict(dict):
|
|
209
|
+
def __init__(self, dataobject, data: dict):
|
|
210
|
+
self.dataobject = dataobject
|
|
211
|
+
super().__init__(data)
|
|
212
|
+
|
|
213
|
+
def __getattr__(self, name: str) -> Any:
|
|
214
|
+
return self.dataobject._get_field(name, self)
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
"""Module Doc"""
|
|
5
5
|
|
|
6
|
-
|
|
7
6
|
from os.path import dirname, join, abspath
|
|
8
7
|
from sys import argv as sys_argv
|
|
9
8
|
from getpass import getpass as os_getpass
|
|
@@ -14,9 +13,9 @@ from .settings import Settings
|
|
|
14
13
|
from .template import Template
|
|
15
14
|
|
|
16
15
|
ARGV = sys_argv
|
|
17
|
-
PRINT = print
|
|
18
|
-
SET_PASSWORD = set_password
|
|
19
|
-
GET_PASS = os_getpass
|
|
16
|
+
PRINT = print # pylint: disable=invalid-name
|
|
17
|
+
SET_PASSWORD = set_password # pylint: disable=invalid-name
|
|
18
|
+
GET_PASS = os_getpass # pylint: disable=invalid-name
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
def main() -> None:
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
"""Ability to send emails with embedded images"""
|
|
5
5
|
|
|
6
|
-
|
|
7
6
|
from smtplib import SMTP as OS_SMTP, SMTP_SSL as OS_SMTP_SSL
|
|
8
7
|
from email.mime.multipart import MIMEMultipart as OS_MIMEMultipart
|
|
9
8
|
from email.mime.text import MIMEText as OS_MIMEText
|
|
@@ -11,7 +10,6 @@ from email.mime.image import MIMEImage as OS_MIMEImage
|
|
|
11
10
|
|
|
12
11
|
from devopsdriver.settings import Settings
|
|
13
12
|
|
|
14
|
-
|
|
15
13
|
IMAGE_HEADERS = {".png": b"\x89PNG\r\n\x1a\n", ".jpg": b"\xff\xd8\xff"}
|
|
16
14
|
|
|
17
15
|
# for testing
|
|
@@ -75,7 +75,8 @@ api:
|
|
|
75
75
|
johndoe and janedoe share the same password, so you just need to update the `user`
|
|
76
76
|
"""
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
from dataclasses import MISSING, fields, is_dataclass
|
|
79
80
|
from json import load
|
|
80
81
|
from os.path import dirname, basename, splitext, join
|
|
81
82
|
from os import environ as os_environ, makedirs as os_makedirs
|
|
@@ -83,22 +84,81 @@ from re import compile as regex
|
|
|
83
84
|
from platform import system as os_system
|
|
84
85
|
from sys import argv as sys_argv
|
|
85
86
|
from getpass import getpass as os_getpass
|
|
87
|
+
from typing import Any, TypeVar, Union, cast, get_args, get_origin, get_type_hints
|
|
86
88
|
|
|
87
89
|
from yaml import safe_load
|
|
88
90
|
from keyring import get_password, set_password
|
|
89
91
|
from keyring.backends import fail
|
|
90
92
|
|
|
91
|
-
|
|
92
93
|
# for testing
|
|
93
94
|
ENVIRON = os_environ
|
|
94
95
|
ARGV = sys_argv
|
|
95
|
-
SYSTEM = os_system
|
|
96
|
-
MAKEDIRS = os_makedirs
|
|
96
|
+
SYSTEM = os_system # pylint: disable=invalid-name
|
|
97
|
+
MAKEDIRS = os_makedirs # pylint: disable=invalid-name
|
|
97
98
|
SHARED = "devopsdriver"
|
|
98
|
-
PRINT = print
|
|
99
|
-
GET_PASSWORD = get_password
|
|
100
|
-
SET_PASSWORD = set_password
|
|
101
|
-
GET_PASS = os_getpass
|
|
99
|
+
PRINT = print # pylint: disable=invalid-name
|
|
100
|
+
GET_PASSWORD = get_password # pylint: disable=invalid-name
|
|
101
|
+
SET_PASSWORD = set_password # pylint: disable=invalid-name
|
|
102
|
+
GET_PASS = os_getpass # pylint: disable=invalid-name
|
|
103
|
+
T = TypeVar("T")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_instance_of_type( # pylint: disable=too-many-branches, too-many-return-statements
|
|
107
|
+
value: Any, expected: Any
|
|
108
|
+
) -> bool:
|
|
109
|
+
"""Check if a value matches an expected type, including support for common typing constructs."""
|
|
110
|
+
if expected is Any:
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
origin = get_origin(expected)
|
|
114
|
+
args = get_args(expected)
|
|
115
|
+
|
|
116
|
+
if origin is Union: # Optional[T] / Union[T1, T2, ...]
|
|
117
|
+
return any(_is_instance_of_type(value, t) for t in args)
|
|
118
|
+
|
|
119
|
+
if origin is list: # list[T]
|
|
120
|
+
if not isinstance(value, list):
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
if not args:
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
item_type = args[0]
|
|
127
|
+
return all(_is_instance_of_type(v, item_type) for v in value)
|
|
128
|
+
|
|
129
|
+
if origin is dict: # dict[K, V]
|
|
130
|
+
if not isinstance(value, dict):
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
if len(args) != 2:
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
key_type, val_type = args
|
|
137
|
+
return all(
|
|
138
|
+
_is_instance_of_type(k, key_type) and _is_instance_of_type(v, val_type)
|
|
139
|
+
for k, v in value.items()
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if origin is tuple: # tuple[T1, T2] or tuple[T, ...]
|
|
143
|
+
if not isinstance(value, tuple):
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
if not args:
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
150
|
+
return all(_is_instance_of_type(v, args[0]) for v in value)
|
|
151
|
+
|
|
152
|
+
if len(value) != len(args):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
return all(_is_instance_of_type(v, t) for v, t in zip(value, args))
|
|
156
|
+
|
|
157
|
+
try: # Fallback to normal isinstance
|
|
158
|
+
return isinstance(value, expected)
|
|
159
|
+
|
|
160
|
+
except TypeError:
|
|
161
|
+
return False # Covers unsupported runtime checks for some typing constructs
|
|
102
162
|
|
|
103
163
|
|
|
104
164
|
def load_json(path: str) -> dict:
|
|
@@ -225,6 +285,37 @@ class Settings:
|
|
|
225
285
|
"""
|
|
226
286
|
return self.__bypass(key, name, self.environ)
|
|
227
287
|
|
|
288
|
+
def parse(self, cls: type[T]) -> T:
|
|
289
|
+
"""Extract settings into a dataclass for type safety"""
|
|
290
|
+
if not is_dataclass(cls):
|
|
291
|
+
raise TypeError(f"{cls} must be a dataclass type")
|
|
292
|
+
|
|
293
|
+
type_hints = get_type_hints(cls)
|
|
294
|
+
kwargs = {}
|
|
295
|
+
|
|
296
|
+
for f in fields(cls):
|
|
297
|
+
expected_type = type_hints.get(f.name, Any)
|
|
298
|
+
|
|
299
|
+
# Pull from settings if present, otherwise use dataclass default/default_factory
|
|
300
|
+
if self.has(f.name):
|
|
301
|
+
value = self.get(f.name)
|
|
302
|
+
elif f.default is not MISSING:
|
|
303
|
+
value = f.default
|
|
304
|
+
elif f.default_factory is not MISSING:
|
|
305
|
+
value = f.default_factory()
|
|
306
|
+
else:
|
|
307
|
+
raise KeyError(f"Missing required setting: {f.name}")
|
|
308
|
+
|
|
309
|
+
if not _is_instance_of_type(value, expected_type):
|
|
310
|
+
raise TypeError(
|
|
311
|
+
f"Invalid type for '{f.name}': expected {expected_type}, "
|
|
312
|
+
+ f"got {type(value).__name__}"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
kwargs[f.name] = value
|
|
316
|
+
|
|
317
|
+
return cast(T, cls(**kwargs))
|
|
318
|
+
|
|
228
319
|
@staticmethod
|
|
229
320
|
def __patch_instance(key: str) -> str:
|
|
230
321
|
for env_key, value in ENVIRON.items():
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devopsdriver
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.54
|
|
4
4
|
Summary: DevOps tools
|
|
5
5
|
Author-email: Marc Page <marcallenpage@gmail.com>
|
|
6
6
|
License: This is free and unencumbered software released into the public domain.
|
|
@@ -47,11 +47,11 @@ Requires-Python: >=3.10
|
|
|
47
47
|
Description-Content-Type: text/markdown
|
|
48
48
|
License-File: LICENSE
|
|
49
49
|
Requires-Dist: PyYAML==6.0.3
|
|
50
|
-
Requires-Dist: keyring==25.
|
|
51
|
-
Requires-Dist: setuptools==
|
|
50
|
+
Requires-Dist: keyring==25.7.0
|
|
51
|
+
Requires-Dist: setuptools==82.0.1
|
|
52
52
|
Requires-Dist: azure-devops==7.1.0b4
|
|
53
|
-
Requires-Dist: Mako==1.3.
|
|
54
|
-
Requires-Dist: PyGithub==2.
|
|
53
|
+
Requires-Dist: Mako==1.3.11
|
|
54
|
+
Requires-Dist: PyGithub==2.9.1
|
|
55
55
|
Provides-Extra: dev
|
|
56
56
|
Requires-Dist: black>=24.3.0; extra == "dev"
|
|
57
57
|
Requires-Dist: pylint>=3.1.0; extra == "dev"
|
|
@@ -64,7 +64,7 @@ Dynamic: license-file
|
|
|
64
64
|
# devops-driver
|
|
65
65
|
|
|
66
66
|

|
|
67
|
-
[](https://pypi.org/project/devopsdriver/0.1.54/)
|
|
68
68
|
[](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
|
|
69
69
|
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
70
70
|
[](http://makeapullrequest.com)
|
|
@@ -75,7 +75,7 @@ Dynamic: license-file
|
|
|
75
75
|
[](https://github.com/marcpage/devops-driver)
|
|
76
76
|
|
|
77
77
|
[](https://github.com/marcpage/devops-driver/actions/workflows/pr.yml)
|
|
78
|
-
[](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
|
|
79
79
|
[](https://github.com/marcpage/devops-driver/issues)
|
|
80
80
|
[](https://github.com/marcpage/devops-driver/pulls)
|
|
81
81
|
|