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.
Files changed (52) hide show
  1. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/PKG-INFO +7 -7
  2. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/README.md +2 -2
  3. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/__init__.py +1 -2
  4. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/__init__.py +1 -1
  5. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/azureobject.py +0 -1
  6. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/builds/client.py +0 -1
  7. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/clients.py +0 -2
  8. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/client.py +0 -1
  9. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/log.py +1 -3
  10. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/pipeline.py +0 -1
  11. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/run.py +0 -1
  12. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/timestamp.py +0 -1
  13. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/workitem/client.py +0 -1
  14. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/workitem/wiql.py +10 -2
  15. devopsdriver-0.1.54/devopsdriver/dataobject.py +214 -0
  16. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/github/client.py +0 -1
  17. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/manage_settings.py +3 -4
  18. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/sendmail.py +0 -2
  19. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/settings.py +99 -8
  20. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/template.py +0 -1
  21. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/PKG-INFO +7 -7
  22. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/requires.txt +4 -4
  23. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/pyproject.toml +4 -4
  24. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_workitem_wiql.py +43 -1
  25. devopsdriver-0.1.54/tests/test_dataobject.py +253 -0
  26. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_settings.py +242 -7
  27. devopsdriver-0.1.52/devopsdriver/dataobject.py +0 -64
  28. devopsdriver-0.1.52/tests/test_dataobject.py +0 -29
  29. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/LICENSE +0 -0
  30. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/builds/__init__.py +0 -0
  31. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/builds/build.py +0 -0
  32. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/pipeline/__init__.py +0 -0
  33. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/azdo/workitem/__init__.py +0 -0
  34. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/github/__init__.py +0 -0
  35. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver/templates/manage_settings.txt.mako +0 -0
  36. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/SOURCES.txt +0 -0
  37. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/dependency_links.txt +0 -0
  38. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/entry_points.txt +0 -0
  39. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/devopsdriver.egg-info/top_level.txt +0 -0
  40. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/setup.cfg +0 -0
  41. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_azureobject.py +0 -0
  42. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_build.py +0 -0
  43. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_build_client.py +0 -0
  44. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_clients.py +0 -0
  45. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_pipeline.py +0 -0
  46. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_pipeline_client.py +0 -0
  47. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_pipeline_run.py +0 -0
  48. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_timestamp.py +0 -0
  49. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_azure_workitem_client.py +0 -0
  50. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_manage_settings.py +0 -0
  51. {devopsdriver-0.1.52 → devopsdriver-0.1.54}/tests/test_sendmail.py +0 -0
  52. {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.52
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.6.0
51
- Requires-Dist: setuptools==80.9.0
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.10
54
- Requires-Dist: PyGithub==2.8.1
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
  ![status sheild](https://img.shields.io/static/v1?label=status&message=beta&color=blue&style=plastic)
67
- [![status sheild](https://img.shields.io/static/v1?label=released&message=v0.1.52&color=active&style=plastic)](https://pypi.org/project/devopsdriver/0.1.52/)
67
+ [![status sheild](https://img.shields.io/static/v1?label=released&message=v0.1.54&color=active&style=plastic)](https://pypi.org/project/devopsdriver/0.1.54/)
68
68
  [![GitHub](https://img.shields.io/github/license/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
69
69
  [![GitHub contributors](https://img.shields.io/github/contributors/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/graphs/contributors)
70
70
  [![PR's Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com)
@@ -75,7 +75,7 @@ Dynamic: license-file
75
75
  [![size sheild](https://img.shields.io/github/languages/code-size/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver)
76
76
 
77
77
  [![example workflow](https://github.com/marcpage/devops-driver/actions/workflows/pr.yml/badge.svg)](https://github.com/marcpage/devops-driver/actions/workflows/pr.yml)
78
- [![status sheild](https://img.shields.io/static/v1?label=test+coverage&message=98%&color=active&style=plastic)](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
78
+ [![status sheild](https://img.shields.io/static/v1?label=test+coverage&message=97%&color=active&style=plastic)](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
79
79
  [![issues sheild](https://img.shields.io/github/issues-raw/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver/issues)
80
80
  [![GitHub pull requests](https://img.shields.io/github/issues-pr/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/pulls)
81
81
 
@@ -1,7 +1,7 @@
1
1
  # devops-driver
2
2
 
3
3
  ![status sheild](https://img.shields.io/static/v1?label=status&message=beta&color=blue&style=plastic)
4
- [![status sheild](https://img.shields.io/static/v1?label=released&message=v0.1.52&color=active&style=plastic)](https://pypi.org/project/devopsdriver/0.1.52/)
4
+ [![status sheild](https://img.shields.io/static/v1?label=released&message=v0.1.54&color=active&style=plastic)](https://pypi.org/project/devopsdriver/0.1.54/)
5
5
  [![GitHub](https://img.shields.io/github/license/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
6
6
  [![GitHub contributors](https://img.shields.io/github/contributors/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/graphs/contributors)
7
7
  [![PR's Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com)
@@ -12,7 +12,7 @@
12
12
  [![size sheild](https://img.shields.io/github/languages/code-size/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver)
13
13
 
14
14
  [![example workflow](https://github.com/marcpage/devops-driver/actions/workflows/pr.yml/badge.svg)](https://github.com/marcpage/devops-driver/actions/workflows/pr.yml)
15
- [![status sheild](https://img.shields.io/static/v1?label=test+coverage&message=98%&color=active&style=plastic)](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
15
+ [![status sheild](https://img.shields.io/static/v1?label=test+coverage&message=97%&color=active&style=plastic)](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
16
16
  [![issues sheild](https://img.shields.io/github/issues-raw/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver/issues)
17
17
  [![GitHub pull requests](https://img.shields.io/github/issues-pr/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/pulls)
18
18
 
@@ -6,7 +6,6 @@ from .template import Template
6
6
  from .azdo.clients import Azure
7
7
  from .github.client import Github
8
8
 
9
-
10
- __version__ = "0.1.52"
9
+ __version__ = "0.1.54"
11
10
  __author__ = "Marc Page"
12
11
  __credits__ = ""
@@ -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
@@ -3,7 +3,6 @@
3
3
 
4
4
  """An Azure Devops WorkItem"""
5
5
 
6
-
7
6
  from msrest.serialization import Model
8
7
 
9
8
  from devopsdriver.azdo.timestamp import Timestamp
@@ -3,7 +3,6 @@
3
3
 
4
4
  """Azure Build Client"""
5
5
 
6
-
7
6
  from datetime import datetime
8
7
  from azure.devops.v7_1.build import BuildClient
9
8
 
@@ -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,7 +3,6 @@
3
3
 
4
4
  """Pipeline client"""
5
5
 
6
-
7
6
  from azure.devops.v7_1.pipelines import PipelinesClient
8
7
 
9
8
  from .pipeline import Pipeline
@@ -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
@@ -3,7 +3,6 @@
3
3
 
4
4
  """Azure Pipeline"""
5
5
 
6
-
7
6
  from azure.devops.v7_1.pipelines.models import Pipeline as AzurePipeline
8
7
  from azure.devops.v7_1.pipelines import PipelinesClient
9
8
 
@@ -3,7 +3,6 @@
3
3
 
4
4
  """Pipeline Run"""
5
5
 
6
-
7
6
  from azure.devops.v7_1.pipelines.models import Pipeline
8
7
  from azure.devops.v7_1.pipelines.models import Run as AzureRun
9
8
  from azure.devops.v7_1.pipelines import PipelinesClient
@@ -3,7 +3,6 @@
3
3
 
4
4
  """Tools that help when working with Azure"""
5
5
 
6
-
7
6
  from datetime import datetime, timezone, timedelta
8
7
  from functools import total_ordering
9
8
 
@@ -3,7 +3,6 @@
3
3
 
4
4
  """Azure WorkItem Client"""
5
5
 
6
-
7
6
  from azure.devops.v7_1.work_item_tracking.models import Wiql as AzureWiql
8
7
  from azure.devops.v7_1.work_item_tracking.models import WorkItem as AzureWorkItem
9
8
  from azure.devops.v7_1.work_item_tracking.models import TeamContext
@@ -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)
@@ -2,7 +2,6 @@
2
2
 
3
3
  """Manages GitHub connection"""
4
4
 
5
-
6
5
  from github import Github as Github_connection, Auth
7
6
 
8
7
  from devopsdriver.settings import Settings
@@ -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():
@@ -3,7 +3,6 @@
3
3
 
4
4
  """Module Doc"""
5
5
 
6
-
7
6
  from os.path import dirname, basename, splitext
8
7
 
9
8
  from mako.lookup import TemplateLookup
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devopsdriver
3
- Version: 0.1.52
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.6.0
51
- Requires-Dist: setuptools==80.9.0
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.10
54
- Requires-Dist: PyGithub==2.8.1
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
  ![status sheild](https://img.shields.io/static/v1?label=status&message=beta&color=blue&style=plastic)
67
- [![status sheild](https://img.shields.io/static/v1?label=released&message=v0.1.52&color=active&style=plastic)](https://pypi.org/project/devopsdriver/0.1.52/)
67
+ [![status sheild](https://img.shields.io/static/v1?label=released&message=v0.1.54&color=active&style=plastic)](https://pypi.org/project/devopsdriver/0.1.54/)
68
68
  [![GitHub](https://img.shields.io/github/license/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
69
69
  [![GitHub contributors](https://img.shields.io/github/contributors/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/graphs/contributors)
70
70
  [![PR's Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com)
@@ -75,7 +75,7 @@ Dynamic: license-file
75
75
  [![size sheild](https://img.shields.io/github/languages/code-size/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver)
76
76
 
77
77
  [![example workflow](https://github.com/marcpage/devops-driver/actions/workflows/pr.yml/badge.svg)](https://github.com/marcpage/devops-driver/actions/workflows/pr.yml)
78
- [![status sheild](https://img.shields.io/static/v1?label=test+coverage&message=98%&color=active&style=plastic)](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
78
+ [![status sheild](https://img.shields.io/static/v1?label=test+coverage&message=97%&color=active&style=plastic)](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
79
79
  [![issues sheild](https://img.shields.io/github/issues-raw/marcpage/devops-driver?style=plastic)](https://github.com/marcpage/devops-driver/issues)
80
80
  [![GitHub pull requests](https://img.shields.io/github/issues-pr/marcpage/devops-driver?style=flat)](https://github.com/marcpage/devops-driver/pulls)
81
81