yellowdog-python-examples 7.13.0__tar.gz → 7.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {yellowdog_python_examples-7.13.0/yellowdog_python_examples.egg-info → yellowdog_python_examples-7.14.0}/PKG-INFO +1 -1
  2. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/README.md +24 -1
  3. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/pyproject.toml +1 -0
  4. yellowdog_python_examples-7.14.0/yellowdog_cli/__init__.py +1 -0
  5. yellowdog_python_examples-7.14.0/yellowdog_cli/analyse.py +715 -0
  6. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/provision.py +3 -0
  7. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/args.py +46 -19
  8. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/interactive.py +18 -3
  9. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/load_config.py +10 -7
  10. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/printing.py +2 -0
  11. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0/yellowdog_python_examples.egg-info}/PKG-INFO +1 -1
  12. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_python_examples.egg-info/SOURCES.txt +1 -0
  13. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_python_examples.egg-info/entry_points.txt +1 -0
  14. yellowdog_python_examples-7.13.0/yellowdog_cli/__init__.py +0 -1
  15. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/LICENSE +0 -0
  16. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/PYPI_README.md +0 -0
  17. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/requirements.txt +0 -0
  18. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/setup.cfg +0 -0
  19. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/tests/test_create_remove.py +0 -0
  20. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/tests/test_demos.py +0 -0
  21. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/tests/test_dryruns.py +0 -0
  22. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/tests/test_entrypoints.py +0 -0
  23. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/tests/test_gui.py +0 -0
  24. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/tests/test_list.py +0 -0
  25. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/tests/test_objects.py +0 -0
  26. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/tests/test_variable_processing.py +0 -0
  27. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/abort.py +0 -0
  28. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/admin.py +0 -0
  29. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/boost.py +0 -0
  30. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/cancel.py +0 -0
  31. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/cloudwizard.py +0 -0
  32. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/create.py +0 -0
  33. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/delete.py +0 -0
  34. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/download.py +0 -0
  35. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/follow.py +0 -0
  36. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/format_json.py +0 -0
  37. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/hold.py +0 -0
  38. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/instantiate.py +0 -0
  39. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/jsonnet2json.py +0 -0
  40. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/list.py +0 -0
  41. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/remove.py +0 -0
  42. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/resize.py +0 -0
  43. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/show.py +0 -0
  44. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/shutdown.py +0 -0
  45. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/start.py +0 -0
  46. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/submit.py +0 -0
  47. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/terminate.py +0 -0
  48. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/upload.py +0 -0
  49. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/__init__.py +0 -0
  50. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/check_imports.py +0 -0
  51. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/cloudwizard_aws.py +0 -0
  52. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/cloudwizard_aws_types.py +0 -0
  53. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/cloudwizard_azure.py +0 -0
  54. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/cloudwizard_common.py +0 -0
  55. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/cloudwizard_gcp.py +0 -0
  56. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/compact_json.py +0 -0
  57. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/config_types.py +0 -0
  58. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/csv_data.py +0 -0
  59. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/entity_utils.py +0 -0
  60. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/follow_utils.py +0 -0
  61. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/items.py +0 -0
  62. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/load_resources.py +0 -0
  63. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/misc_utils.py +0 -0
  64. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/property_names.py +0 -0
  65. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/provision_utils.py +0 -0
  66. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/rich_console_input_fixed.py +0 -0
  67. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/settings.py +0 -0
  68. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/start_hold_common.py +0 -0
  69. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/submit_utils.py +0 -0
  70. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/type_check.py +0 -0
  71. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/upload_utils.py +0 -0
  72. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/validate_properties.py +0 -0
  73. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/variables.py +0 -0
  74. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/wrapper.py +0 -0
  75. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/utils/ydid_utils.py +0 -0
  76. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_cli/version.py +0 -0
  77. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_python_examples.egg-info/dependency_links.txt +0 -0
  78. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_python_examples.egg-info/requires.txt +0 -0
  79. {yellowdog_python_examples-7.13.0 → yellowdog_python_examples-7.14.0}/yellowdog_python_examples.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: yellowdog-python-examples
3
- Version: 7.13.0
3
+ Version: 7.14.0
4
4
  Summary: Python CLI commands using the YellowDog Python SDK
5
5
  Author-email: YellowDog Limited <support@yellowdog.co>
6
6
  Project-URL: Homepage, https://github.com/yellowdog/python-examples
@@ -123,9 +123,10 @@
123
123
  * [yd-hold](#yd-hold)
124
124
  * [yd-boost](#yd-boost)
125
125
  * [yd-show](#yd-show)
126
+ * [yd-analyse](#yd-analyse)
126
127
 
127
128
  <!-- Created by https://github.com/ekalinin/github-markdown-toc -->
128
- <!-- Added by: pwt, at: Mon Jan 20 08:54:22 GMT 2025 -->
129
+ <!-- Added by: pwt, at: Wed Feb 12 12:13:59 GMT 2025 -->
129
130
 
130
131
  <!--te-->
131
132
 
@@ -159,6 +160,7 @@ The commands provide the following capabilities:
159
160
  - **Creating, Updating and Removing** Source Templates, Compute Templates, Keyrings, Credentials, Namespace Storage Configurations, Image Families, Allowances, Configured Worker Pools, User Attributes, and Namespace Policies with the **`yd-create`** and **`yd-remove`** commands
160
161
  - **Following Event Streams** for Work Requirements, Worker Pools and Compute Requirements with the **`yd-follow`** command
161
162
  - **Showing** the details of any YellowDog entity using its YellowDog ID with the **`yd-show`** command
163
+ - **Analysing** whether worker pools are match for task groups
162
164
 
163
165
  The operation of the commands is controlled using TOML configuration files and/or environment variables and command line arguments. In addition, Work Requirements and Worker Pools can be defined using JSON files providing extensive configurability.
164
166
 
@@ -2765,3 +2767,24 @@ The `yd-show` command will show the details (in JSON) of any YellowDog entity th
2765
2767
  - Image Families, Image Groups, and Images
2766
2768
  - Keyrings
2767
2769
  - Allowances
2770
+
2771
+ ## yd-analyse
2772
+
2773
+ The `yd-analyse` command takes a Work Requirement or Task Group ID, and matches the selected Task Group against the available Nodes/Workers in selected Worker Pools.
2774
+
2775
+ ```commandline
2776
+ yd-analyse ydid:taskgrp:000000:83587010-5e26-4174-92a7-c7cc2612638d:1
2777
+ ```
2778
+
2779
+ The command checks if the **Run Specification** of a Task Group matches the properties of the selected Worker Pools and their registered Nodes and Workers, meaning there are Workers in the Worker Pool that could be claimed by the Task Group and that the Worker Pool would be a candidate for scaling up to meet the demands of the Task Group.
2780
+
2781
+ A detailed matching report showing the match against each specific property can be created, which can be used to highlight which properties are preventing a Worker Pool match.
2782
+
2783
+ The match status of a Worker Pool falls into one of four categories:
2784
+
2785
+ | **Match Status** | **Meaning** |
2786
+ |------------------|-----------------------------------------------------------------------------------------------------------------|
2787
+ | **YES** | The Worker Pool and every Node/Worker that has registered so far match the Task Group. |
2788
+ | **NO** | The Worker Pool and/or none of the Nodes/Workers that have registered so far match the Task Group. |
2789
+ | **MAYBE** | The Worker Pool matches the Task Group but no Nodes have yet registered, so Node/Worker properties are unknown. |
2790
+ | **PARTIAL** | The Worker Pool and some of the Nodes/Workers that have registered are a match for the Task Group. |
@@ -42,6 +42,7 @@
42
42
 
43
43
  [project.scripts]
44
44
  yd-abort = "yellowdog_cli.abort:main"
45
+ yd-analyse = "yellowdog_cli.analyse:main"
45
46
  yd-boost = "yellowdog_cli.boost:main"
46
47
  yd-cancel = "yellowdog_cli.cancel:main"
47
48
  yd-cloudwizard = "yellowdog_cli.cloudwizard:main"
@@ -0,0 +1 @@
1
+ __version__ = "7.14.0"
@@ -0,0 +1,715 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ A script to analyse task groups and check for matching worker pools.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from functools import cache
10
+ from typing import List
11
+
12
+ from tabulate import tabulate
13
+ from yellowdog_client.model import (
14
+ DoubleRange,
15
+ Node,
16
+ NodeSearch,
17
+ TaskGroup,
18
+ WorkerPool,
19
+ WorkRequirement,
20
+ )
21
+
22
+ from yellowdog_cli.utils.interactive import get_selected_list_items, select
23
+ from yellowdog_cli.utils.printing import (
24
+ indent,
25
+ print_error,
26
+ print_log,
27
+ print_table_core,
28
+ print_warning,
29
+ )
30
+ from yellowdog_cli.utils.wrapper import ARGS_PARSER, CLIENT, main_wrapper
31
+ from yellowdog_cli.utils.ydid_utils import YDIDType, get_ydid_type
32
+
33
+ NONE_STRING = "NONE"
34
+ UNKNOWN_STRING = "NOT YET KNOWN"
35
+
36
+
37
+ class MatchType(Enum):
38
+ YES = "YES" # Definite match to the worker pool (so far)
39
+ NO = "NO" # Definite non-match to the worker pool
40
+ MAYBE = "MAYBE (no Nodes have registered yet)" # Possible match to the worker pool; no nodes yet
41
+ PARTIAL = "PARTIAL (at least one Node matches, but not all)" # Some nodes in the worker pool match
42
+
43
+
44
+ @dataclass
45
+ class PropertyMatch:
46
+ property_name: str
47
+ task_group_value: str
48
+ worker_pool_values: str
49
+ match: MatchType
50
+
51
+
52
+ class MatchReport:
53
+ """
54
+ Class to contain and report on whether a worker pool
55
+ matches a task group.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ worker_pool_name: str,
61
+ worker_pool_id: str,
62
+ worker_tags: PropertyMatch,
63
+ task_types: PropertyMatch,
64
+ instance_types: PropertyMatch,
65
+ providers: PropertyMatch,
66
+ regions: PropertyMatch,
67
+ namespaces: PropertyMatch,
68
+ ram: PropertyMatch,
69
+ vcpus: PropertyMatch,
70
+ ):
71
+ self.worker_pool_name = worker_pool_name
72
+ self.worker_pool_id = worker_pool_id
73
+ self._namespaces = namespaces
74
+ self._worker_tags = worker_tags
75
+ self._task_types = task_types
76
+ self._instance_types = instance_types
77
+ self._providers = providers
78
+ self._regions = regions
79
+ self._ram = ram
80
+ self._vcpus = vcpus
81
+ self._property_match_list = [
82
+ self._worker_tags,
83
+ self._namespaces,
84
+ self._task_types,
85
+ self._instance_types,
86
+ self._providers,
87
+ self._regions,
88
+ self._ram,
89
+ self._vcpus,
90
+ ]
91
+
92
+ @cache
93
+ def summary(self) -> MatchType:
94
+ """
95
+ Summarise the overall match status for the worker pool.
96
+ """
97
+ if all(p.match == MatchType.YES for p in self._property_match_list):
98
+ return MatchType.YES
99
+
100
+ elif any(p.match == MatchType.NO for p in self._property_match_list):
101
+ return MatchType.NO
102
+
103
+ elif all(
104
+ p.match == MatchType.YES or p.match == MatchType.PARTIAL
105
+ for p in self._property_match_list
106
+ ):
107
+ return MatchType.PARTIAL
108
+
109
+ elif all(
110
+ p.match == MatchType.YES or p.match == MatchType.MAYBE
111
+ for p in self._property_match_list
112
+ ):
113
+ return MatchType.MAYBE
114
+
115
+ # Shouldn't get here
116
+ print_warning("Unable to calculate YES/PARTIAL/MAYBE/NO summary")
117
+ return MatchType.NO
118
+
119
+ def print_detailed_report(self):
120
+ """
121
+ Print a detailed matching report for the worker pool.
122
+ """
123
+ if self.summary() == MatchType.YES:
124
+ match_str = "MATCHING"
125
+ elif self.summary() == MatchType.MAYBE:
126
+ match_str = "POSSIBLY MATCHING"
127
+ elif self.summary() == MatchType.PARTIAL:
128
+ match_str = "PARTIALLY MATCHING"
129
+ else:
130
+ match_str = "NON-MATCHING"
131
+ print_log(
132
+ f"Detailed report for {match_str} worker pool "
133
+ f"'{self.worker_pool_name}' ({self.worker_pool_id})",
134
+ override_quiet=True,
135
+ )
136
+
137
+ # Print table
138
+ header_row = [
139
+ "Property Name",
140
+ "Task Group Run Specification",
141
+ "Worker Pool Nodes/Workers",
142
+ "Match Status",
143
+ ]
144
+ table_rows = []
145
+ for p in self._property_match_list:
146
+ table_rows.append(
147
+ [
148
+ p.property_name,
149
+ p.task_group_value,
150
+ p.worker_pool_values,
151
+ p.match.value,
152
+ ]
153
+ )
154
+
155
+ print_table_core(
156
+ indent(
157
+ tabulate(table_rows, headers=header_row, tablefmt="simple_outline"),
158
+ indent_width=4,
159
+ )
160
+ )
161
+
162
+
163
+ class WorkerPools:
164
+ """
165
+ Class to contain cached worker pools, and to check for matches.
166
+ Populates once for each run of the script.
167
+ """
168
+
169
+ def __init__(self):
170
+ self._populated = False
171
+ self._worker_pools: List[WorkerPool] = []
172
+
173
+ def populate(self) -> bool:
174
+ """
175
+ Populate the worker pool and compute requirement data, if not
176
+ already populated.
177
+ """
178
+ if self._populated:
179
+ return True
180
+
181
+ self._worker_pools = self._get_selected_worker_pools()
182
+ if len(self._worker_pools) == 0:
183
+ return False
184
+
185
+ self._populated = True
186
+ return True
187
+
188
+ def check_task_group_for_matching_worker_pools(
189
+ self, task_group: TaskGroup
190
+ ) -> List[MatchReport]:
191
+ """
192
+ Check a task group for matches with the selected worker pools.
193
+ """
194
+ return [
195
+ self._check_worker_pool_for_match(worker_pool, task_group)
196
+ for worker_pool in self._worker_pools
197
+ ]
198
+
199
+ def _check_worker_pool_for_match(
200
+ self, worker_pool: WorkerPool, task_group: TaskGroup
201
+ ) -> MatchReport:
202
+ """
203
+ Check a worker pool against the requirements of
204
+ a task group.
205
+ """
206
+ return MatchReport(
207
+ worker_pool_name=worker_pool.name,
208
+ worker_pool_id=worker_pool.id,
209
+ namespaces=self._match_namespaces(task_group, worker_pool),
210
+ worker_tags=self._match_worker_tags(task_group, worker_pool),
211
+ instance_types=self._match_instance_types(task_group, worker_pool),
212
+ task_types=self._match_task_types(task_group, worker_pool),
213
+ providers=self._match_providers(task_group, worker_pool),
214
+ regions=self._match_regions(task_group, worker_pool),
215
+ ram=self._match_ram(task_group, worker_pool),
216
+ vcpus=self._match_vcpus(task_group, worker_pool),
217
+ )
218
+
219
+ @staticmethod
220
+ def _match_worker_tags(
221
+ task_group: TaskGroup, worker_pool: WorkerPool
222
+ ) -> PropertyMatch:
223
+ return PropertyMatch(
224
+ property_name="Worker Tag(s)",
225
+ task_group_value=(
226
+ NONE_STRING
227
+ if task_group.runSpecification.workerTags is None
228
+ else ", ".join(task_group.runSpecification.workerTags)
229
+ ),
230
+ worker_pool_values=(
231
+ NONE_STRING
232
+ if worker_pool.properties.workerTag is None
233
+ else worker_pool.properties.workerTag
234
+ ),
235
+ # Any single workerTag in the list can match
236
+ match=(
237
+ MatchType.YES
238
+ if task_group.runSpecification.workerTags is None
239
+ or (
240
+ worker_pool.properties.workerTag
241
+ in task_group.runSpecification.workerTags
242
+ )
243
+ else MatchType.NO
244
+ ),
245
+ )
246
+
247
+ def _match_instance_types(
248
+ self, task_group: TaskGroup, worker_pool: WorkerPool
249
+ ) -> PropertyMatch:
250
+ runspec_instance_types = (
251
+ set()
252
+ if task_group.runSpecification.instanceTypes is None
253
+ else set(task_group.runSpecification.instanceTypes)
254
+ )
255
+ nodes = self._get_all_nodes_in_worker_pool(worker_pool)
256
+ node_instance_types = {
257
+ node.details.instanceType
258
+ for node in nodes
259
+ if node.details.instanceType != ""
260
+ }
261
+ worker_pool_values = (
262
+ UNKNOWN_STRING
263
+ if len(nodes) == 0
264
+ else (
265
+ ", ".join(node_instance_types)
266
+ if len(node_instance_types) > 0
267
+ else NONE_STRING
268
+ )
269
+ )
270
+
271
+ # Calculate match
272
+ if len(runspec_instance_types) == 0:
273
+ match_type = MatchType.YES
274
+ elif len(nodes) == 0:
275
+ match_type = MatchType.MAYBE
276
+ else:
277
+ matching_nodes_counter = 0
278
+ for node in nodes:
279
+ if node.details.instanceType in runspec_instance_types:
280
+ matching_nodes_counter += 1
281
+ if matching_nodes_counter == 0:
282
+ match_type = MatchType.NO
283
+ elif matching_nodes_counter < len(nodes):
284
+ match_type = MatchType.PARTIAL
285
+ else:
286
+ match_type = MatchType.YES
287
+
288
+ return PropertyMatch(
289
+ property_name="Instance Type(s)",
290
+ task_group_value=(
291
+ NONE_STRING
292
+ if task_group.runSpecification.instanceTypes is None
293
+ else ", ".join(task_group.runSpecification.instanceTypes)
294
+ ),
295
+ worker_pool_values=worker_pool_values,
296
+ match=match_type,
297
+ )
298
+
299
+ def _match_task_types(
300
+ self, task_group: TaskGroup, worker_pool: WorkerPool
301
+ ) -> PropertyMatch:
302
+ runspec_task_types = (
303
+ set()
304
+ if task_group.runSpecification.taskTypes is None
305
+ else set(task_group.runSpecification.taskTypes)
306
+ )
307
+ nodes = self._get_all_nodes_in_worker_pool(worker_pool)
308
+ node_task_types = set(
309
+ [
310
+ task_type
311
+ for node in nodes
312
+ for task_type in node.details.supportedTaskTypes
313
+ ]
314
+ )
315
+
316
+ worker_pool_values = (
317
+ UNKNOWN_STRING
318
+ if len(nodes) == 0
319
+ else (
320
+ ", ".join(node_task_types) if len(node_task_types) > 0 else NONE_STRING
321
+ )
322
+ )
323
+
324
+ # Calculate match
325
+ if len(nodes) == 0:
326
+ match_type = MatchType.MAYBE
327
+ else:
328
+ matching_node_counter = 0
329
+ for node in nodes:
330
+ if runspec_task_types <= set(node.details.supportedTaskTypes):
331
+ matching_node_counter += 1
332
+ if matching_node_counter == 0:
333
+ match_type = MatchType.NO
334
+ elif matching_node_counter < len(nodes):
335
+ match_type = MatchType.PARTIAL
336
+ else:
337
+ match_type = MatchType.YES
338
+
339
+ return PropertyMatch(
340
+ property_name="Task Type(s)",
341
+ task_group_value=(
342
+ NONE_STRING
343
+ if task_group.runSpecification.taskTypes is None
344
+ else ", ".join(task_group.runSpecification.taskTypes)
345
+ ),
346
+ worker_pool_values=worker_pool_values,
347
+ match=match_type,
348
+ )
349
+
350
+ def _match_providers(
351
+ self, task_group: TaskGroup, worker_pool: WorkerPool
352
+ ) -> PropertyMatch:
353
+ runspec_providers = (
354
+ set()
355
+ if task_group.runSpecification.providers is None
356
+ else set(task_group.runSpecification.providers)
357
+ )
358
+ nodes = self._get_all_nodes_in_worker_pool(worker_pool)
359
+ node_providers = {node.details.provider.value for node in nodes}
360
+
361
+ # Calculate match
362
+ if len(runspec_providers) == 0:
363
+ match_type = MatchType.YES
364
+ elif len(nodes) == 0:
365
+ match_type = MatchType.MAYBE
366
+ else:
367
+ matching_node_counter = 0
368
+ for node in nodes:
369
+ if node.details.provider in runspec_providers:
370
+ matching_node_counter += 1
371
+ if matching_node_counter == 0:
372
+ match_type = MatchType.NO
373
+ elif matching_node_counter < len(nodes):
374
+ match_type = MatchType.PARTIAL
375
+ else:
376
+ match_type = MatchType.YES
377
+
378
+ return PropertyMatch(
379
+ property_name="Provider(s)",
380
+ task_group_value=(
381
+ NONE_STRING
382
+ if task_group.runSpecification.providers is None
383
+ else ", ".join([x.value for x in task_group.runSpecification.providers])
384
+ ),
385
+ worker_pool_values=(
386
+ UNKNOWN_STRING
387
+ if len(nodes) == 0
388
+ else (
389
+ NONE_STRING
390
+ if len(node_providers) == 0
391
+ else ", ".join(node_providers)
392
+ )
393
+ ),
394
+ match=match_type,
395
+ )
396
+
397
+ def _match_regions(
398
+ self, task_group: TaskGroup, worker_pool: WorkerPool
399
+ ) -> PropertyMatch:
400
+ runspec_regions = (
401
+ set()
402
+ if task_group.runSpecification.regions is None
403
+ else set(task_group.runSpecification.regions)
404
+ )
405
+ nodes = self._get_all_nodes_in_worker_pool(worker_pool)
406
+ node_regions = {
407
+ node.details.region for node in nodes if node.details.region != ""
408
+ }
409
+
410
+ # Calculate match
411
+ if len(runspec_regions) == 0:
412
+ match_type = MatchType.YES
413
+ elif len(nodes) == 0:
414
+ match_type = MatchType.MAYBE
415
+ else:
416
+ matching_node_counter = 0
417
+ for node in nodes:
418
+ if node.details.region in runspec_regions:
419
+ matching_node_counter += 1
420
+ if matching_node_counter == 0:
421
+ match_type = MatchType.NO
422
+ elif matching_node_counter < len(nodes):
423
+ match_type = MatchType.PARTIAL
424
+ else:
425
+ match_type = MatchType.YES
426
+
427
+ return PropertyMatch(
428
+ property_name="Region(s)",
429
+ task_group_value=(
430
+ NONE_STRING
431
+ if task_group.runSpecification.regions is None
432
+ else ", ".join(task_group.runSpecification.regions)
433
+ ),
434
+ worker_pool_values=(
435
+ UNKNOWN_STRING
436
+ if len(nodes) == 0
437
+ else ", ".join(node_regions) if len(node_regions) > 0 else NONE_STRING
438
+ ),
439
+ match=match_type,
440
+ )
441
+
442
+ @staticmethod
443
+ def _match_namespaces(
444
+ task_group: TaskGroup, worker_pool: WorkerPool
445
+ ) -> PropertyMatch:
446
+ return PropertyMatch(
447
+ property_name="Namespace(s)",
448
+ task_group_value=(
449
+ NONE_STRING
450
+ if task_group.runSpecification.namespaces is None
451
+ else ", ".join(task_group.runSpecification.namespaces)
452
+ ),
453
+ worker_pool_values=(
454
+ NONE_STRING if worker_pool.namespace is None else worker_pool.namespace
455
+ ),
456
+ match=(
457
+ MatchType.YES
458
+ if task_group.runSpecification.namespaces is None
459
+ or worker_pool.namespace in task_group.runSpecification.namespaces
460
+ else MatchType.NO
461
+ ),
462
+ )
463
+
464
+ def _match_ram(
465
+ self, task_group: TaskGroup, worker_pool: WorkerPool
466
+ ) -> PropertyMatch:
467
+
468
+ nodes = self._get_all_nodes_in_worker_pool(worker_pool)
469
+ nodes_ram = {node.details.ram for node in nodes}
470
+
471
+ # Calculate match
472
+ if task_group.runSpecification.ram is None:
473
+ match_type = MatchType.YES
474
+ elif len(nodes) == 0:
475
+ match_type = MatchType.MAYBE
476
+ else:
477
+ matching_node_counter = 0
478
+ for node in nodes:
479
+ if self._check_in_range(
480
+ node.details.ram, task_group.runSpecification.ram
481
+ ):
482
+ matching_node_counter += 1
483
+ if matching_node_counter == 0:
484
+ match_type = MatchType.NO
485
+ elif matching_node_counter < len(nodes):
486
+ match_type = MatchType.PARTIAL
487
+ else:
488
+ match_type = MatchType.YES
489
+
490
+ return PropertyMatch(
491
+ property_name="RAM (GB)",
492
+ task_group_value=(
493
+ NONE_STRING
494
+ if task_group.runSpecification.ram is None
495
+ else self._doublerange_str(task_group.runSpecification.ram)
496
+ ),
497
+ worker_pool_values=(
498
+ UNKNOWN_STRING
499
+ if len(nodes) == 0
500
+ else ", ".join([str(node_ram) for node_ram in nodes_ram])
501
+ ),
502
+ match=match_type,
503
+ )
504
+
505
+ def _match_vcpus(
506
+ self, task_group: TaskGroup, worker_pool: WorkerPool
507
+ ) -> PropertyMatch:
508
+
509
+ nodes = self._get_all_nodes_in_worker_pool(worker_pool)
510
+ nodes_vcpus = {node.details.vcpus for node in nodes}
511
+
512
+ # Calculate match
513
+ if task_group.runSpecification.vcpus is None:
514
+ match_type = MatchType.YES
515
+ elif len(nodes) == 0:
516
+ match_type = MatchType.MAYBE
517
+ else:
518
+ matching_node_counter = 0
519
+ for node in nodes:
520
+ if self._check_in_range(
521
+ node.details.vcpus, task_group.runSpecification.vcpus
522
+ ):
523
+ matching_node_counter += 1
524
+ if matching_node_counter == 0:
525
+ match_type = MatchType.NO
526
+ elif matching_node_counter < len(nodes):
527
+ match_type = MatchType.PARTIAL
528
+ else:
529
+ match_type = MatchType.YES
530
+
531
+ return PropertyMatch(
532
+ property_name="vCPUs Count",
533
+ task_group_value=(
534
+ NONE_STRING
535
+ if task_group.runSpecification.vcpus is None
536
+ else self._doublerange_str(task_group.runSpecification.vcpus)
537
+ ),
538
+ worker_pool_values=(
539
+ UNKNOWN_STRING
540
+ if len(nodes) == 0
541
+ else ", ".join([str(node_vcpus) for node_vcpus in nodes_vcpus])
542
+ ),
543
+ match=match_type,
544
+ )
545
+
546
+ @staticmethod
547
+ def _check_in_range(value: float, range: DoubleRange) -> bool:
548
+ """
549
+ Check whether a value is within a DoubleRange.
550
+ """
551
+ return True if range.min <= value <= range.max else False
552
+
553
+ def _get_all_nodes_in_worker_pool(self, worker_pool: WorkerPool) -> List[Node]:
554
+ """
555
+ Return all nodes in the worker pool.
556
+ """
557
+ return self._get_all_nodes_in_worker_pool_cached(worker_pool.id)
558
+
559
+ @staticmethod
560
+ def _doublerange_str(dr: DoubleRange) -> str:
561
+ """
562
+ Convert a DoubleRange into a tidy string.
563
+ """
564
+ if dr.min == dr.max:
565
+ return str(dr.min)
566
+ else:
567
+ return f"{dr.min} to {dr.max}"
568
+
569
+ @staticmethod
570
+ @cache
571
+ def _get_all_nodes_in_worker_pool_cached(worker_pool_id: str) -> List[Node]:
572
+ """
573
+ Cached version of the above with hashable argument.
574
+ """
575
+ try:
576
+ return CLIENT.worker_pool_client.get_nodes(
577
+ search=NodeSearch(worker_pool_id)
578
+ ).list_all()
579
+ except Exception as e:
580
+ raise Exception(f"Unable to get details of nodes: {e}")
581
+
582
+ @staticmethod
583
+ def _get_selected_worker_pools() -> List[WorkerPool]:
584
+ """
585
+ Get the list of worker pools.
586
+ """
587
+ print_log("Select Worker Pools to be analysed", override_quiet=True)
588
+ try:
589
+ return [
590
+ CLIENT.worker_pool_client.get_worker_pool_by_id(wps.id)
591
+ for wps in select(
592
+ client=CLIENT,
593
+ objects=CLIENT.worker_pool_client.find_all_worker_pools(),
594
+ force_interactive=True,
595
+ override_quiet=True,
596
+ )
597
+ ]
598
+ except Exception as e:
599
+ raise Exception(f"Failed to obtain list of Worker Pools: {e}")
600
+
601
+
602
+ WORKER_POOLS = WorkerPools()
603
+
604
+
605
+ @main_wrapper
606
+ def main():
607
+
608
+ if get_ydid_type(ARGS_PARSER.ydid) == YDIDType.TASK_GROUP:
609
+ _analyse_task_group(_get_task_group_by_id(ARGS_PARSER.ydid))
610
+
611
+ elif get_ydid_type(ARGS_PARSER.ydid) == YDIDType.WORK_REQUIREMENT:
612
+ work_requirement = _get_work_requirement_by_id(ARGS_PARSER.ydid)
613
+ if len(work_requirement.taskGroups) == 1:
614
+ _analyse_task_group(work_requirement.taskGroups[0])
615
+ else:
616
+ print_log("Please select the Task Group to analyse", override_quiet=True)
617
+ task_group = select(
618
+ CLIENT,
619
+ work_requirement.taskGroups,
620
+ override_quiet=True,
621
+ force_interactive=True,
622
+ single_result=True,
623
+ result_required=True,
624
+ )
625
+ print_log(
626
+ f"Selected Task Group '{task_group[0].name}' ({task_group[0].id})",
627
+ override_quiet=True,
628
+ )
629
+ _analyse_task_group(task_group[0])
630
+
631
+ else:
632
+ print_error(
633
+ f"Not a YellowDog Work Requirement or Task Group ID: '{ARGS_PARSER.ydid}'"
634
+ )
635
+
636
+
637
+ def _get_task_group_by_id(task_group_id) -> TaskGroup:
638
+ work_requirement_id = task_group_id[:-2].replace("taskgrp", "workreq")
639
+ try:
640
+ work_requirement: WorkRequirement = (
641
+ CLIENT.work_client.get_work_requirement_by_id(work_requirement_id)
642
+ )
643
+ return work_requirement.taskGroups[int(task_group_id[-1:]) - 1]
644
+ except Exception as e:
645
+ if "404" in str(e):
646
+ raise Exception(f"Task Group ID '{task_group_id}' not found")
647
+ else:
648
+ raise Exception(
649
+ f"Unable to obtain Task Group details for '{task_group_id}': {e}"
650
+ )
651
+
652
+
653
+ def _get_work_requirement_by_id(work_requirement_id) -> WorkRequirement:
654
+ try:
655
+ return CLIENT.work_client.get_work_requirement_by_id(work_requirement_id)
656
+ except Exception as e:
657
+ if "404" in str(e):
658
+ raise Exception(f"Work Requirement ID '{work_requirement_id}' not found")
659
+ else:
660
+ raise Exception(
661
+ f"Unable to obtain Work Requirement details for '{work_requirement_id}': {e}"
662
+ )
663
+
664
+
665
+ def _analyse_task_group(task_group: TaskGroup):
666
+ """
667
+ Analyse a Task Group.
668
+ """
669
+ print_log(
670
+ f"Analysing Task Group: '{task_group.name}' ({task_group.id})",
671
+ override_quiet=True,
672
+ )
673
+
674
+ if not WORKER_POOLS.populate():
675
+ print_log("No Worker Pools selected")
676
+ return
677
+
678
+ match_reports: List[MatchReport] = (
679
+ WORKER_POOLS.check_task_group_for_matching_worker_pools(task_group=task_group)
680
+ )
681
+
682
+ # Summary table
683
+ print_log("Summary of Worker Pool matches:", override_quiet=True)
684
+ header_row = ["", "Worker Pool Name", "Worker Pool ID", "Worker Pool Match?"]
685
+ table_rows = []
686
+ for index, match_report in enumerate(match_reports):
687
+ table_rows.append(
688
+ [
689
+ index + 1,
690
+ match_report.worker_pool_name,
691
+ match_report.worker_pool_id,
692
+ match_report.summary().value,
693
+ ]
694
+ )
695
+ print_table_core(
696
+ indent(
697
+ tabulate(table_rows, headers=header_row, tablefmt="simple_outline"),
698
+ indent_width=4,
699
+ ),
700
+ )
701
+
702
+ # Detailed reporting
703
+ print_log(
704
+ "Please select Worker Pools for which to show a detailed report",
705
+ override_quiet=True,
706
+ )
707
+ for selected_item in get_selected_list_items(len(match_reports)):
708
+ match_reports[selected_item - 1].print_detailed_report()
709
+
710
+ print_log("Task Group analysis complete")
711
+
712
+
713
+ # Entry point
714
+ if __name__ == "__main__":
715
+ main()
@@ -427,6 +427,9 @@ def create_worker_pool_from_toml():
427
427
  )
428
428
  print_log(idle_pool_shutdown_msg)
429
429
 
430
+ if CONFIG_WP.metrics_enabled:
431
+ print_log("Node metrics are enabled")
432
+
430
433
  if ARGS_PARSER.dry_run:
431
434
  print_log("Dry-run: Complete")
432
435
  return
@@ -38,6 +38,9 @@ class CLIParser:
38
38
  Create the argument parser, and parse the command
39
39
  line arguments. Argument availability depends on module.
40
40
  """
41
+ self.tag_required = False
42
+ self.namespace_required = False
43
+
41
44
  parser = argparse.ArgumentParser(description=description)
42
45
 
43
46
  # Common arguments across all commands
@@ -83,25 +86,6 @@ class CLIParser:
83
86
  help="the URL of the YellowDog Platform API",
84
87
  metavar="<url>",
85
88
  )
86
- parser.add_argument(
87
- "--variable",
88
- "-v",
89
- type=str,
90
- required=False,
91
- action="append",
92
- help=(
93
- "user-defined variable substitution; the option can be supplied"
94
- " multiple times, one per variable"
95
- ),
96
- metavar="<var1=v1>",
97
- )
98
- parser.add_argument(
99
- "--quiet",
100
- "-q",
101
- action="store_true",
102
- required=False,
103
- help="suppress (non-error, non-interactive) status and progress messages",
104
- )
105
89
  parser.add_argument(
106
90
  "--debug",
107
91
  action="store_true",
@@ -122,7 +106,28 @@ class CLIParser:
122
106
  help="disable colouring and text wrapping in command output",
123
107
  )
124
108
 
109
+ parser.add_argument(
110
+ "--quiet",
111
+ "-q",
112
+ action="store_true",
113
+ required=False,
114
+ help="suppress (non-error, non-interactive) status and progress messages",
115
+ )
116
+
125
117
  # Module-specific argument sets
118
+ if not any(module in sys.argv[0] for module in ["analyse"]):
119
+ parser.add_argument(
120
+ "--variable",
121
+ "-v",
122
+ type=str,
123
+ required=False,
124
+ action="append",
125
+ help=(
126
+ "user-defined variable substitution; the option can be supplied"
127
+ " multiple times, one per variable"
128
+ ),
129
+ metavar="<var1=v1>",
130
+ )
126
131
 
127
132
  if not any(
128
133
  module in sys.argv[0]
@@ -132,8 +137,10 @@ class CLIParser:
132
137
  "cloudwizard",
133
138
  "follow",
134
139
  "list",
140
+ "analyse",
135
141
  ]
136
142
  ):
143
+ self.namespace_required = True
137
144
  parser.add_argument(
138
145
  "--namespace",
139
146
  "-n",
@@ -147,6 +154,7 @@ class CLIParser:
147
154
  ),
148
155
  metavar="<namespace>",
149
156
  )
157
+ self.tag_required = True
150
158
  parser.add_argument(
151
159
  "--tag",
152
160
  "-t",
@@ -1105,6 +1113,16 @@ class CLIParser:
1105
1113
  ),
1106
1114
  )
1107
1115
 
1116
+ if "analyse" in sys.argv[0]:
1117
+ parser.add_argument(
1118
+ "ydid",
1119
+ metavar="<ydid-of-work-requirement-or-task-group>",
1120
+ type=str,
1121
+ help=(
1122
+ "the YellowDog ID of the work requirement or task group to be analysed"
1123
+ ),
1124
+ )
1125
+
1108
1126
  self.args = parser.parse_args()
1109
1127
 
1110
1128
  if self.args.docs:
@@ -1585,6 +1603,11 @@ class CLIParser:
1585
1603
  def substitute_ids(self) -> Optional[bool]:
1586
1604
  return self.args.substitute_ids
1587
1605
 
1606
+ @property
1607
+ @allow_missing_attribute
1608
+ def ydid(self) -> Optional[str]:
1609
+ return self.args.ydid
1610
+
1588
1611
 
1589
1612
  def lookup_module_description(module_name: str) -> Optional[str]:
1590
1613
  """
@@ -1633,6 +1656,10 @@ def lookup_module_description(module_name: str) -> Optional[str]:
1633
1656
  suffix = "boosting Allowances"
1634
1657
  elif "show" in module_name:
1635
1658
  suffix = "showing the JSON details of entities referenced by their YDIDs"
1659
+ elif "analyse" in module_name:
1660
+ suffix = (
1661
+ "analysing if a task group is matched by workers in selected worker pools"
1662
+ )
1636
1663
 
1637
1664
  return None if suffix is None else prefix + suffix
1638
1665
 
@@ -63,8 +63,23 @@ def select(
63
63
  if not ARGS_PARSER.interactive and force_interactive is False:
64
64
  return objects
65
65
 
66
+ return [
67
+ objects[x - 1]
68
+ for x in get_selected_list_items(
69
+ len(objects), result_required=result_required, single_result=single_result
70
+ )
71
+ ]
72
+
73
+
74
+ def get_selected_list_items(
75
+ num_items, result_required: bool = False, single_result: bool = False
76
+ ) -> List[int]:
77
+ """
78
+ Get a numbered selection list.
79
+ """
80
+
66
81
  def in_range(num: int) -> bool:
67
- if 1 <= num <= len(objects):
82
+ if 1 <= num <= num_items:
68
83
  return True
69
84
  else:
70
85
  print_error(f"'{num}' is out of range")
@@ -79,7 +94,7 @@ def select(
79
94
  )
80
95
  selector_string = _get_user_input(print_string(input_string) + " ")
81
96
  if selector_string.strip() == "*":
82
- selector_string = f"1-{len(objects)}"
97
+ selector_string = f"1-{num_items}"
83
98
  selector_list = selector_string.split(",")
84
99
  selector_set: Set[int] = set()
85
100
  error_flag = False
@@ -133,7 +148,7 @@ def select(
133
148
  else:
134
149
  print_log("No items selected")
135
150
 
136
- return [objects[x - 1] for x in selected_list]
151
+ return selected_list
137
152
 
138
153
 
139
154
  def confirmed(msg: str) -> bool:
@@ -121,15 +121,18 @@ def load_config_common() -> ConfigCommon:
121
121
  # Provide default values for namespace and tag
122
122
  if common_section.get(NAMESPACE, None) is None:
123
123
  common_section[NAMESPACE] = "default"
124
- print_log(
125
- "Using default value for 'namespace': " f"'{common_section[NAMESPACE]}'"
126
- )
124
+ if ARGS_PARSER.namespace_required:
125
+ print_log(
126
+ "Using default value for 'namespace': "
127
+ f"'{common_section[NAMESPACE]}'"
128
+ )
127
129
  if common_section.get(NAME_TAG, None) is None:
128
130
  common_section[NAME_TAG] = "{{username}}"
129
- print_log(
130
- "Using default value for 'tag/prefix/name' = "
131
- f"'{VARIABLE_SUBSTITUTIONS['username']}'"
132
- )
131
+ if ARGS_PARSER.tag_required:
132
+ print_log(
133
+ "Using default value for 'tag/prefix/name' = "
134
+ f"'{VARIABLE_SUBSTITUTIONS['username']}'"
135
+ )
133
136
 
134
137
  url = process_variable_substitutions(common_section.get(URL, DEFAULT_URL))
135
138
  if url != DEFAULT_URL:
@@ -103,6 +103,8 @@ class PrintTableHighlighter(RegexHighlighter):
103
103
  rf"(?P<table_outline>[{table_outline_chars}]*)",
104
104
  rf"(?P<table_content>[^{table_outline_chars}]*)",
105
105
  r"(?P<ydid>ydid:[a-z]*:[0-9ABCDEF]*:[0-9abcdef-]*)",
106
+ r"(?P<ydid>ydid:[a-z]*:[0-9ABCDEF]*:[0-9abcdef-]*:[0-9]*)",
107
+ r"(?P<ydid>ydid:[a-z]*:[0-9ABCDEF]*:[0-9abcdef-]*:[0-9]*:[0-9]*)",
106
108
  ] + HIGHLIGHTED_STATES
107
109
 
108
110
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: yellowdog-python-examples
3
- Version: 7.13.0
3
+ Version: 7.14.0
4
4
  Summary: Python CLI commands using the YellowDog Python SDK
5
5
  Author-email: YellowDog Limited <support@yellowdog.co>
6
6
  Project-URL: Homepage, https://github.com/yellowdog/python-examples
@@ -14,6 +14,7 @@ tests/test_variable_processing.py
14
14
  yellowdog_cli/__init__.py
15
15
  yellowdog_cli/abort.py
16
16
  yellowdog_cli/admin.py
17
+ yellowdog_cli/analyse.py
17
18
  yellowdog_cli/boost.py
18
19
  yellowdog_cli/cancel.py
19
20
  yellowdog_cli/cloudwizard.py
@@ -1,5 +1,6 @@
1
1
  [console_scripts]
2
2
  yd-abort = yellowdog_cli.abort:main
3
+ yd-analyse = yellowdog_cli.analyse:main
3
4
  yd-boost = yellowdog_cli.boost:main
4
5
  yd-cancel = yellowdog_cli.cancel:main
5
6
  yd-cloudwizard = yellowdog_cli.cloudwizard:main
@@ -1 +0,0 @@
1
- __version__ = "7.13.0"