DIRAC 9.0.0a64__py3-none-any.whl → 9.0.0a67__py3-none-any.whl
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.
- DIRAC/ConfigurationSystem/Client/LocalConfiguration.py +11 -8
- DIRAC/ConfigurationSystem/Client/VOMS2CSSynchronizer.py +1 -1
- DIRAC/Core/Security/IAMService.py +4 -3
- DIRAC/Core/Utilities/ClassAd/ClassAdLight.py +4 -290
- DIRAC/Core/Utilities/DErrno.py +5 -309
- DIRAC/Core/Utilities/JDL.py +1 -195
- DIRAC/Core/Utilities/List.py +1 -127
- DIRAC/Core/Utilities/ReturnValues.py +7 -252
- DIRAC/Core/Utilities/StateMachine.py +12 -178
- DIRAC/Core/Utilities/TimeUtilities.py +10 -253
- DIRAC/Core/Utilities/test/Test_JDL.py +0 -3
- DIRAC/Core/scripts/dirac_agent.py +1 -1
- DIRAC/DataManagementSystem/DB/FTS3DB.py +3 -0
- DIRAC/RequestManagementSystem/DB/test/RMSTestScenari.py +2 -0
- DIRAC/Resources/Catalog/RucioFileCatalogClient.py +1 -1
- DIRAC/Resources/Computing/test/Test_PoolComputingElement.py +2 -1
- DIRAC/TransformationSystem/Agent/TransformationCleaningAgent.py +1 -1
- DIRAC/Workflow/Modules/test/Test_Modules.py +5 -0
- DIRAC/WorkloadManagementSystem/Agent/JobCleaningAgent.py +1 -1
- DIRAC/WorkloadManagementSystem/Agent/StalledJobAgent.py +1 -1
- DIRAC/WorkloadManagementSystem/Agent/test/Test_Agent_JobAgent.py +2 -0
- DIRAC/WorkloadManagementSystem/Agent/test/Test_Agent_PushJobAgent.py +1 -0
- DIRAC/WorkloadManagementSystem/Client/JobState/JobManifest.py +32 -261
- DIRAC/WorkloadManagementSystem/Client/JobStatus.py +8 -93
- DIRAC/WorkloadManagementSystem/DB/JobDBUtils.py +18 -147
- DIRAC/WorkloadManagementSystem/DB/StatusUtils.py +125 -0
- DIRAC/WorkloadManagementSystem/DB/tests/Test_StatusUtils.py +28 -0
- DIRAC/WorkloadManagementSystem/JobWrapper/JobWrapper.py +4 -2
- DIRAC/WorkloadManagementSystem/JobWrapper/test/Test_JobWrapper.py +21 -5
- DIRAC/WorkloadManagementSystem/JobWrapper/test/Test_JobWrapperTemplate.py +4 -0
- DIRAC/WorkloadManagementSystem/Service/JobManagerHandler.py +1 -1
- DIRAC/WorkloadManagementSystem/Utilities/JobModel.py +28 -199
- DIRAC/WorkloadManagementSystem/Utilities/JobStatusUtility.py +1 -63
- DIRAC/WorkloadManagementSystem/Utilities/ParametricJob.py +7 -171
- DIRAC/WorkloadManagementSystem/Utilities/jobAdministration.py +0 -123
- DIRAC/WorkloadManagementSystem/Utilities/test/Test_JobModel.py +1 -5
- DIRAC/WorkloadManagementSystem/Utilities/test/Test_ParametricJob.py +45 -128
- DIRAC/__init__.py +55 -54
- {dirac-9.0.0a64.dist-info → dirac-9.0.0a67.dist-info}/METADATA +2 -1
- {dirac-9.0.0a64.dist-info → dirac-9.0.0a67.dist-info}/RECORD +44 -45
- DIRAC/Core/Utilities/test/Test_List.py +0 -150
- DIRAC/Core/Utilities/test/Test_Time.py +0 -88
- DIRAC/WorkloadManagementSystem/Utilities/test/Test_JobAdministration.py +0 -28
- {dirac-9.0.0a64.dist-info → dirac-9.0.0a67.dist-info}/WHEEL +0 -0
- {dirac-9.0.0a64.dist-info → dirac-9.0.0a67.dist-info}/entry_points.txt +0 -0
- {dirac-9.0.0a64.dist-info → dirac-9.0.0a67.dist-info}/licenses/LICENSE +0 -0
- {dirac-9.0.0a64.dist-info → dirac-9.0.0a67.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
1
|
+
"""This is the guy that parses and interprets the local configuration options."""
|
|
2
|
+
|
|
3
3
|
import re
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
@@ -286,16 +286,18 @@ class LocalConfiguration:
|
|
|
286
286
|
gLogger.exception()
|
|
287
287
|
return S_ERROR(str(e))
|
|
288
288
|
|
|
289
|
-
def initialize(self, *, returnErrors=False):
|
|
289
|
+
def initialize(self, *, returnErrors=False, requireSuccessfulSync=False):
|
|
290
290
|
"""Entrypoint used by :py:class:`DIRAC.initialize`
|
|
291
291
|
|
|
292
|
+
:param requireSuccessfulSync: fails if syncing with the remote did not work
|
|
293
|
+
|
|
292
294
|
TODO: This is currently a hack that returns a list of errors for so it
|
|
293
295
|
can be used by ``__addUserDataToConfiguration``. This entire module
|
|
294
296
|
should be refactored and simplified with ``Script.parseCommandLine``.
|
|
295
297
|
"""
|
|
296
298
|
errorsList = self.__loadCFGFiles()
|
|
297
299
|
if gConfigurationData.getServers():
|
|
298
|
-
retVal = self.syncRemoteConfiguration()
|
|
300
|
+
retVal = self.syncRemoteConfiguration(strict=requireSuccessfulSync)
|
|
299
301
|
if not retVal["OK"]:
|
|
300
302
|
return retVal
|
|
301
303
|
else:
|
|
@@ -321,7 +323,7 @@ class LocalConfiguration:
|
|
|
321
323
|
gLogger.showHeaders(True)
|
|
322
324
|
gLogger.enableLogsFromExternalLibs()
|
|
323
325
|
|
|
324
|
-
def loadUserData(self):
|
|
326
|
+
def loadUserData(self, requireSuccessfulSync=False):
|
|
325
327
|
"""
|
|
326
328
|
This is the magic method that reads the command line and processes it
|
|
327
329
|
It is used by the Script Base class and the dirac-service and dirac-agent scripts
|
|
@@ -329,6 +331,7 @@ class LocalConfiguration:
|
|
|
329
331
|
- any additional switches to be processed
|
|
330
332
|
- mandatory and default configuration configuration options must be defined.
|
|
331
333
|
|
|
334
|
+
:param requireSuccessfulSync: if True, will fail if the sync with remote server failed
|
|
332
335
|
"""
|
|
333
336
|
if self.initialized:
|
|
334
337
|
return S_OK()
|
|
@@ -336,7 +339,7 @@ class LocalConfiguration:
|
|
|
336
339
|
try:
|
|
337
340
|
if not self.isParsed:
|
|
338
341
|
self.__parseCommandLine() # Parse command line
|
|
339
|
-
retVal = self.__addUserDataToConfiguration()
|
|
342
|
+
retVal = self.__addUserDataToConfiguration(requireSuccessfulSync=requireSuccessfulSync)
|
|
340
343
|
|
|
341
344
|
for optionTuple in self.optionalEntryList:
|
|
342
345
|
optionPath = self.__getAbsolutePath(optionTuple[0])
|
|
@@ -496,8 +499,8 @@ class LocalConfiguration:
|
|
|
496
499
|
|
|
497
500
|
return errorsList
|
|
498
501
|
|
|
499
|
-
def __addUserDataToConfiguration(self):
|
|
500
|
-
retVal = self.initialize(returnErrors=True)
|
|
502
|
+
def __addUserDataToConfiguration(self, requireSuccessfulSync=False):
|
|
503
|
+
retVal = self.initialize(returnErrors=True, requireSuccessfulSync=requireSuccessfulSync)
|
|
501
504
|
if not retVal["OK"]:
|
|
502
505
|
return retVal
|
|
503
506
|
errorsList = retVal["Value"]
|
|
@@ -594,7 +594,7 @@ class VOMS2CSSynchronizer:
|
|
|
594
594
|
|
|
595
595
|
# Try to fill in the DiracX section
|
|
596
596
|
if self.useIAM:
|
|
597
|
-
iam_subs = self.iamSrv.getUsersSub()
|
|
597
|
+
iam_subs = self.iamSrv.getUsersSub(self.vo)
|
|
598
598
|
diracx_vo_config = {"DiracX": {"CsSync": {"VOs": {self.vo: {"UserSubjects": iam_subs}}}}}
|
|
599
599
|
iam_sub_cfg = CFG()
|
|
600
600
|
iam_sub_cfg.loadFromDict(diracx_vo_config)
|
|
@@ -144,7 +144,7 @@ class IAMService:
|
|
|
144
144
|
result = S_OK({"Users": users, "Errors": errors})
|
|
145
145
|
return result
|
|
146
146
|
|
|
147
|
-
def getUsersSub(self) -> dict[str, str]:
|
|
147
|
+
def getUsersSub(self, vo=None) -> dict[str, str]:
|
|
148
148
|
"""
|
|
149
149
|
Return the mapping based on IAM sub:
|
|
150
150
|
{nickname : sub}
|
|
@@ -152,6 +152,7 @@ class IAMService:
|
|
|
152
152
|
iam_users_raw = self._getIamUserDump()
|
|
153
153
|
diracx_user_section = {}
|
|
154
154
|
for user_info in iam_users_raw:
|
|
155
|
+
userGroups = [grp["display"] for grp in user_info.get("groups", [])]
|
|
155
156
|
# The nickname is available in the list of attributes
|
|
156
157
|
# (if configured so)
|
|
157
158
|
# in the form {'name': 'nickname', 'value': 'chaen'}
|
|
@@ -165,8 +166,8 @@ class IAMService:
|
|
|
165
166
|
except (KeyError, IndexError):
|
|
166
167
|
nickname = user_info["userName"]
|
|
167
168
|
sub = user_info["id"]
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
if not vo or vo in userGroups:
|
|
170
|
+
diracx_user_section[nickname] = sub
|
|
170
171
|
# reorder it
|
|
171
172
|
diracx_user_section = dict(sorted(diracx_user_section.items()))
|
|
172
173
|
|
|
@@ -2,294 +2,8 @@
|
|
|
2
2
|
Condor ClassAd library.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
# Import from DIRACCommon for backward compatibility
|
|
6
|
+
from DIRACCommon.Core.Utilities.ClassAd.ClassAdLight import ClassAd
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"""ClassAd constructor from a JDL string"""
|
|
9
|
-
self.contents = {}
|
|
10
|
-
result = self.__analyse_jdl(jdl)
|
|
11
|
-
if result:
|
|
12
|
-
self.contents = result
|
|
13
|
-
|
|
14
|
-
def __analyse_jdl(self, jdl, index=0):
|
|
15
|
-
"""Analyse one [] jdl enclosure"""
|
|
16
|
-
|
|
17
|
-
jdl = jdl.strip()
|
|
18
|
-
|
|
19
|
-
# Strip all the blanks first
|
|
20
|
-
# temp = jdl.replace(' ','').replace('\n','')
|
|
21
|
-
temp = jdl
|
|
22
|
-
|
|
23
|
-
result = {}
|
|
24
|
-
|
|
25
|
-
if temp[0] != "[" or temp[-1] != "]":
|
|
26
|
-
print("Invalid JDL: it should start with [ and end with ]")
|
|
27
|
-
return result
|
|
28
|
-
|
|
29
|
-
# Parse the jdl string now
|
|
30
|
-
body = temp[1:-1]
|
|
31
|
-
index = 0
|
|
32
|
-
namemode = 1
|
|
33
|
-
valuemode = 0
|
|
34
|
-
while index < len(body):
|
|
35
|
-
if namemode:
|
|
36
|
-
ind = body.find("=", index)
|
|
37
|
-
if ind != -1:
|
|
38
|
-
name = body[index:ind]
|
|
39
|
-
index = ind + 1
|
|
40
|
-
valuemode = 1
|
|
41
|
-
namemode = 0
|
|
42
|
-
else:
|
|
43
|
-
break
|
|
44
|
-
elif valuemode:
|
|
45
|
-
ind1 = body.find("[", index)
|
|
46
|
-
ind2 = body.find(";", index)
|
|
47
|
-
if ind1 != -1 and ind1 < ind2:
|
|
48
|
-
value, newind = self.__find_subjdl(body, ind1)
|
|
49
|
-
elif ind1 == -1 and ind2 == -1:
|
|
50
|
-
value = body[index:]
|
|
51
|
-
newind = len(body)
|
|
52
|
-
else:
|
|
53
|
-
if index == ind2:
|
|
54
|
-
return {}
|
|
55
|
-
else:
|
|
56
|
-
value = body[index:ind2]
|
|
57
|
-
newind = ind2 + 1
|
|
58
|
-
|
|
59
|
-
result[name.strip()] = value.strip().replace("\n", "")
|
|
60
|
-
index = newind
|
|
61
|
-
valuemode = 0
|
|
62
|
-
namemode = 1
|
|
63
|
-
|
|
64
|
-
return result
|
|
65
|
-
|
|
66
|
-
def __find_subjdl(self, body, index):
|
|
67
|
-
"""Find a full [] enclosure starting from index"""
|
|
68
|
-
result = ""
|
|
69
|
-
if body[index] != "[":
|
|
70
|
-
return (result, 0)
|
|
71
|
-
|
|
72
|
-
depth = 0
|
|
73
|
-
ind = index
|
|
74
|
-
while depth < 10:
|
|
75
|
-
ind1 = body.find("]", ind + 1)
|
|
76
|
-
ind2 = body.find("[", ind + 1)
|
|
77
|
-
if ind2 != -1 and ind2 < ind1:
|
|
78
|
-
depth += 1
|
|
79
|
-
ind = ind2
|
|
80
|
-
else:
|
|
81
|
-
if depth > 0:
|
|
82
|
-
depth -= 1
|
|
83
|
-
ind = ind1
|
|
84
|
-
else:
|
|
85
|
-
result = body[index : ind1 + 1]
|
|
86
|
-
if body[ind1 + 1] == ";":
|
|
87
|
-
return (result, ind1 + 2)
|
|
88
|
-
return result, 0
|
|
89
|
-
|
|
90
|
-
return result, 0
|
|
91
|
-
|
|
92
|
-
def insertAttributeInt(self, name, attribute):
|
|
93
|
-
"""Insert a named integer attribute"""
|
|
94
|
-
|
|
95
|
-
self.contents[name] = str(attribute)
|
|
96
|
-
|
|
97
|
-
def insertAttributeBool(self, name, attribute):
|
|
98
|
-
"""Insert a named boolean attribute"""
|
|
99
|
-
|
|
100
|
-
if attribute:
|
|
101
|
-
self.contents[name] = "true"
|
|
102
|
-
else:
|
|
103
|
-
self.contents[name] = "false"
|
|
104
|
-
|
|
105
|
-
def insertAttributeString(self, name, attribute):
|
|
106
|
-
"""Insert a named string attribute"""
|
|
107
|
-
|
|
108
|
-
self.contents[name] = '"' + str(attribute) + '"'
|
|
109
|
-
|
|
110
|
-
def insertAttributeVectorString(self, name, attributelist):
|
|
111
|
-
"""Insert a named string list attribute"""
|
|
112
|
-
|
|
113
|
-
tmp = ['"' + x + '"' for x in attributelist]
|
|
114
|
-
tmpstr = ",".join(tmp)
|
|
115
|
-
self.contents[name] = "{" + tmpstr + "}"
|
|
116
|
-
|
|
117
|
-
def insertAttributeVectorInt(self, name, attributelist):
|
|
118
|
-
"""Insert a named string list attribute"""
|
|
119
|
-
|
|
120
|
-
tmp = [str(x) for x in attributelist]
|
|
121
|
-
tmpstr = ",".join(tmp)
|
|
122
|
-
self.contents[name] = "{" + tmpstr + "}"
|
|
123
|
-
|
|
124
|
-
def insertAttributeVectorStringList(self, name, attributelist):
|
|
125
|
-
"""Insert a named list of string lists"""
|
|
126
|
-
|
|
127
|
-
listOfLists = []
|
|
128
|
-
for stringList in attributelist:
|
|
129
|
-
# tmp = map ( lambda x : '"' + x + '"', stringList )
|
|
130
|
-
tmpstr = ",".join(stringList)
|
|
131
|
-
listOfLists.append("{" + tmpstr + "}")
|
|
132
|
-
self.contents[name] = "{" + ",".join(listOfLists) + "}"
|
|
133
|
-
|
|
134
|
-
def lookupAttribute(self, name):
|
|
135
|
-
"""Check the presence of the given attribute"""
|
|
136
|
-
|
|
137
|
-
return name in self.contents
|
|
138
|
-
|
|
139
|
-
def set_expression(self, name, attribute):
|
|
140
|
-
"""Insert a named expression attribute"""
|
|
141
|
-
|
|
142
|
-
self.contents[name] = str(attribute)
|
|
143
|
-
|
|
144
|
-
def get_expression(self, name):
|
|
145
|
-
"""Get expression corresponding to a named attribute"""
|
|
146
|
-
|
|
147
|
-
if name in self.contents:
|
|
148
|
-
if isinstance(self.contents[name], int):
|
|
149
|
-
return str(self.contents[name])
|
|
150
|
-
return self.contents[name]
|
|
151
|
-
return ""
|
|
152
|
-
|
|
153
|
-
def isAttributeList(self, name):
|
|
154
|
-
"""Check if the given attribute is of the List type"""
|
|
155
|
-
attribute = self.get_expression(name).strip()
|
|
156
|
-
return attribute.startswith("{")
|
|
157
|
-
|
|
158
|
-
def getListFromExpression(self, name):
|
|
159
|
-
"""Get a list of strings from a given expression"""
|
|
160
|
-
|
|
161
|
-
tempString = self.get_expression(name).strip()
|
|
162
|
-
listMode = False
|
|
163
|
-
if tempString.startswith("{"):
|
|
164
|
-
tempString = tempString[1:-1]
|
|
165
|
-
listMode = True
|
|
166
|
-
|
|
167
|
-
tempString = tempString.replace(" ", "").replace("\n", "")
|
|
168
|
-
if tempString.find("{") < 0:
|
|
169
|
-
if not listMode:
|
|
170
|
-
tempString = tempString.replace('"', "")
|
|
171
|
-
if not tempString:
|
|
172
|
-
return []
|
|
173
|
-
return tempString.split(",")
|
|
174
|
-
|
|
175
|
-
resultList = []
|
|
176
|
-
while tempString:
|
|
177
|
-
if tempString.find("{") == 0:
|
|
178
|
-
end = tempString.find("}")
|
|
179
|
-
resultList.append(tempString[: end + 1])
|
|
180
|
-
tempString = tempString[end + 1 :]
|
|
181
|
-
if tempString.startswith(","):
|
|
182
|
-
tempString = tempString[1:]
|
|
183
|
-
elif tempString.find('"') == 0:
|
|
184
|
-
end = tempString[1:].find('"')
|
|
185
|
-
resultList.append(tempString[1 : end + 1])
|
|
186
|
-
tempString = tempString[end + 2 :]
|
|
187
|
-
if tempString.startswith(","):
|
|
188
|
-
tempString = tempString[1:]
|
|
189
|
-
else:
|
|
190
|
-
end = tempString.find(",")
|
|
191
|
-
if end < 0:
|
|
192
|
-
resultList.append(tempString.replace('"', "").replace(" ", ""))
|
|
193
|
-
break
|
|
194
|
-
else:
|
|
195
|
-
resultList.append(tempString[:end].replace('"', "").replace(" ", ""))
|
|
196
|
-
tempString = tempString[end + 1 :]
|
|
197
|
-
|
|
198
|
-
return resultList
|
|
199
|
-
|
|
200
|
-
def getDictionaryFromSubJDL(self, name):
|
|
201
|
-
"""Get a dictionary of the JDL attributes from a subsection"""
|
|
202
|
-
|
|
203
|
-
tempList = self.get_expression(name)[1:-1]
|
|
204
|
-
resDict = {}
|
|
205
|
-
for item in tempList.split(";"):
|
|
206
|
-
if len(item.split("=")) == 2:
|
|
207
|
-
resDict[item.split("=")[0].strip()] = item.split("=")[1].strip().replace('"', "")
|
|
208
|
-
else:
|
|
209
|
-
return {}
|
|
210
|
-
|
|
211
|
-
return resDict
|
|
212
|
-
|
|
213
|
-
def deleteAttribute(self, name):
|
|
214
|
-
"""Delete a named attribute"""
|
|
215
|
-
|
|
216
|
-
if name in self.contents:
|
|
217
|
-
del self.contents[name]
|
|
218
|
-
return 1
|
|
219
|
-
return 0
|
|
220
|
-
|
|
221
|
-
def isOK(self):
|
|
222
|
-
"""Check the JDL validity - to be defined"""
|
|
223
|
-
|
|
224
|
-
if self.contents:
|
|
225
|
-
return 1
|
|
226
|
-
return 0
|
|
227
|
-
|
|
228
|
-
def asJDL(self):
|
|
229
|
-
"""Convert the JDL description into a string"""
|
|
230
|
-
|
|
231
|
-
result = []
|
|
232
|
-
for name, value in sorted(self.contents.items()):
|
|
233
|
-
if value[0:1] == "{":
|
|
234
|
-
result += [4 * " " + name + " = \n"]
|
|
235
|
-
result += [8 * " " + "{\n"]
|
|
236
|
-
strings = value[1:-1].split(",")
|
|
237
|
-
for st in strings:
|
|
238
|
-
result += [12 * " " + st.strip() + ",\n"]
|
|
239
|
-
result[-1] = result[-1][:-2]
|
|
240
|
-
result += ["\n" + 8 * " " + "};\n"]
|
|
241
|
-
elif value[0:1] == "[":
|
|
242
|
-
tempad = ClassAd(value)
|
|
243
|
-
tempjdl = tempad.asJDL() + ";"
|
|
244
|
-
lines = tempjdl.split("\n")
|
|
245
|
-
result += [4 * " " + name + " = \n"]
|
|
246
|
-
for line in lines:
|
|
247
|
-
result += [8 * " " + line + "\n"]
|
|
248
|
-
|
|
249
|
-
else:
|
|
250
|
-
result += [4 * " " + name + " = " + str(value) + ";\n"]
|
|
251
|
-
if result:
|
|
252
|
-
result[-1] = result[-1][:-1]
|
|
253
|
-
return "[ \n" + "".join(result) + "\n]"
|
|
254
|
-
|
|
255
|
-
def getAttributeString(self, name):
|
|
256
|
-
"""Get String type attribute value"""
|
|
257
|
-
value = ""
|
|
258
|
-
if self.lookupAttribute(name):
|
|
259
|
-
value = self.get_expression(name).replace('"', "")
|
|
260
|
-
return value
|
|
261
|
-
|
|
262
|
-
def getAttributeInt(self, name):
|
|
263
|
-
"""Get Integer type attribute value"""
|
|
264
|
-
value = None
|
|
265
|
-
if self.lookupAttribute(name):
|
|
266
|
-
try:
|
|
267
|
-
value = int(self.get_expression(name).replace('"', ""))
|
|
268
|
-
except Exception:
|
|
269
|
-
value = None
|
|
270
|
-
return value
|
|
271
|
-
|
|
272
|
-
def getAttributeBool(self, name):
|
|
273
|
-
"""Get Boolean type attribute value"""
|
|
274
|
-
if not self.lookupAttribute(name):
|
|
275
|
-
return False
|
|
276
|
-
|
|
277
|
-
value = self.get_expression(name).replace('"', "")
|
|
278
|
-
return value.lower() == "true"
|
|
279
|
-
|
|
280
|
-
def getAttributeFloat(self, name):
|
|
281
|
-
"""Get Float type attribute value"""
|
|
282
|
-
value = None
|
|
283
|
-
if self.lookupAttribute(name):
|
|
284
|
-
try:
|
|
285
|
-
value = float(self.get_expression(name).replace('"', ""))
|
|
286
|
-
except Exception:
|
|
287
|
-
value = None
|
|
288
|
-
return value
|
|
289
|
-
|
|
290
|
-
def getAttributes(self) -> list[str]:
|
|
291
|
-
"""Get the list of all the attribute names
|
|
292
|
-
|
|
293
|
-
:return: list of names as strings
|
|
294
|
-
"""
|
|
295
|
-
return list(self.contents)
|
|
8
|
+
# Re-export for backward compatibility
|
|
9
|
+
__all__ = ["ClassAd"]
|