devopsdriver 0.1.32__tar.gz → 0.1.35__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.
Files changed (25) hide show
  1. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/PKG-INFO +10 -4
  2. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/README.md +5 -2
  3. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/devopsdriver/__init__.py +3 -1
  4. devopsdriver-0.1.35/devopsdriver/azure/__init__.py +10 -0
  5. devopsdriver-0.1.35/devopsdriver/azure/clients.py +53 -0
  6. devopsdriver-0.1.35/devopsdriver/azure/workitem/__init__.py +4 -0
  7. devopsdriver-0.1.35/devopsdriver/azure/workitem/client.py +88 -0
  8. devopsdriver-0.1.35/devopsdriver/azure/workitem/wiql.py +282 -0
  9. devopsdriver-0.1.35/devopsdriver/azure/workitem/workitem.py +53 -0
  10. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/devopsdriver/settings.py +76 -17
  11. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/devopsdriver.egg-info/PKG-INFO +10 -4
  12. devopsdriver-0.1.35/devopsdriver.egg-info/SOURCES.txt +21 -0
  13. devopsdriver-0.1.35/devopsdriver.egg-info/requires.txt +4 -0
  14. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/pyproject.toml +4 -1
  15. devopsdriver-0.1.35/tests/test_azure_clients.py +144 -0
  16. devopsdriver-0.1.35/tests/test_azure_workitem.py +95 -0
  17. devopsdriver-0.1.35/tests/test_azure_workitem_client.py +61 -0
  18. devopsdriver-0.1.35/tests/test_azure_workitem_wiql.py +71 -0
  19. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/tests/test_settings.py +57 -48
  20. devopsdriver-0.1.32/devopsdriver.egg-info/SOURCES.txt +0 -11
  21. devopsdriver-0.1.32/devopsdriver.egg-info/requires.txt +0 -1
  22. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/LICENSE +0 -0
  23. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/devopsdriver.egg-info/dependency_links.txt +0 -0
  24. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/devopsdriver.egg-info/top_level.txt +0 -0
  25. {devopsdriver-0.1.32 → devopsdriver-0.1.35}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: devopsdriver
3
- Version: 0.1.32
3
+ Version: 0.1.35
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.
@@ -43,13 +43,20 @@ Classifier: License :: OSI Approved :: The Unlicense (Unlicense)
43
43
  Classifier: Programming Language :: Python :: 3.12
44
44
  Classifier: Topic :: Utilities
45
45
  Classifier: Topic :: Software Development
46
- Requires-Python: >=3.12
46
+ Requires-Python: >=3.10
47
47
  Description-Content-Type: text/markdown
48
48
  License-File: LICENSE
49
49
  Requires-Dist: PyYAML==6.0.1
50
+ Requires-Dist: keyring==25.0.0
51
+ Requires-Dist: setuptools==69.0.2
52
+ Requires-Dist: azure-devops==7.1.0b4
50
53
 
51
54
  ![status sheild](https://img.shields.io/static/v1?label=status&message=starting...&color=inactive&style=plastic)
55
+ [![status sheild](https://img.shields.io/static/v1?label=released&message=v0.1.35&color=active&style=plastic)](https://pypi.org/project/devopsdriver/0.1.35/)
52
56
  [![GitHub](https://img.shields.io/github/license/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
57
+ [![GitHub contributors](https://img.shields.io/github/contributors/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/graphs/contributors)
58
+ [![PR's Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com)
59
+
53
60
  [![commit sheild](https://img.shields.io/github/last-commit/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver/commits)
54
61
  [![activity sheild](https://img.shields.io/github/commit-activity/m/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver/commits)
55
62
  [![GitHub top language](https://img.shields.io/github/languages/top/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver)
@@ -59,8 +66,6 @@ Requires-Dist: PyYAML==6.0.1
59
66
  [![status sheild](https://img.shields.io/static/v1?label=test+coverage&message=99%&color=active&style=plastic)](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
60
67
  [![issues sheild](https://img.shields.io/github/issues-raw/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver/issues)
61
68
  [![GitHub pull requests](https://img.shields.io/github/issues-pr/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/pulls)
62
- [![GitHub contributors](https://img.shields.io/github/contributors/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/graphs/contributors)
63
- [![PR's Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com)
64
69
 
65
70
  [![follow sheild](https://img.shields.io/github/followers/marcpage?label=Follow&style=social)](https://github.com/marcpage?tab=followers)
66
71
  [![watch sheild](https://img.shields.io/github/watchers/marcpage/devops-driver?label=Watch&style=social)](https://github.com/marcpage/devops-driver/watchers)
@@ -68,3 +73,4 @@ Requires-Dist: PyYAML==6.0.1
68
73
  # devops-driver
69
74
 
70
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,5 +1,9 @@
1
1
  ![status sheild](https://img.shields.io/static/v1?label=status&message=starting...&color=inactive&style=plastic)
2
+ [![status sheild](https://img.shields.io/static/v1?label=released&message=v0.1.35&color=active&style=plastic)](https://pypi.org/project/devopsdriver/0.1.35/)
2
3
  [![GitHub](https://img.shields.io/github/license/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
4
+ [![GitHub contributors](https://img.shields.io/github/contributors/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/graphs/contributors)
5
+ [![PR's Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com)
6
+
3
7
  [![commit sheild](https://img.shields.io/github/last-commit/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver/commits)
4
8
  [![activity sheild](https://img.shields.io/github/commit-activity/m/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver/commits)
5
9
  [![GitHub top language](https://img.shields.io/github/languages/top/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver)
@@ -9,8 +13,6 @@
9
13
  [![status sheild](https://img.shields.io/static/v1?label=test+coverage&message=99%&color=active&style=plastic)](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
10
14
  [![issues sheild](https://img.shields.io/github/issues-raw/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver/issues)
11
15
  [![GitHub pull requests](https://img.shields.io/github/issues-pr/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/pulls)
12
- [![GitHub contributors](https://img.shields.io/github/contributors/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/graphs/contributors)
13
- [![PR's Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com)
14
16
 
15
17
  [![follow sheild](https://img.shields.io/github/followers/marcpage?label=Follow&style=social)](https://github.com/marcpage?tab=followers)
16
18
  [![watch sheild](https://img.shields.io/github/watchers/marcpage/devops-driver?label=Watch&style=social)](https://github.com/marcpage/devops-driver/watchers)
@@ -18,3 +20,4 @@
18
20
  # devops-driver
19
21
 
20
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
+
@@ -1,5 +1,7 @@
1
1
  """ DevOps tools """
2
2
 
3
- __version__ = "0.1.32"
3
+ from .azure import Azure
4
+
5
+ __version__ = "0.1.35"
4
6
  __author__ = "Marc Page"
5
7
  __credits__ = ""
@@ -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.azure.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,4 @@
1
+ """ init workitem module """
2
+
3
+ # Re-export symbols to make things make sense
4
+ from .workitem import WorkItem
@@ -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.azure.workitem import WorkItem
10
+ from devopsdriver.azure.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())