devopsdriver 0.1.34__tar.gz → 0.1.36__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.34 → devopsdriver-0.1.36}/PKG-INFO +8 -4
- {devopsdriver-0.1.34 → devopsdriver-0.1.36}/README.md +5 -3
- devopsdriver-0.1.36/devopsdriver/__init__.py +9 -0
- devopsdriver-0.1.36/devopsdriver/azdo/__init__.py +10 -0
- devopsdriver-0.1.36/devopsdriver/azdo/clients.py +53 -0
- devopsdriver-0.1.36/devopsdriver/azdo/workitem/__init__.py +4 -0
- devopsdriver-0.1.36/devopsdriver/azdo/workitem/client.py +88 -0
- devopsdriver-0.1.36/devopsdriver/azdo/workitem/wiql.py +282 -0
- devopsdriver-0.1.36/devopsdriver/azdo/workitem/workitem.py +53 -0
- devopsdriver-0.1.36/devopsdriver/sendmail.py +88 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.36}/devopsdriver/settings.py +19 -5
- {devopsdriver-0.1.34 → devopsdriver-0.1.36}/devopsdriver.egg-info/PKG-INFO +8 -4
- devopsdriver-0.1.36/devopsdriver.egg-info/SOURCES.txt +23 -0
- devopsdriver-0.1.36/devopsdriver.egg-info/requires.txt +4 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.36}/pyproject.toml +2 -0
- devopsdriver-0.1.36/tests/test_azure_clients.py +144 -0
- devopsdriver-0.1.36/tests/test_azure_workitem.py +95 -0
- devopsdriver-0.1.36/tests/test_azure_workitem_client.py +61 -0
- devopsdriver-0.1.36/tests/test_azure_workitem_wiql.py +71 -0
- devopsdriver-0.1.36/tests/test_sendmail.py +151 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.36}/tests/test_settings.py +42 -79
- devopsdriver-0.1.34/devopsdriver/__init__.py +0 -5
- devopsdriver-0.1.34/devopsdriver.egg-info/SOURCES.txt +0 -11
- devopsdriver-0.1.34/devopsdriver.egg-info/requires.txt +0 -2
- {devopsdriver-0.1.34 → devopsdriver-0.1.36}/LICENSE +0 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.36}/devopsdriver.egg-info/dependency_links.txt +0 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.36}/devopsdriver.egg-info/top_level.txt +0 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.36}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: devopsdriver
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.36
|
|
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.
|
|
@@ -48,10 +48,15 @@ Description-Content-Type: text/markdown
|
|
|
48
48
|
License-File: LICENSE
|
|
49
49
|
Requires-Dist: PyYAML==6.0.1
|
|
50
50
|
Requires-Dist: keyring==25.0.0
|
|
51
|
+
Requires-Dist: setuptools==69.0.2
|
|
52
|
+
Requires-Dist: azure-devops==7.1.0b4
|
|
51
53
|
|
|
52
54
|

|
|
53
|
-
[](https://pypi.org/project/devopsdriver/0.1.36/)
|
|
54
56
|
[](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
|
|
57
|
+
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
58
|
+
[](http://makeapullrequest.com)
|
|
59
|
+
|
|
55
60
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
56
61
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
57
62
|
[](https://github.com/marcpage/devops-driver)
|
|
@@ -61,8 +66,6 @@ Requires-Dist: keyring==25.0.0
|
|
|
61
66
|
[](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
|
|
62
67
|
[](https://github.com/marcpage/devops-driver/issues)
|
|
63
68
|
[](https://github.com/marcpage/devops-driver/pulls)
|
|
64
|
-
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
65
|
-
[](http://makeapullrequest.com)
|
|
66
69
|
|
|
67
70
|
[](https://github.com/marcpage?tab=followers)
|
|
68
71
|
[](https://github.com/marcpage/devops-driver/watchers)
|
|
@@ -70,3 +73,4 @@ Requires-Dist: keyring==25.0.0
|
|
|
70
73
|
# devops-driver
|
|
71
74
|
|
|
72
75
|
Devops-driver is a set of tools to help streamline developer's experience. It is a collection of tools to help gain insights into various processes.
|
|
76
|
+
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|

|
|
2
|
-
[](https://pypi.org/project/devopsdriver/0.1.36/)
|
|
3
3
|
[](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
|
|
4
|
+
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
5
|
+
[](http://makeapullrequest.com)
|
|
6
|
+
|
|
4
7
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
5
8
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
6
9
|
[](https://github.com/marcpage/devops-driver)
|
|
@@ -10,8 +13,6 @@
|
|
|
10
13
|
[](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
|
|
11
14
|
[](https://github.com/marcpage/devops-driver/issues)
|
|
12
15
|
[](https://github.com/marcpage/devops-driver/pulls)
|
|
13
|
-
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
14
|
-
[](http://makeapullrequest.com)
|
|
15
16
|
|
|
16
17
|
[](https://github.com/marcpage?tab=followers)
|
|
17
18
|
[](https://github.com/marcpage/devops-driver/watchers)
|
|
@@ -19,3 +20,4 @@
|
|
|
19
20
|
# devops-driver
|
|
20
21
|
|
|
21
22
|
Devops-driver is a set of tools to help streamline developer's experience. It is a collection of tools to help gain insights into various processes.
|
|
23
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
""" initialize azure module """
|
|
2
|
+
|
|
3
|
+
# re export symbols for easier use
|
|
4
|
+
from .clients import Azure
|
|
5
|
+
|
|
6
|
+
from .workitem import WorkItem
|
|
7
|
+
from .workitem.wiql import Wiql, Value, Field
|
|
8
|
+
from .workitem.wiql import Ascending, Descending, And, Or
|
|
9
|
+
from .workitem.wiql import Equal, NotEqual, LessThanOrEqual, GreaterThanOrEqual
|
|
10
|
+
from .workitem.wiql import IsEmpty, IsNotEmpty, LessThan, GreaterThan
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
""" Establish a connection to Azure Devops
|
|
5
|
+
|
|
6
|
+
API Documented here:
|
|
7
|
+
https://github.com/microsoft/azure-devops-python-api
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from azure.devops.connection import Connection as AzureConnection
|
|
11
|
+
from msrest.authentication import BasicAuthentication as MSBasicAuthentication
|
|
12
|
+
|
|
13
|
+
from devopsdriver.settings import Settings
|
|
14
|
+
from devopsdriver.azdo.workitem.client import Client as WIClient
|
|
15
|
+
|
|
16
|
+
CONNECTION = AzureConnection
|
|
17
|
+
AUTHENTICATION = MSBasicAuthentication
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Azure: # pylint: disable=too-few-public-methods
|
|
21
|
+
"""A connection to Azure clients"""
|
|
22
|
+
|
|
23
|
+
SUPPORTED_CLIENTS = {"workitem", "pipeline"}
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, settings: Settings = None, token: str = None, url: str = None, **clients
|
|
27
|
+
):
|
|
28
|
+
unsupported_clients = set(clients) - Azure.SUPPORTED_CLIENTS
|
|
29
|
+
assert not unsupported_clients, f"{unsupported_clients} not supported"
|
|
30
|
+
settings = (
|
|
31
|
+
Settings(__file__).key("secrets")
|
|
32
|
+
if settings is None and token is None and url is None
|
|
33
|
+
else settings
|
|
34
|
+
)
|
|
35
|
+
assert (token or "azure.token" in settings) and (
|
|
36
|
+
url or "azure.url" in settings
|
|
37
|
+
), "azure.token and azure.url not found in:\n" + "\n".join(
|
|
38
|
+
settings.search_files
|
|
39
|
+
)
|
|
40
|
+
url = settings["azure.url"] if url is None else url
|
|
41
|
+
token = settings["azure.token"] if token is None else token
|
|
42
|
+
self.connection = CONNECTION(base_url=url, creds=AUTHENTICATION("", token))
|
|
43
|
+
client_calls = {
|
|
44
|
+
"workitem": self.connection.clients_v7_1.get_work_item_tracking_client
|
|
45
|
+
}
|
|
46
|
+
self.workitem = WIClient(Azure.__client("workitem", clients, client_calls))
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def __client(name: str, clients: dict, calls: dict) -> any:
|
|
50
|
+
if clients and not clients.get(name, False):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
return calls[name]()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Azure WorkItem Client """
|
|
4
|
+
|
|
5
|
+
from azure.devops.v7_1.work_item_tracking.models import Wiql as AzureWiql
|
|
6
|
+
from azure.devops.v7_1.work_item_tracking.models import WorkItem as AzureWorkItem
|
|
7
|
+
from azure.devops.v7_1.work_item_tracking.models import TeamContext
|
|
8
|
+
from azure.devops.v7_1.work_item_tracking.models import WorkItemQueryResult
|
|
9
|
+
from devopsdriver.azdo.workitem import WorkItem
|
|
10
|
+
from devopsdriver.azdo.workitem.wiql import Wiql
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Client:
|
|
14
|
+
"""Wraps work item client"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client):
|
|
17
|
+
self.client = client
|
|
18
|
+
|
|
19
|
+
def query(
|
|
20
|
+
self,
|
|
21
|
+
wiql: Wiql | str,
|
|
22
|
+
team_context: TeamContext = None,
|
|
23
|
+
time_precision: bool = None,
|
|
24
|
+
top: int = None,
|
|
25
|
+
) -> WorkItemQueryResult:
|
|
26
|
+
"""Perform a wiql query
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
wiql (Wiql | str): The query
|
|
30
|
+
team_context (TeamContext, optional): context object. Defaults to None.
|
|
31
|
+
time_precision (bool, optional): True for precision time. Defaults to None.
|
|
32
|
+
top (int, optional): Count of items to get. Defaults to None.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
WorkItemQueryResult: The results
|
|
36
|
+
"""
|
|
37
|
+
return self.client.query_by_wiql(
|
|
38
|
+
AzureWiql(query=str(wiql)),
|
|
39
|
+
team_context=team_context,
|
|
40
|
+
time_precision=time_precision,
|
|
41
|
+
top=top,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def history( # pylint: disable=too-many-arguments
|
|
45
|
+
self,
|
|
46
|
+
wi_id: int,
|
|
47
|
+
project: str = None,
|
|
48
|
+
top: int = None,
|
|
49
|
+
skip: int = None,
|
|
50
|
+
expand: str = None,
|
|
51
|
+
) -> list[AzureWorkItem]:
|
|
52
|
+
"""Simple wrapper around get_revisions"""
|
|
53
|
+
return self.client.get_revisions(wi_id, project, top, skip, expand)
|
|
54
|
+
|
|
55
|
+
def find_ids(self, wiql: Wiql | str, top: int = None) -> list[int]:
|
|
56
|
+
"""Given a query, find the work item ids
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
wiql (Wiql | str): The query
|
|
60
|
+
top (int, optional): The number of results to return. Defaults to None.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
list: List of item ids
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(wiql, Wiql):
|
|
66
|
+
wiql.select("Id")
|
|
67
|
+
|
|
68
|
+
found = self.query(wiql, top=top)
|
|
69
|
+
# top-level items: as_of, columns, query_results_type, query_type, work_items
|
|
70
|
+
# work_items fields: id, url
|
|
71
|
+
# query_results_type: workItem
|
|
72
|
+
# query_type: flat
|
|
73
|
+
# columns: list of name, reference_name, url
|
|
74
|
+
return [i.id for i in found.work_items]
|
|
75
|
+
|
|
76
|
+
def find(self, wiql: Wiql | str, top: int = None) -> list[list[WorkItem]]:
|
|
77
|
+
"""Gets the full history of items found in a WIQL search
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
wiql (Wiql | str): The query
|
|
81
|
+
top (int, optional): The number of work items to return. Defaults to None.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
list[list[WorkItem]]: List of work items, each is a history of work items
|
|
85
|
+
"""
|
|
86
|
+
return [
|
|
87
|
+
[WorkItem(e) for e in self.history(i)] for i in self.find_ids(wiql, top)
|
|
88
|
+
]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Builds a WIQL query
|
|
4
|
+
https://learn.microsoft.com/en-us/azure/devops/boards/queries/wiql-syntax?view=azure-devops
|
|
5
|
+
|
|
6
|
+
SELECT
|
|
7
|
+
[System.Id],
|
|
8
|
+
[System.AssignedTo],
|
|
9
|
+
[System.State],
|
|
10
|
+
[System.Title],
|
|
11
|
+
[System.Tags]
|
|
12
|
+
FROM workitems
|
|
13
|
+
WHERE
|
|
14
|
+
[System.TeamProject] = 'Design Agile'
|
|
15
|
+
AND [System.WorkItemType] = 'User Story'
|
|
16
|
+
AND [System.State] = 'Active'
|
|
17
|
+
ORDER BY [System.ChangedDate] DESC
|
|
18
|
+
ASOF '02-11-2020'
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
from datetime import datetime, date
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Field: # pylint: disable=too-few-public-methods
|
|
26
|
+
"""column name"""
|
|
27
|
+
|
|
28
|
+
SYSTEM = {
|
|
29
|
+
"Id",
|
|
30
|
+
"AssignTo",
|
|
31
|
+
"State",
|
|
32
|
+
"Title",
|
|
33
|
+
"Tags",
|
|
34
|
+
"TeamProject",
|
|
35
|
+
"WorkItemType",
|
|
36
|
+
"ChangedDate",
|
|
37
|
+
"CreatedDate",
|
|
38
|
+
}
|
|
39
|
+
COMMON = {"Priority"}
|
|
40
|
+
|
|
41
|
+
def __init__(self, value: str):
|
|
42
|
+
self.value = value
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
if self.value in Field.SYSTEM:
|
|
46
|
+
prefix = "System."
|
|
47
|
+
|
|
48
|
+
elif self.value in Field.COMMON:
|
|
49
|
+
prefix = "Microsoft.VSTS.Common."
|
|
50
|
+
|
|
51
|
+
else:
|
|
52
|
+
prefix = ""
|
|
53
|
+
|
|
54
|
+
return f"[{prefix}{self.value}]"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OrderBy: # pylint: disable=too-few-public-methods
|
|
58
|
+
"""Order by field"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, field: Field | str, order: str = "DESC"):
|
|
61
|
+
self.field = field if isinstance(field, Field) else Field(field)
|
|
62
|
+
self.order = order
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return f"{self.field} {self.order}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Ascending(OrderBy): # pylint: disable=too-few-public-methods
|
|
69
|
+
"""Order by field ascending"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, field: Field | str):
|
|
72
|
+
super().__init__(field, "ASC")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Descending(OrderBy): # pylint: disable=too-few-public-methods
|
|
76
|
+
"""Order by field descending"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, field: Field | str):
|
|
79
|
+
super().__init__(field, "DESC")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Value: # pylint: disable=too-few-public-methods
|
|
83
|
+
"""A constant value"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, value: date | datetime | int | float | str):
|
|
86
|
+
self.value = value
|
|
87
|
+
|
|
88
|
+
def __str__(self) -> str:
|
|
89
|
+
if isinstance(self.value, datetime):
|
|
90
|
+
return self.value.strftime("%Y-%m-%d %H:%M:%S")
|
|
91
|
+
|
|
92
|
+
if isinstance(self.value, date):
|
|
93
|
+
return self.value.strftime("%Y-%m-%d")
|
|
94
|
+
|
|
95
|
+
if isinstance(self.value, int):
|
|
96
|
+
return str(self.value)
|
|
97
|
+
|
|
98
|
+
if isinstance(self.value, float):
|
|
99
|
+
return f"{self.value:0.3f}"
|
|
100
|
+
|
|
101
|
+
if isinstance(self.value, str):
|
|
102
|
+
return f'"{self.value}"' # TODO: escape value # pylint: disable=fixme
|
|
103
|
+
|
|
104
|
+
raise AssertionError("Unknown type")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class IsEmpty: # pylint: disable=too-few-public-methods
|
|
108
|
+
"""Compare a field to a value"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, field: Field | str):
|
|
111
|
+
self.field = field if isinstance(field, Field) else Field(field)
|
|
112
|
+
|
|
113
|
+
def __str__(self) -> str:
|
|
114
|
+
return f"{str(self.field)} IS EMPTY"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class IsNotEmpty: # pylint: disable=too-few-public-methods
|
|
118
|
+
"""Compare a field to a value"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, field: Field | str):
|
|
121
|
+
self.field = field if isinstance(field, Field) else Field(field)
|
|
122
|
+
|
|
123
|
+
def __str__(self) -> str:
|
|
124
|
+
return f"{str(self.field)} IS NOT EMPTY"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Compare: # pylint: disable=too-few-public-methods
|
|
128
|
+
"""Compare a field to a value"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
field: Field | str,
|
|
133
|
+
value: Value | str | date | datetime | int | float,
|
|
134
|
+
operator: str,
|
|
135
|
+
):
|
|
136
|
+
self.left = field if isinstance(field, Field) else Field(field)
|
|
137
|
+
self.right = value if isinstance(value, Value) else Value(value)
|
|
138
|
+
self.operator = operator
|
|
139
|
+
|
|
140
|
+
def __str__(self) -> str:
|
|
141
|
+
return f"{str(self.left)} {self.operator} {str(self.right)}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Equal(Compare): # pylint: disable=too-few-public-methods
|
|
145
|
+
"""checks for equality"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
149
|
+
):
|
|
150
|
+
super().__init__(field, value, "=")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class NotEqual(Compare): # pylint: disable=too-few-public-methods
|
|
154
|
+
"""checks for equality"""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
158
|
+
):
|
|
159
|
+
super().__init__(field, value, "<>")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class LessThan(Compare): # pylint: disable=too-few-public-methods
|
|
163
|
+
"""checks for lass than"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
167
|
+
):
|
|
168
|
+
super().__init__(field, value, "<")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class GreaterThan(Compare): # pylint: disable=too-few-public-methods
|
|
172
|
+
"""checks for greater than"""
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
176
|
+
):
|
|
177
|
+
super().__init__(field, value, ">")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class LessThanOrEqual(Compare): # pylint: disable=too-few-public-methods
|
|
181
|
+
"""checks for less than or equal"""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
185
|
+
):
|
|
186
|
+
super().__init__(field, value, "<=")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class GreaterThanOrEqual(Compare): # pylint: disable=too-few-public-methods
|
|
190
|
+
"""checks for greater than or equal"""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
194
|
+
):
|
|
195
|
+
super().__init__(field, value, ">=")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Expression: # pylint: disable=too-few-public-methods
|
|
199
|
+
"""Join several compares"""
|
|
200
|
+
|
|
201
|
+
def __init__(self, operator: str, *compares: list[Compare]):
|
|
202
|
+
self.operator = operator
|
|
203
|
+
self.expressions = compares
|
|
204
|
+
|
|
205
|
+
def __str__(self) -> str:
|
|
206
|
+
return f" {self.operator} ".join(str(e) for e in self.expressions)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class And(Expression): # pylint: disable=too-few-public-methods
|
|
210
|
+
"""Join compares via AND"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, *compares: list[Compare | Expression]):
|
|
213
|
+
super().__init__("AND", *compares)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Or(Expression): # pylint: disable=too-few-public-methods
|
|
217
|
+
"""join compares via OR"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, *compares: list[Compare | Expression]):
|
|
220
|
+
super().__init__("OR", *compares)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Wiql:
|
|
224
|
+
"""Build a WIQL query"""
|
|
225
|
+
|
|
226
|
+
def __init__(self):
|
|
227
|
+
self.selected = [Field("Id")]
|
|
228
|
+
self.search = None
|
|
229
|
+
self.order = []
|
|
230
|
+
self.snapshot = None
|
|
231
|
+
|
|
232
|
+
def select(self, *fields: list[Field | str]):
|
|
233
|
+
"""The fields to select
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Builder: Returns self for chaining
|
|
237
|
+
"""
|
|
238
|
+
self.selected = [f if isinstance(f, Field) else Field(f) for f in fields]
|
|
239
|
+
return self
|
|
240
|
+
|
|
241
|
+
def where(self, expression: Compare | And | Or):
|
|
242
|
+
"""Search criteria
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
expression (Expression|Compare): An expression of what to search for.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Builder: self for chaining calls
|
|
249
|
+
"""
|
|
250
|
+
self.search = expression
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
def order_by(self, *orders):
|
|
254
|
+
"""Set the fields to order the results by
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Builder: Returns self for chaining
|
|
258
|
+
"""
|
|
259
|
+
self.order = orders
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
def asof(self, stamp: Value | date | datetime | str):
|
|
263
|
+
"""Set the view of the data
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
stamp (Value): The date or datetime in a Value
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Builder: self for chainingd
|
|
270
|
+
"""
|
|
271
|
+
assert isinstance(stamp, (Value, date, datetime, str)), stamp
|
|
272
|
+
self.snapshot = stamp if isinstance(stamp, (Value, str)) else Value(stamp)
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
def __str__(self) -> str:
|
|
276
|
+
select = ", ".join(str(s) for s in self.selected)
|
|
277
|
+
where = f" WHERE {self.search}" if self.search else ""
|
|
278
|
+
order = (
|
|
279
|
+
f" ORDER BY {', '.join(str(o) for o in self.order)}" if self.order else ""
|
|
280
|
+
)
|
|
281
|
+
asof = f" ASOF {str(self.snapshot)}" if self.snapshot else ""
|
|
282
|
+
return f"SELECT {select} FROM workitems{where}{order}{asof}"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" An Azure Devops WorkItem """
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from azure.devops.v7_1.work_item_tracking.models import WorkItem as AzureWorkItem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkItem: # pylint: disable=too-few-public-methods
|
|
10
|
+
"""Azure WorkItem"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, work_item: AzureWorkItem):
|
|
13
|
+
self.raw = work_item
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def __matches_field(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
|
+
@staticmethod
|
|
32
|
+
def _parse_field(name: str, data: dict) -> any:
|
|
33
|
+
assert name and data
|
|
34
|
+
found = [f for f in data if WorkItem.__matches_field(name, f)]
|
|
35
|
+
|
|
36
|
+
if len(found) == 1:
|
|
37
|
+
return (
|
|
38
|
+
WorkItem._Dict(data[found[0]])
|
|
39
|
+
if isinstance(data[found[0]], dict)
|
|
40
|
+
else data[found[0]]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if "fields" in data:
|
|
44
|
+
return WorkItem._parse_field(name, data["fields"])
|
|
45
|
+
|
|
46
|
+
raise AttributeError(f"'WorkItem' object has no attribute '{name}'")
|
|
47
|
+
|
|
48
|
+
class _Dict(dict):
|
|
49
|
+
def __getattr__(self, name: str) -> Any:
|
|
50
|
+
return WorkItem._parse_field(name, self)
|
|
51
|
+
|
|
52
|
+
def __getattr__(self, name: str) -> Any:
|
|
53
|
+
return WorkItem._parse_field(name, self.raw.as_dict())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Ability to send emails with embedded images """
|
|
4
|
+
from smtplib import SMTP as OS_SMTP, SMTP_SSL as OS_SMTP_SSL
|
|
5
|
+
from email.mime.multipart import MIMEMultipart as OS_MIMEMultipart
|
|
6
|
+
from email.mime.text import MIMEText as OS_MIMEText
|
|
7
|
+
from email.mime.image import MIMEImage as OS_MIMEImage
|
|
8
|
+
|
|
9
|
+
from devopsdriver.settings import Settings
|
|
10
|
+
|
|
11
|
+
IMAGE_HEADERS = {".png": b"\x89PNG\r\n\x1a\n", ".jpg": b"\xff\xd8\xff"}
|
|
12
|
+
|
|
13
|
+
# for testing
|
|
14
|
+
MIMEMULTIPART = OS_MIMEMultipart
|
|
15
|
+
MIMETEXT = OS_MIMEText
|
|
16
|
+
MIMEIMAGE = OS_MIMEImage
|
|
17
|
+
SMTP = OS_SMTP
|
|
18
|
+
SMTPSSL = OS_SMTP_SSL
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def image_extension(data: bytes) -> str:
|
|
22
|
+
"""Given image data, determine the file extension
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
data (bytes): The image binary data
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
AttributeError: If the data type is not recognized
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
str: The extension, like ".png"
|
|
32
|
+
"""
|
|
33
|
+
for extension, header in IMAGE_HEADERS.items():
|
|
34
|
+
if data.startswith(header):
|
|
35
|
+
return extension
|
|
36
|
+
|
|
37
|
+
raise AttributeError("Image not a known format: " + ",".join(IMAGE_HEADERS))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def send_email(
|
|
41
|
+
recipients: str | list[str],
|
|
42
|
+
subject: str,
|
|
43
|
+
html_body: str,
|
|
44
|
+
settings: Settings = None,
|
|
45
|
+
**image_data,
|
|
46
|
+
):
|
|
47
|
+
"""Sends an email with embedded images
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
recipients (str | list[str]): A single email address or a list of them
|
|
51
|
+
subject (str): Subject line
|
|
52
|
+
html_body (str): html formatted body. To reference an image in your
|
|
53
|
+
body, <img src="cid:image1"> if you pass image1=png.read()
|
|
54
|
+
settings (Settings, optional): The settings object. Defaults to None.
|
|
55
|
+
image_data (dict, optional): keyword image names to binary image data
|
|
56
|
+
"""
|
|
57
|
+
settings = Settings(__file__).key("secrets") if settings is None else settings
|
|
58
|
+
required = {"smtp.sender", "smtp.server", "smtp.port", "smtp.password"}
|
|
59
|
+
missing = {r for r in required if r not in settings}
|
|
60
|
+
assert not missing, (
|
|
61
|
+
", ".join(missing) + " not found in:\n" + "\n".join(settings.search_files)
|
|
62
|
+
)
|
|
63
|
+
sender = settings["smtp.sender"]
|
|
64
|
+
username = settings.get("smtp.username", sender)
|
|
65
|
+
message = MIMEMULTIPART()
|
|
66
|
+
message["Subject"] = subject
|
|
67
|
+
message["From"] = sender
|
|
68
|
+
message["To"] = ", ".join(
|
|
69
|
+
[recipients] if isinstance(recipients, str) else recipients
|
|
70
|
+
)
|
|
71
|
+
message.attach(MIMETEXT(html_body, "html"))
|
|
72
|
+
connection_type = SMTPSSL if settings.get("smtp.ssl", True) else SMTP
|
|
73
|
+
|
|
74
|
+
for name, binary_data in image_data.items():
|
|
75
|
+
image = MIMEIMAGE(binary_data)
|
|
76
|
+
image.add_header("Content-ID", f"<{name}>")
|
|
77
|
+
image.add_header(
|
|
78
|
+
"Content-Disposition",
|
|
79
|
+
"inline",
|
|
80
|
+
filename=name + image_extension(binary_data),
|
|
81
|
+
)
|
|
82
|
+
message.attach(image)
|
|
83
|
+
|
|
84
|
+
with connection_type(settings["smtp.server"], settings["smtp.port"]) as smtp:
|
|
85
|
+
smtp.set_debuglevel(False)
|
|
86
|
+
smtp.login(username, settings["smtp.password"])
|
|
87
|
+
smtp.sendmail(sender, recipients, message.as_string())
|
|
88
|
+
smtp.quit()
|