synapse 2.223.0__py311-none-any.whl → 2.225.0__py311-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.
Potentially problematic release.
This version of synapse might be problematic. Click here for more details.
- synapse/axon.py +10 -5
- synapse/common.py +2 -2
- synapse/cortex.py +52 -3
- synapse/datamodel.py +1 -1
- synapse/lib/cell.py +1 -1
- synapse/lib/const.py +4 -0
- synapse/lib/layer.py +6 -6
- synapse/lib/multislabseqn.py +36 -1
- synapse/lib/nexus.py +67 -8
- synapse/lib/queue.py +4 -1
- synapse/lib/rstorm.py +2 -2
- synapse/lib/schemas.py +13 -1
- synapse/lib/slabseqn.py +28 -0
- synapse/lib/storm.py +40 -2
- synapse/lib/stormhttp.py +7 -1
- synapse/lib/stormlib/imap.py +12 -4
- synapse/lib/stormlib/task.py +0 -1
- synapse/lib/stormtypes.py +19 -1
- synapse/lib/version.py +2 -2
- synapse/models/inet.py +29 -0
- synapse/models/media.py +4 -0
- synapse/models/proj.py +3 -0
- synapse/models/risk.py +9 -0
- synapse/models/syn.py +8 -0
- synapse/tests/test_common.py +4 -0
- synapse/tests/test_cortex.py +53 -2
- synapse/tests/test_lib_agenda.py +1 -1
- synapse/tests/test_lib_cell.py +1 -1
- synapse/tests/test_lib_certdir.py +1 -1
- synapse/tests/test_lib_httpapi.py +1 -1
- synapse/tests/test_lib_layer.py +1 -1
- synapse/tests/test_lib_multislabseqn.py +22 -0
- synapse/tests/test_lib_nexus.py +42 -1
- synapse/tests/test_lib_slabseqn.py +30 -1
- synapse/tests/test_lib_storm.py +156 -1
- synapse/tests/test_lib_stormhttp.py +16 -0
- synapse/tests/test_lib_stormlib_imap.py +14 -0
- synapse/tests/test_lib_stormlib_oauth.py +1 -1
- synapse/tests/test_lib_stormsvc.py +1 -1
- synapse/tests/test_lib_stormtypes.py +12 -0
- synapse/tests/test_lib_trigger.py +1 -1
- synapse/tests/test_model_inet.py +29 -0
- synapse/tests/test_model_media.py +4 -1
- synapse/tests/test_model_proj.py +3 -1
- synapse/tests/test_model_risk.py +12 -0
- synapse/tests/test_model_syn.py +54 -2
- synapse/tests/{test_tools_axon2axon.py → test_tools_axon_copy.py} +4 -4
- synapse/tests/{test_tools_pullfile.py → test_tools_axon_get.py} +4 -4
- synapse/tests/{test_tools_pushfile.py → test_tools_axon_put.py} +7 -7
- synapse/tests/{test_tools_csvtool.py → test_tools_cortex_csv.py} +12 -3
- synapse/tests/{test_tools_feed.py → test_tools_cortex_feed.py} +2 -2
- synapse/tests/{test_tools_apikey.py → test_tools_service_apikey.py} +1 -4
- synapse/tests/{test_tools_backup.py → test_tools_service_backup.py} +5 -5
- synapse/tests/{test_tools_demote.py → test_tools_service_demote.py} +1 -1
- synapse/tests/{test_tools_healthcheck.py → test_tools_service_healthcheck.py} +1 -1
- synapse/tests/{test_tools_livebackup.py → test_tools_service_livebackup.py} +1 -1
- synapse/tests/{test_tools_modrole.py → test_tools_service_modrole.py} +1 -1
- synapse/tests/{test_tools_moduser.py → test_tools_service_moduser.py} +1 -1
- synapse/tests/{test_tools_promote.py → test_tools_service_promote.py} +1 -1
- synapse/tests/{test_tools_reload.py → test_tools_service_reload.py} +1 -1
- synapse/tests/{test_tools_shutdown.py → test_tools_service_shutdown.py} +1 -1
- synapse/tests/{test_tools_snapshot.py → test_tools_service_snapshot.py} +1 -1
- synapse/tests/{test_tools_storm.py → test_tools_storm_cli.py} +1 -1
- synapse/tests/{test_tools_pkgs_gendocs.py → test_tools_storm_pkg_doc.py} +12 -3
- synapse/tests/{test_tools_genpkg.py → test_tools_storm_pkg_gen.py} +1 -1
- synapse/tests/{test_tools_autodoc.py → test_tools_utils_autodoc.py} +1 -1
- synapse/tests/test_tools_utils_changelog.py +454 -0
- synapse/tests/{test_tools_easycert.py → test_tools_utils_easycert.py} +48 -46
- synapse/tests/{test_tools_guid.py → test_tools_utils_guid.py} +3 -3
- synapse/tests/{test_tools_json2mpk.py → test_tools_utils_json2mpk.py} +3 -3
- synapse/tests/{test_tools_rstorm.py → test_tools_utils_rstorm.py} +6 -1
- synapse/tests/utils.py +3 -1
- synapse/tools/apikey.py +4 -83
- synapse/tools/autodoc.py +3 -1031
- synapse/tools/axon/copy.py +44 -0
- synapse/tools/axon/get.py +64 -0
- synapse/tools/axon/put.py +122 -0
- synapse/tools/axon2axon.py +3 -36
- synapse/tools/backup.py +6 -176
- synapse/tools/changelog.py +3 -1098
- synapse/tools/cortex/csv.py +236 -0
- synapse/tools/cortex/feed.py +151 -0
- synapse/tools/csvtool.py +3 -227
- synapse/tools/demote.py +4 -40
- synapse/tools/docker/validate.py +3 -3
- synapse/tools/easycert.py +4 -129
- synapse/tools/feed.py +3 -140
- synapse/tools/genpkg.py +3 -307
- synapse/tools/guid.py +7 -6
- synapse/tools/healthcheck.py +3 -101
- synapse/tools/json2mpk.py +6 -38
- synapse/tools/livebackup.py +4 -27
- synapse/tools/modrole.py +3 -108
- synapse/tools/moduser.py +3 -179
- synapse/tools/pkgs/gendocs.py +3 -164
- synapse/tools/promote.py +4 -41
- synapse/tools/pullfile.py +3 -56
- synapse/tools/pushfile.py +3 -114
- synapse/tools/reload.py +4 -61
- synapse/tools/rstorm.py +3 -26
- synapse/tools/service/__init__.py +0 -0
- synapse/tools/service/apikey.py +90 -0
- synapse/tools/service/backup.py +181 -0
- synapse/tools/service/demote.py +47 -0
- synapse/tools/service/healthcheck.py +109 -0
- synapse/tools/service/livebackup.py +34 -0
- synapse/tools/service/modrole.py +116 -0
- synapse/tools/service/moduser.py +184 -0
- synapse/tools/service/promote.py +48 -0
- synapse/tools/service/reload.py +68 -0
- synapse/tools/service/shutdown.py +51 -0
- synapse/tools/service/snapshot.py +64 -0
- synapse/tools/shutdown.py +5 -45
- synapse/tools/snapshot.py +4 -57
- synapse/tools/storm/__init__.py +0 -0
- synapse/tools/storm/__main__.py +5 -0
- synapse/tools/{storm.py → storm/_cli.py} +0 -3
- synapse/tools/storm/pkg/__init__.py +0 -0
- synapse/tools/{pkgs/pandoc_filter.py → storm/pkg/_pandoc_filter.py} +1 -1
- synapse/tools/storm/pkg/doc.py +176 -0
- synapse/tools/storm/pkg/gen.py +315 -0
- synapse/tools/utils/__init__.py +0 -0
- synapse/tools/utils/autodoc.py +1040 -0
- synapse/tools/utils/changelog.py +1124 -0
- synapse/tools/utils/easycert.py +136 -0
- synapse/tools/utils/guid.py +11 -0
- synapse/tools/utils/json2mpk.py +46 -0
- synapse/tools/utils/rstorm.py +35 -0
- {synapse-2.223.0.dist-info → synapse-2.225.0.dist-info}/METADATA +1 -1
- {synapse-2.223.0.dist-info → synapse-2.225.0.dist-info}/RECORD +134 -105
- synapse/tests/test_tools_changelog.py +0 -196
- /synapse/tests/{test_tools_axon.py → test_tools_axon_dump_load.py} +0 -0
- {synapse-2.223.0.dist-info → synapse-2.225.0.dist-info}/WHEEL +0 -0
- {synapse-2.223.0.dist-info → synapse-2.225.0.dist-info}/licenses/LICENSE +0 -0
- {synapse-2.223.0.dist-info → synapse-2.225.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import copy
|
|
4
|
+
import gzip
|
|
5
|
+
import pprint
|
|
6
|
+
import datetime
|
|
7
|
+
import tempfile
|
|
8
|
+
import textwrap
|
|
9
|
+
import traceback
|
|
10
|
+
import subprocess
|
|
11
|
+
import collections
|
|
12
|
+
|
|
13
|
+
import regex
|
|
14
|
+
|
|
15
|
+
import synapse.exc as s_exc
|
|
16
|
+
import synapse.common as s_common
|
|
17
|
+
import synapse.cortex as s_cortex
|
|
18
|
+
|
|
19
|
+
import synapse.lib.cmd as s_cmd
|
|
20
|
+
import synapse.lib.output as s_output
|
|
21
|
+
import synapse.lib.autodoc as s_autodoc
|
|
22
|
+
import synapse.lib.schemas as s_schemas
|
|
23
|
+
import synapse.lib.version as s_version
|
|
24
|
+
|
|
25
|
+
defstruct = (
|
|
26
|
+
('type', None),
|
|
27
|
+
('desc', ''),
|
|
28
|
+
('prs', ()),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
SKIP_FILES = (
|
|
32
|
+
'.gitkeep',
|
|
33
|
+
'modelrefs',
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
version_regex = r'^v[0-9]\.[0-9]+\.[0-9]+((a|b|rc)[0-9]*)?$'
|
|
37
|
+
|
|
38
|
+
def _getCurrentCommit(outp: s_output.OutPut) -> str | None:
|
|
39
|
+
try:
|
|
40
|
+
ret = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
timeout=15,
|
|
43
|
+
check=False,
|
|
44
|
+
text=True,
|
|
45
|
+
)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
outp.printf(f'Error grabbing commit: {e}')
|
|
48
|
+
return
|
|
49
|
+
else:
|
|
50
|
+
commit = ret.stdout.strip()
|
|
51
|
+
assert commit
|
|
52
|
+
return commit
|
|
53
|
+
|
|
54
|
+
async def _getCurrentModl(outp: s_output.OutPut) -> dict:
|
|
55
|
+
with tempfile.TemporaryDirectory() as dirn:
|
|
56
|
+
conf = {'health:sysctl:checks': False}
|
|
57
|
+
async with await s_cortex.Cortex.anit(conf=conf, dirn=dirn) as core:
|
|
58
|
+
modl = await core.getModelDict()
|
|
59
|
+
# Reserialize modl so its consistent with the model on disk
|
|
60
|
+
modl = s_common.yamlloads(s_common.yamldump(modl))
|
|
61
|
+
return modl
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ModelDiffer:
|
|
65
|
+
def __init__(self, current_model: dict, reference_model: dict):
|
|
66
|
+
self.cur_model = current_model
|
|
67
|
+
self.ref_model = reference_model
|
|
68
|
+
self.changes = {}
|
|
69
|
+
|
|
70
|
+
self.cur_iface_to_allifaces = collections.defaultdict(list)
|
|
71
|
+
for iface, info in self.cur_model.get('interfaces').items():
|
|
72
|
+
self.cur_iface_to_allifaces[iface] = [iface]
|
|
73
|
+
q = collections.deque(info.get('interfaces', ()))
|
|
74
|
+
while q:
|
|
75
|
+
_iface = q.popleft()
|
|
76
|
+
if _iface in self.cur_iface_to_allifaces[iface]:
|
|
77
|
+
continue
|
|
78
|
+
self.cur_iface_to_allifaces[iface].append(_iface)
|
|
79
|
+
q.extend(self.cur_model.get('interfaces').get(_iface).get('interfaces', ()))
|
|
80
|
+
|
|
81
|
+
self.cur_type2iface = collections.defaultdict(list)
|
|
82
|
+
|
|
83
|
+
for _type, tnfo in self.cur_model.get('types').items():
|
|
84
|
+
for iface in tnfo.get('info').get('interfaces', ()):
|
|
85
|
+
ifaces = self.cur_iface_to_allifaces[iface]
|
|
86
|
+
for _iface in ifaces:
|
|
87
|
+
if _iface not in self.cur_type2iface[_type]:
|
|
88
|
+
self.cur_type2iface[_type].append(_iface)
|
|
89
|
+
|
|
90
|
+
def _compareEdges(self, curv, oldv, outp: s_output.OutPut) -> dict:
|
|
91
|
+
changes = {}
|
|
92
|
+
if curv == oldv:
|
|
93
|
+
return changes
|
|
94
|
+
|
|
95
|
+
# Flatten the edges into structures that can be handled
|
|
96
|
+
_curv = {tuple(item[0]): item[1] for item in curv}
|
|
97
|
+
_oldv = {tuple(item[0]): item[1] for item in oldv}
|
|
98
|
+
|
|
99
|
+
curedges = set(_curv.keys())
|
|
100
|
+
oldedges = set(_oldv.keys())
|
|
101
|
+
|
|
102
|
+
new_edges = curedges - oldedges
|
|
103
|
+
del_edges = oldedges - curedges # This should generally not happen...
|
|
104
|
+
assert len(del_edges) == 0, 'A edge was removed from the data model!'
|
|
105
|
+
|
|
106
|
+
if new_edges:
|
|
107
|
+
changes['new_edges'] = {k: _curv.get(k) for k in new_edges}
|
|
108
|
+
|
|
109
|
+
updated_edges = collections.defaultdict(dict)
|
|
110
|
+
deprecated_edges = {}
|
|
111
|
+
|
|
112
|
+
for edge, curinfo in _curv.items():
|
|
113
|
+
if edge in new_edges:
|
|
114
|
+
continue
|
|
115
|
+
oldinfo = _oldv.get(edge)
|
|
116
|
+
if curinfo == oldinfo:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if curinfo.get('deprecated') and not oldinfo.get('deprecated'):
|
|
120
|
+
deprecated_edges[edge] = curinfo
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if oldinfo.get('doc') != curinfo.get('doc'):
|
|
124
|
+
updated_edges[edge] = curinfo
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# TODO - Support additional changes to the edges?
|
|
128
|
+
assert False, f'A change was found for the edge: {edge}'
|
|
129
|
+
|
|
130
|
+
if updated_edges:
|
|
131
|
+
changes['updated_edges'] = dict(updated_edges)
|
|
132
|
+
|
|
133
|
+
if deprecated_edges:
|
|
134
|
+
changes['deprecated_edges'] = deprecated_edges
|
|
135
|
+
|
|
136
|
+
return changes
|
|
137
|
+
|
|
138
|
+
def _compareForms(self, curv, oldv, outp: s_output.OutPut) -> dict:
|
|
139
|
+
changes = {}
|
|
140
|
+
if curv == oldv:
|
|
141
|
+
return changes
|
|
142
|
+
|
|
143
|
+
curforms = set(curv.keys())
|
|
144
|
+
oldforms = set(oldv.keys())
|
|
145
|
+
|
|
146
|
+
new_forms = curforms - oldforms
|
|
147
|
+
del_forms = oldforms - curforms # This should generally not happen...
|
|
148
|
+
assert len(del_forms) == 0, 'A form was removed from the data model!'
|
|
149
|
+
|
|
150
|
+
if new_forms:
|
|
151
|
+
changes['new_forms'] = {k: curv.get(k) for k in new_forms}
|
|
152
|
+
|
|
153
|
+
updated_forms = collections.defaultdict(dict)
|
|
154
|
+
for form, curinfo in curv.items():
|
|
155
|
+
if form in new_forms:
|
|
156
|
+
continue
|
|
157
|
+
oldinfo = oldv.get(form)
|
|
158
|
+
if curinfo == oldinfo:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
# Check for different properties
|
|
162
|
+
nprops = curinfo.get('props')
|
|
163
|
+
oprops = oldinfo.get('props')
|
|
164
|
+
|
|
165
|
+
new_props = set(nprops.keys()) - set(oprops.keys())
|
|
166
|
+
del_props = set(oprops.keys()) - set(nprops.keys()) # This should generally not happen...
|
|
167
|
+
assert len(del_props) == 0, 'A form was removed from the data model!'
|
|
168
|
+
|
|
169
|
+
if new_props:
|
|
170
|
+
updated_forms[form]['new_properties'] = {prop: nprops.get(prop) for prop in new_props}
|
|
171
|
+
np_noiface = {}
|
|
172
|
+
ifaces = self.cur_type2iface[form]
|
|
173
|
+
|
|
174
|
+
for prop in new_props:
|
|
175
|
+
# TODO record raw new_props to make bulk edits possible
|
|
176
|
+
is_ifaceprop = False
|
|
177
|
+
for iface in ifaces:
|
|
178
|
+
# Is the prop in the new_interfaces or updated_interfaces lists?
|
|
179
|
+
new_iface = self.changes.get('interfaces').get('new_interfaces', {}).get(iface)
|
|
180
|
+
if new_iface and prop in new_iface.get('props', {}):
|
|
181
|
+
is_ifaceprop = True
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
upt_iface = self.changes.get('interfaces').get('updated_interfaces', {}).get(iface)
|
|
185
|
+
if upt_iface and prop in upt_iface.get('new_properties', {}):
|
|
186
|
+
is_ifaceprop = True
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
if is_ifaceprop:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
np_noiface[prop] = nprops.get(prop)
|
|
193
|
+
|
|
194
|
+
if np_noiface:
|
|
195
|
+
updated_forms[form]['new_properties_no_interfaces'] = np_noiface
|
|
196
|
+
|
|
197
|
+
updated_props = {}
|
|
198
|
+
updated_props_noiface = {}
|
|
199
|
+
|
|
200
|
+
deprecated_props = {}
|
|
201
|
+
deprecated_props_noiface = {}
|
|
202
|
+
|
|
203
|
+
for prop, cpinfo in nprops.items():
|
|
204
|
+
if prop in new_props:
|
|
205
|
+
continue
|
|
206
|
+
opinfo = oprops.get(prop)
|
|
207
|
+
if cpinfo == opinfo:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
# Deprecation has a higher priority than updated type information
|
|
211
|
+
if cpinfo.get('deprecated') and not opinfo.get('deprecated'):
|
|
212
|
+
# A deprecated property could be present on an updated iface
|
|
213
|
+
deprecated_props[prop] = cpinfo
|
|
214
|
+
|
|
215
|
+
is_ifaceprop = False
|
|
216
|
+
for iface in self.cur_type2iface[form]:
|
|
217
|
+
upt_iface = self.changes.get('interfaces').get('updated_interfaces', {}).get(iface)
|
|
218
|
+
if upt_iface and prop in upt_iface.get('deprecated_properties', {}):
|
|
219
|
+
is_ifaceprop = True
|
|
220
|
+
break
|
|
221
|
+
if is_ifaceprop:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
deprecated_props_noiface[prop] = cpinfo
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
okeys = set(opinfo.keys())
|
|
228
|
+
nkeys = set(cpinfo.keys())
|
|
229
|
+
|
|
230
|
+
if nkeys - okeys:
|
|
231
|
+
# We've added a key to the prop def.
|
|
232
|
+
updated_props[prop] = {'type': 'addkey', 'keys': list(nkeys - okeys)}
|
|
233
|
+
|
|
234
|
+
if okeys - nkeys:
|
|
235
|
+
# We've removed a key from the prop def.
|
|
236
|
+
updated_props[prop] = {'type': 'delkey', 'keys': list(okeys - nkeys)}
|
|
237
|
+
|
|
238
|
+
# Check if type change happened, we'll want to document that.
|
|
239
|
+
ctyp = cpinfo.get('type')
|
|
240
|
+
otyp = opinfo.get('type')
|
|
241
|
+
if ctyp == otyp:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
updated_props[prop] = {'type': 'type_change', 'new_type': ctyp, 'old_type': otyp}
|
|
245
|
+
is_ifaceprop = False
|
|
246
|
+
for iface in self.cur_type2iface[form]:
|
|
247
|
+
upt_iface = self.changes.get('interfaces').get('updated_interfaces', {}).get(iface)
|
|
248
|
+
if upt_iface and prop in upt_iface.get('updated_properties', {}):
|
|
249
|
+
is_ifaceprop = True
|
|
250
|
+
break
|
|
251
|
+
if is_ifaceprop:
|
|
252
|
+
continue
|
|
253
|
+
updated_props_noiface[prop] = {'type': 'type_change', 'new_type': ctyp, 'old_type': otyp}
|
|
254
|
+
|
|
255
|
+
if updated_props:
|
|
256
|
+
updated_forms[form]['updated_properties'] = updated_props
|
|
257
|
+
|
|
258
|
+
if updated_props_noiface:
|
|
259
|
+
updated_forms[form]['updated_properties_no_interfaces'] = updated_props_noiface
|
|
260
|
+
|
|
261
|
+
if deprecated_props:
|
|
262
|
+
updated_forms[form]['deprecated_properties'] = deprecated_props
|
|
263
|
+
|
|
264
|
+
if deprecated_props_noiface:
|
|
265
|
+
updated_forms[form]['deprecated_properties_no_interfaces'] = deprecated_props_noiface
|
|
266
|
+
|
|
267
|
+
if updated_forms:
|
|
268
|
+
changes['updated_forms'] = dict(updated_forms)
|
|
269
|
+
|
|
270
|
+
return changes
|
|
271
|
+
|
|
272
|
+
def _compareIfaces(self, curv, oldv, outp: s_output.OutPut) -> dict:
|
|
273
|
+
changes = {}
|
|
274
|
+
if curv == oldv:
|
|
275
|
+
return changes
|
|
276
|
+
|
|
277
|
+
curfaces = set(curv.keys())
|
|
278
|
+
oldfaces = set(oldv.keys())
|
|
279
|
+
|
|
280
|
+
new_faces = curfaces - oldfaces
|
|
281
|
+
del_faces = oldfaces - curfaces # This should generally not happen...
|
|
282
|
+
assert len(del_faces) == 0, f'An interface was removed from the data model!: {del_faces}'
|
|
283
|
+
|
|
284
|
+
if new_faces:
|
|
285
|
+
nv = {}
|
|
286
|
+
for iface in new_faces:
|
|
287
|
+
k = copy.deepcopy(curv.get(iface))
|
|
288
|
+
# Rewrite props into a dictionary for easier lookup later
|
|
289
|
+
k['props'] = {item[0]: {'type': item[1], 'props': item[2]} for item in k['props']}
|
|
290
|
+
nv[iface] = k
|
|
291
|
+
|
|
292
|
+
changes['new_interfaces'] = nv
|
|
293
|
+
|
|
294
|
+
updated_interfaces = collections.defaultdict(dict)
|
|
295
|
+
|
|
296
|
+
for iface, curinfo in curv.items():
|
|
297
|
+
if iface in new_faces:
|
|
298
|
+
continue
|
|
299
|
+
oldinfo = oldv.get(iface)
|
|
300
|
+
|
|
301
|
+
# Did the interface inheritance change?
|
|
302
|
+
if curinfo.get('interfaces') != oldinfo.get('interfaces'):
|
|
303
|
+
updated_interfaces[iface] = {'updated_interfaces': {'curv': curinfo.get('interfaces'),
|
|
304
|
+
'oldv': oldinfo.get('interfaces')}}
|
|
305
|
+
# Did the interface have a property definition change?
|
|
306
|
+
nprops = curinfo.get('props')
|
|
307
|
+
oprops = oldinfo.get('props')
|
|
308
|
+
|
|
309
|
+
# Convert props to dictionary
|
|
310
|
+
nprops = {item[0]: {'type': item[1], 'props': item[2]} for item in nprops}
|
|
311
|
+
oprops = {item[0]: {'type': item[1], 'props': item[2]} for item in oprops}
|
|
312
|
+
|
|
313
|
+
new_props = set(nprops.keys()) - set(oprops.keys())
|
|
314
|
+
del_props = set(oprops.keys()) - set(nprops.keys()) # This should generally not happen...
|
|
315
|
+
assert len(del_props) == 0, f'A prop was removed from the iface {iface}'
|
|
316
|
+
|
|
317
|
+
if new_props:
|
|
318
|
+
updated_interfaces[iface]['new_properties'] = {prop: nprops.get(prop) for prop in new_props}
|
|
319
|
+
|
|
320
|
+
updated_props = {}
|
|
321
|
+
deprecated_props = {}
|
|
322
|
+
for prop, cpinfo in nprops.items():
|
|
323
|
+
if prop in new_props:
|
|
324
|
+
continue
|
|
325
|
+
opinfo = oprops.get(prop)
|
|
326
|
+
if cpinfo == opinfo:
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
if cpinfo.get('props').get('deprecated') and not opinfo.get('props').get('deprecated'):
|
|
330
|
+
deprecated_props[prop] = cpinfo
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
okeys = set(opinfo.keys())
|
|
334
|
+
nkeys = set(cpinfo.keys())
|
|
335
|
+
|
|
336
|
+
if nkeys - okeys:
|
|
337
|
+
# We've added a key to the prop def.
|
|
338
|
+
updated_props[prop] = {'type': 'addkey', 'keys': list(nkeys - okeys)}
|
|
339
|
+
|
|
340
|
+
if okeys - nkeys:
|
|
341
|
+
# We've removed a key from the prop def.
|
|
342
|
+
updated_props[prop] = {'type': 'delkey', 'keys': list(okeys - nkeys)}
|
|
343
|
+
|
|
344
|
+
# Check if type change happened, we'll want to document that.
|
|
345
|
+
ctyp = cpinfo.get('type')
|
|
346
|
+
otyp = opinfo.get('type')
|
|
347
|
+
if ctyp == otyp:
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
updated_props[prop] = {'type': 'type_change', 'new_type': ctyp, 'old_type': otyp}
|
|
351
|
+
if updated_props:
|
|
352
|
+
updated_interfaces[iface]['updated_properties'] = updated_props
|
|
353
|
+
|
|
354
|
+
if deprecated_props:
|
|
355
|
+
updated_interfaces[iface]['deprecated_properties'] = deprecated_props
|
|
356
|
+
|
|
357
|
+
changes['updated_interfaces'] = dict(updated_interfaces)
|
|
358
|
+
|
|
359
|
+
return changes
|
|
360
|
+
|
|
361
|
+
def _compareTagprops(self, curv, oldv, outp: s_output.OutPut) -> dict:
|
|
362
|
+
changes = {}
|
|
363
|
+
if curv == oldv:
|
|
364
|
+
return changes
|
|
365
|
+
raise NotImplementedError('_compareTagprops')
|
|
366
|
+
|
|
367
|
+
def _compareTypes(self, curv, oldv, outp: s_output.OutPut) -> dict:
|
|
368
|
+
changes = {}
|
|
369
|
+
if curv == oldv:
|
|
370
|
+
return changes
|
|
371
|
+
|
|
372
|
+
curtypes = set(curv.keys())
|
|
373
|
+
oldtypes = set(oldv.keys())
|
|
374
|
+
|
|
375
|
+
new_types = curtypes - oldtypes
|
|
376
|
+
del_types = oldtypes - curtypes # This should generally not happen...
|
|
377
|
+
assert len(del_types) == 0, 'A type was removed from the data model!'
|
|
378
|
+
|
|
379
|
+
if new_types:
|
|
380
|
+
changes['new_types'] = {k: curv.get(k) for k in new_types}
|
|
381
|
+
|
|
382
|
+
updated_types = collections.defaultdict(dict)
|
|
383
|
+
deprecated_types = collections.defaultdict(dict)
|
|
384
|
+
|
|
385
|
+
for _type, curinfo in curv.items():
|
|
386
|
+
if _type in new_types:
|
|
387
|
+
continue
|
|
388
|
+
oldinfo = oldv.get(_type)
|
|
389
|
+
if curinfo == oldinfo:
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
cnfo = curinfo.get('info')
|
|
393
|
+
onfo = oldinfo.get('info')
|
|
394
|
+
|
|
395
|
+
if cnfo.get('deprecated') and not onfo.get('deprecated'):
|
|
396
|
+
deprecated_types[_type] = curinfo
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
if cnfo.get('interfaces') != onfo.get('interfaces'):
|
|
400
|
+
updated_types[_type]['updated_interfaces'] = {'curv': cnfo.get('interfaces'),
|
|
401
|
+
'oldv': onfo.get('interfaces'),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if curinfo.get('opts') != oldinfo.get('opts'):
|
|
405
|
+
updated_types[_type]['updated_opts'] = {'curv': curinfo.get('opts'),
|
|
406
|
+
'oldv': oldinfo.get('opts'),
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if updated_types:
|
|
410
|
+
changes['updated_types'] = dict(updated_types)
|
|
411
|
+
|
|
412
|
+
if deprecated_types:
|
|
413
|
+
changes['deprecated_types'] = dict(deprecated_types)
|
|
414
|
+
|
|
415
|
+
return changes
|
|
416
|
+
|
|
417
|
+
def _compareUnivs(self, curv, oldv, outp: s_output.OutPut) -> dict:
|
|
418
|
+
changes = {}
|
|
419
|
+
if curv == oldv:
|
|
420
|
+
return changes
|
|
421
|
+
raise NotImplementedError('_compareUnivs')
|
|
422
|
+
|
|
423
|
+
def diffModl(self, outp: s_output.OutPut) -> dict | None:
|
|
424
|
+
if self.changes:
|
|
425
|
+
return self.changes
|
|
426
|
+
|
|
427
|
+
# These are order sensitive due to interface knowledge being required in order
|
|
428
|
+
# to deconflict downstream changes on forms.
|
|
429
|
+
known_keys = {
|
|
430
|
+
'interfaces': self._compareIfaces,
|
|
431
|
+
'types': self._compareTypes,
|
|
432
|
+
'forms': self._compareForms,
|
|
433
|
+
'tagprops': self._compareTagprops,
|
|
434
|
+
'edges': self._compareEdges,
|
|
435
|
+
'univs': self._compareUnivs,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
all_keys = set(self.cur_model.keys()).union(self.ref_model.keys())
|
|
439
|
+
|
|
440
|
+
for key, func in known_keys.items():
|
|
441
|
+
self.changes[key] = func(self.cur_model.get(key),
|
|
442
|
+
self.ref_model.get(key),
|
|
443
|
+
outp)
|
|
444
|
+
all_keys.remove(key)
|
|
445
|
+
|
|
446
|
+
if all_keys:
|
|
447
|
+
outp.printf(f'ERROR: Unknown model key found: {all_keys}')
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
return self.changes
|
|
451
|
+
|
|
452
|
+
def _getModelFile(fp: str) -> dict | None:
|
|
453
|
+
with s_common.genfile(fp) as fd:
|
|
454
|
+
bytz = fd.read()
|
|
455
|
+
large_bytz = gzip.decompress(bytz)
|
|
456
|
+
ref_modl = s_common.yamlloads(large_bytz)
|
|
457
|
+
return ref_modl
|
|
458
|
+
|
|
459
|
+
async def gen(opts: s_cmd.argparse.Namespace,
|
|
460
|
+
outp: s_output.OutPut):
|
|
461
|
+
|
|
462
|
+
name = opts.name
|
|
463
|
+
if name is None:
|
|
464
|
+
name = f'{s_common.guid()}.yaml'
|
|
465
|
+
fp = s_common.genpath(opts.cdir, name)
|
|
466
|
+
|
|
467
|
+
data = dict(defstruct)
|
|
468
|
+
data['type'] = opts.type
|
|
469
|
+
data['desc'] = opts.desc
|
|
470
|
+
|
|
471
|
+
if opts.pr:
|
|
472
|
+
data['prs'] = [opts.pr]
|
|
473
|
+
|
|
474
|
+
if opts.verbose:
|
|
475
|
+
outp.printf('Validating data against schema')
|
|
476
|
+
|
|
477
|
+
s_schemas._reqChangelogSchema(data)
|
|
478
|
+
|
|
479
|
+
if opts.verbose:
|
|
480
|
+
outp.printf('Saving the following information:')
|
|
481
|
+
outp.printf(s_common.yamldump(data).decode())
|
|
482
|
+
|
|
483
|
+
s_common.yamlsave(data, fp)
|
|
484
|
+
|
|
485
|
+
outp.printf(f'Saved changelog entry to {fp=}')
|
|
486
|
+
|
|
487
|
+
if opts.add: # pragma: no cover
|
|
488
|
+
if opts.verbose:
|
|
489
|
+
outp.printf('Adding file to git staging')
|
|
490
|
+
argv = ['git', 'add', fp]
|
|
491
|
+
ret = subprocess.run(argv, capture_output=True)
|
|
492
|
+
if opts.verbose:
|
|
493
|
+
outp.printf(f'stddout={ret.stdout}')
|
|
494
|
+
outp.printf(f'stderr={ret.stderr}')
|
|
495
|
+
ret.check_returncode()
|
|
496
|
+
|
|
497
|
+
return 0
|
|
498
|
+
|
|
499
|
+
def _gen_model_rst(version, model_ref, changes, current_model, outp: s_output.OutPut, width=80) -> s_autodoc.RstHelp:
|
|
500
|
+
rst = s_autodoc.RstHelp()
|
|
501
|
+
rst.addHead(f'{version} Model Updates', link=f'.. _{model_ref}:')
|
|
502
|
+
rst.addLines(f'The following model updates were made during the ``{version}`` Synapse release.')
|
|
503
|
+
|
|
504
|
+
if new_interfaces := changes.get('interfaces').get('new_interfaces'):
|
|
505
|
+
rst.addHead('New Interfaces', lvl=1)
|
|
506
|
+
for interface, info in new_interfaces.items():
|
|
507
|
+
rst.addLines(f'``{interface}``')
|
|
508
|
+
rst.addLines(*textwrap.wrap(info.get('doc'), initial_indent=' ', subsequent_indent=' ',
|
|
509
|
+
width=width))
|
|
510
|
+
rst.addLines('\n')
|
|
511
|
+
|
|
512
|
+
# Deconflict new_forms vs new_types -> do not add types which appear in new_forms.
|
|
513
|
+
new_forms = changes.get('forms').get('new_forms', {})
|
|
514
|
+
new_types = changes.get('types').get('new_types', {})
|
|
515
|
+
types_to_document = {k: v for k, v in new_types.items() if k not in new_forms}
|
|
516
|
+
|
|
517
|
+
if types_to_document:
|
|
518
|
+
rst.addHead('New Types', lvl=1)
|
|
519
|
+
for _type, info in types_to_document.items():
|
|
520
|
+
rst.addLines(f'``{_type}``')
|
|
521
|
+
rst.addLines(*textwrap.wrap(info.get('info').get('doc'), initial_indent=' ', subsequent_indent=' ',
|
|
522
|
+
width=width))
|
|
523
|
+
rst.addLines('\n')
|
|
524
|
+
|
|
525
|
+
if new_forms:
|
|
526
|
+
rst.addHead('New Forms', lvl=1)
|
|
527
|
+
for form, info in new_forms.items():
|
|
528
|
+
rst.addLines(f'``{form}``')
|
|
529
|
+
# Pull the form doc from the current model directly. In the event of an existing
|
|
530
|
+
# type being turned into a form + then reindexed, it would not show up in the
|
|
531
|
+
# type diff, so we can't rely on the doc being present there.
|
|
532
|
+
doc = current_model.get('types').get(form).get('info').get('doc')
|
|
533
|
+
rst.addLines(*textwrap.wrap(doc, initial_indent=' ', subsequent_indent=' ',
|
|
534
|
+
width=width))
|
|
535
|
+
rst.addLines('\n')
|
|
536
|
+
|
|
537
|
+
# Check for new properties
|
|
538
|
+
updated_forms = changes.get('forms').get('updated_forms', {})
|
|
539
|
+
new_props = []
|
|
540
|
+
for form, info in updated_forms.items():
|
|
541
|
+
if 'new_properties' in info:
|
|
542
|
+
new_props.append((form, info))
|
|
543
|
+
if new_props:
|
|
544
|
+
rst.addHead('New Properties', lvl=1)
|
|
545
|
+
new_props.sort(key=lambda x: x[0])
|
|
546
|
+
for form, info in new_props:
|
|
547
|
+
rst.addLines(f'``{form}``')
|
|
548
|
+
new_form_props = list(info.get('new_properties').items())
|
|
549
|
+
if len(new_form_props) > 1:
|
|
550
|
+
rst.addLines(' The form had the following properties added to it:', '\n')
|
|
551
|
+
new_form_props.sort(key=lambda x: x[0])
|
|
552
|
+
for name, info in new_form_props:
|
|
553
|
+
lines = [
|
|
554
|
+
f' ``{name}``',
|
|
555
|
+
*textwrap.wrap(info.get('doc'), initial_indent=' ', subsequent_indent=' ',
|
|
556
|
+
width=width),
|
|
557
|
+
'\n'
|
|
558
|
+
]
|
|
559
|
+
rst.addLines(*lines)
|
|
560
|
+
|
|
561
|
+
else:
|
|
562
|
+
name, info = new_form_props[0]
|
|
563
|
+
lines = [
|
|
564
|
+
' The form had the following property added to it:',
|
|
565
|
+
'\n'
|
|
566
|
+
f' ``{name}``',
|
|
567
|
+
*textwrap.wrap(info.get('doc'), initial_indent=' ', subsequent_indent=' ',
|
|
568
|
+
width=width),
|
|
569
|
+
'\n'
|
|
570
|
+
]
|
|
571
|
+
rst.addLines(*lines)
|
|
572
|
+
|
|
573
|
+
# Updated interfaces
|
|
574
|
+
if updated_interfaces := changes.get('interfaces').get('updated_interfaces', {}):
|
|
575
|
+
upd_ifaces = list(updated_interfaces.items())
|
|
576
|
+
upd_ifaces.sort(key=lambda x: x[0])
|
|
577
|
+
rst.addHead('Updated Interfaces', lvl=1)
|
|
578
|
+
for iface, info in upd_ifaces:
|
|
579
|
+
lines = [f'``{iface}``',
|
|
580
|
+
]
|
|
581
|
+
for key, valu in sorted(info.items(), key=lambda x: x[0]):
|
|
582
|
+
if key == 'deprecated_properties':
|
|
583
|
+
for prop, pnfo in sorted(valu.items(), key=lambda x: x[0]):
|
|
584
|
+
mesg = f'The interface property ``{prop}`` has been deprecated.'
|
|
585
|
+
lines.extend(textwrap.wrap(mesg, initial_indent=' ', subsequent_indent=' ',
|
|
586
|
+
width=width))
|
|
587
|
+
lines.append('\n')
|
|
588
|
+
elif key == 'new_properties':
|
|
589
|
+
for prop, pnfo in sorted(valu.items(), key=lambda x: x[0]):
|
|
590
|
+
mesg = f'The property ``{prop}`` has been added to the interface.'
|
|
591
|
+
lines.extend(textwrap.wrap(mesg, initial_indent=' ', subsequent_indent=' ',
|
|
592
|
+
width=width))
|
|
593
|
+
lines.append('\n')
|
|
594
|
+
elif key == 'updated_properties':
|
|
595
|
+
for prop, pnfo in sorted(valu.items(), key=lambda x: x[0]):
|
|
596
|
+
ptyp = pnfo.get('type')
|
|
597
|
+
if ptyp == 'type_change':
|
|
598
|
+
mesg = f'The property ``{prop}`` has been modified from {pnfo.get("old_type")}' \
|
|
599
|
+
f' to {pnfo.get("new_type")}.'
|
|
600
|
+
elif ptyp == 'delkey':
|
|
601
|
+
mesg = f'The property ``{prop}`` had the ``{pnfo.get("keys")}`` keys removed from its definition.'
|
|
602
|
+
else:
|
|
603
|
+
raise s_exc.NoSuchImpl(mesg=f'pnfo.type={ptyp} not supported.')
|
|
604
|
+
lines.extend(textwrap.wrap(mesg, initial_indent=' ', subsequent_indent=' ',
|
|
605
|
+
width=width))
|
|
606
|
+
lines.append('\n')
|
|
607
|
+
else: # pragma: no cover
|
|
608
|
+
outp.printf(f'Unknown key: {key=} {valu=}')
|
|
609
|
+
raise s_exc.SynErr(mesg=f'Unknown updated interface key: {key=} {valu=}')
|
|
610
|
+
rst.addLines(*lines)
|
|
611
|
+
|
|
612
|
+
# Updated types
|
|
613
|
+
if updated_types := changes.get('types').get('updated_types', {}):
|
|
614
|
+
upd_types = list(updated_types.items())
|
|
615
|
+
upd_types.sort(key=lambda x: x[0])
|
|
616
|
+
rst.addHead('Updated Types', lvl=1)
|
|
617
|
+
for _type, info in upd_types:
|
|
618
|
+
lines = [f'``{_type}``',
|
|
619
|
+
]
|
|
620
|
+
for key, valu in sorted(info.items(), key=lambda x: x[0]):
|
|
621
|
+
if key == 'updated_interfaces':
|
|
622
|
+
mesg = f'The type interface has been modified from {valu.get("oldv")}' \
|
|
623
|
+
f' to {valu.get("curv")}.'
|
|
624
|
+
lines.extend(textwrap.wrap(mesg, initial_indent=' ', subsequent_indent=' ',
|
|
625
|
+
width=width))
|
|
626
|
+
lines.append('\n')
|
|
627
|
+
elif key == 'updated_opts':
|
|
628
|
+
mesg = f'The type has been modified from {valu.get("oldv")}' \
|
|
629
|
+
f' to {valu.get("curv")}.'
|
|
630
|
+
lines.extend(textwrap.wrap(mesg, initial_indent=' ', subsequent_indent=' ',
|
|
631
|
+
width=width))
|
|
632
|
+
lines.append('\n')
|
|
633
|
+
else: # pragma: no cover
|
|
634
|
+
outp.printf(f'Unknown key: {key=} {valu=}')
|
|
635
|
+
raise s_exc.SynErr(mesg=f'Unknown updated type key: {key=} {valu=}')
|
|
636
|
+
rst.addLines(*lines)
|
|
637
|
+
|
|
638
|
+
# Updated Forms
|
|
639
|
+
# We don't really have a "updated forms" to display since the delta for forms data is really property
|
|
640
|
+
# deltas covered elsewhere.
|
|
641
|
+
|
|
642
|
+
# Updated Edges
|
|
643
|
+
# TODO Add support for updated edges
|
|
644
|
+
|
|
645
|
+
# Updated Properties
|
|
646
|
+
upd_props = []
|
|
647
|
+
for form, info in updated_forms.items():
|
|
648
|
+
if 'updated_properties' in info:
|
|
649
|
+
upd_props.append((form, info))
|
|
650
|
+
if upd_props:
|
|
651
|
+
rst.addHead('Updated Properties', lvl=1)
|
|
652
|
+
upd_props.sort(key=lambda x: x[0])
|
|
653
|
+
for form, info in upd_props:
|
|
654
|
+
rst.addLines(f'``{form}``')
|
|
655
|
+
upd_form_props = list(info.get('updated_properties').items())
|
|
656
|
+
if len(upd_form_props) > 1:
|
|
657
|
+
rst.addLines(' The form had the following properties updated:', '\n')
|
|
658
|
+
upd_form_props.sort(key=lambda x: x[0])
|
|
659
|
+
for prop, pnfo in upd_form_props:
|
|
660
|
+
ptyp = pnfo.get('type')
|
|
661
|
+
if ptyp == 'type_change':
|
|
662
|
+
mesg = f'The property ``{prop}`` has been modified from {pnfo.get("old_type")}' \
|
|
663
|
+
f' to {pnfo.get("new_type")}.'
|
|
664
|
+
elif ptyp == 'delkey':
|
|
665
|
+
mesg = f'The property ``{prop}`` had the ``{pnfo.get("keys")}`` keys removed from its definition.'
|
|
666
|
+
elif ptyp == 'addkey':
|
|
667
|
+
mesg = f'The property ``{prop}`` had the ``{pnfo.get("keys")}`` keys added to its definition.'
|
|
668
|
+
else:
|
|
669
|
+
raise s_exc.NoSuchImpl(mesg=f'pnfo.type={ptyp} not supported.')
|
|
670
|
+
lines = [
|
|
671
|
+
*textwrap.wrap(mesg, initial_indent=' ', subsequent_indent=' ',
|
|
672
|
+
width=width),
|
|
673
|
+
'\n'
|
|
674
|
+
]
|
|
675
|
+
rst.addLines(*lines)
|
|
676
|
+
|
|
677
|
+
else:
|
|
678
|
+
prop, pnfo = upd_form_props[0]
|
|
679
|
+
ptyp = pnfo.get('type')
|
|
680
|
+
if ptyp == 'type_change':
|
|
681
|
+
mesg = f'The property ``{prop}`` has been modified from {pnfo.get("old_type")}' \
|
|
682
|
+
f' to {pnfo.get("new_type")}.'
|
|
683
|
+
elif ptyp == 'delkey':
|
|
684
|
+
mesg = f'The property ``{prop}`` had the ``{pnfo.get("keys")}`` keys removed from its definition.'
|
|
685
|
+
elif ptyp == 'addkey':
|
|
686
|
+
mesg = f'The property ``{prop}`` had the ``{pnfo.get("keys")}`` keys added to its definition.'
|
|
687
|
+
else:
|
|
688
|
+
raise s_exc.NoSuchImpl(mesg=f'pnfo.type={ptyp} not supported.')
|
|
689
|
+
|
|
690
|
+
lines = [
|
|
691
|
+
' The form had the following property updated:',
|
|
692
|
+
'\n',
|
|
693
|
+
*textwrap.wrap(mesg, initial_indent=' ', subsequent_indent=' ',
|
|
694
|
+
width=width),
|
|
695
|
+
'\n'
|
|
696
|
+
]
|
|
697
|
+
rst.addLines(*lines)
|
|
698
|
+
|
|
699
|
+
# Light Edges
|
|
700
|
+
if new_edges := changes.get('edges').get('new_edges'):
|
|
701
|
+
new_edges = list(new_edges.items())
|
|
702
|
+
new_edges.sort(key=lambda x: x[0][1])
|
|
703
|
+
rst.addHead('Light Edges', lvl=1)
|
|
704
|
+
for (n1, name, n2), info in new_edges:
|
|
705
|
+
if n1 is not None and n2 is not None:
|
|
706
|
+
mesg = f'''When used with a ``{n1}`` and an ``{n2}`` node, the edge indicates {info.get('doc')}'''
|
|
707
|
+
elif n1 is None and n2 is not None:
|
|
708
|
+
mesg = f'''When used with a ``{n2}`` target node, the edge indicates {info.get('doc')}'''
|
|
709
|
+
elif n1 is not None and n2 is None:
|
|
710
|
+
mesg = f'''When used with a ``{n1}`` node, the edge indicates {info.get('doc')}'''
|
|
711
|
+
else:
|
|
712
|
+
mesg = info.get('doc')
|
|
713
|
+
|
|
714
|
+
rst.addLines(
|
|
715
|
+
f'``{name}``',
|
|
716
|
+
*textwrap.wrap(mesg, initial_indent=' ', subsequent_indent=' ', width=width),
|
|
717
|
+
'\n',
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# Deprecated Interfaces
|
|
721
|
+
# TODO Support deprecated interfaces!
|
|
722
|
+
|
|
723
|
+
# Deprecated Types
|
|
724
|
+
# Deconflict deprecated forms vs deprecated_types, so we do not
|
|
725
|
+
# not call out types which are also forms in the current model.
|
|
726
|
+
deprecated_types = changes.get('types').get('deprecated_types', {})
|
|
727
|
+
deprecated_forms = {k: v for k, v in deprecated_types.items() if k in current_model.get('forms')}
|
|
728
|
+
deprecated_types = {k: v for k, v in deprecated_types.items() if k not in deprecated_forms}
|
|
729
|
+
if deprecated_types:
|
|
730
|
+
rst.addHead('Deprecated Types', lvl=1)
|
|
731
|
+
rst.addLines('The following types have been marked as deprecated:', '\n')
|
|
732
|
+
|
|
733
|
+
for _type, info in deprecated_types.items():
|
|
734
|
+
rst.addLines(
|
|
735
|
+
f'* ``{_type}``',
|
|
736
|
+
)
|
|
737
|
+
rst.addLines('\n')
|
|
738
|
+
|
|
739
|
+
# Deprecated Forms
|
|
740
|
+
if deprecated_forms:
|
|
741
|
+
rst.addHead('Deprecated Types', lvl=1)
|
|
742
|
+
rst.addLines('The following forms have been marked as deprecated:', '\n')
|
|
743
|
+
|
|
744
|
+
for _type, info in deprecated_forms.items():
|
|
745
|
+
rst.addLines(
|
|
746
|
+
f'* ``{_type}``',
|
|
747
|
+
)
|
|
748
|
+
rst.addLines('\n')
|
|
749
|
+
|
|
750
|
+
# Deprecated Properties
|
|
751
|
+
dep_props = []
|
|
752
|
+
for form, info in updated_forms.items():
|
|
753
|
+
if 'deprecated_properties' in info:
|
|
754
|
+
dep_props.append((form, info))
|
|
755
|
+
if dep_props:
|
|
756
|
+
rst.addHead('Deprecated Properties', lvl=1)
|
|
757
|
+
dep_props.sort(key=lambda x: x[0])
|
|
758
|
+
for form, info in dep_props:
|
|
759
|
+
rst.addLines(f'``{form}``')
|
|
760
|
+
dep_form_props = list(info.get('deprecated_properties').items())
|
|
761
|
+
if len(dep_form_props) > 1:
|
|
762
|
+
rst.addLines(' The form had the following properties deprecated:', '\n')
|
|
763
|
+
dep_form_props.sort(key=lambda x: x[0])
|
|
764
|
+
for name, info in dep_form_props:
|
|
765
|
+
lines = [
|
|
766
|
+
f' ``{name}``',
|
|
767
|
+
*textwrap.wrap(info.get('doc'), initial_indent=' ', subsequent_indent=' ',
|
|
768
|
+
width=width),
|
|
769
|
+
'\n'
|
|
770
|
+
]
|
|
771
|
+
rst.addLines(*lines)
|
|
772
|
+
|
|
773
|
+
else:
|
|
774
|
+
name, info = dep_form_props[0]
|
|
775
|
+
lines = [
|
|
776
|
+
' The form had the following property deprecated:',
|
|
777
|
+
'\n'
|
|
778
|
+
f' ``{name}``',
|
|
779
|
+
*textwrap.wrap(info.get('doc'), initial_indent=' ', subsequent_indent=' ',
|
|
780
|
+
width=width),
|
|
781
|
+
'\n'
|
|
782
|
+
]
|
|
783
|
+
rst.addLines(*lines)
|
|
784
|
+
|
|
785
|
+
if dep_edges := changes.get('edges').get('deprecated_edges'):
|
|
786
|
+
|
|
787
|
+
rst.addHead('Deprecated Edges', lvl=1)
|
|
788
|
+
for (n1, name, n2), info in dep_edges.items():
|
|
789
|
+
if n1 is not None and n2 is not None:
|
|
790
|
+
mesg = f'''The edge has been deprecated when used with a ``{n1}`` and an ``{n2}`` node. {info.get('doc')}'''
|
|
791
|
+
elif n1 is None and n2 is not None:
|
|
792
|
+
mesg = f'''The edge has been deprecated when used with a ``{n2}`` target node. {info.get('doc')}'''
|
|
793
|
+
elif n1 is not None and n2 is None:
|
|
794
|
+
mesg = f'''The edge has been deprecated when used with a ``{n1}`` node. {info.get('doc')}'''
|
|
795
|
+
else:
|
|
796
|
+
mesg = f'''The edge has been deprecated. {info.get('doc')}'''
|
|
797
|
+
|
|
798
|
+
rst.addLines(
|
|
799
|
+
f'``{name}``',
|
|
800
|
+
*textwrap.wrap(mesg, initial_indent=' ', subsequent_indent=' ', width=width),
|
|
801
|
+
'\n',
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
return rst
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
async def format(opts: s_cmd.argparse.Namespace,
|
|
808
|
+
outp: s_output.OutPut):
|
|
809
|
+
|
|
810
|
+
if not regex.match(version_regex, opts.version):
|
|
811
|
+
outp.printf(f'Failed to match {opts.version} vs {version_regex}')
|
|
812
|
+
return 1
|
|
813
|
+
|
|
814
|
+
entries = collections.defaultdict(list)
|
|
815
|
+
|
|
816
|
+
files_processed = [] # Eventually for removing files from git.
|
|
817
|
+
|
|
818
|
+
for fn in os.listdir(opts.cdir):
|
|
819
|
+
if fn in SKIP_FILES:
|
|
820
|
+
continue
|
|
821
|
+
fp = s_common.genpath(opts.cdir, fn)
|
|
822
|
+
if opts.verbose:
|
|
823
|
+
outp.printf(f'Reading: {fp=}')
|
|
824
|
+
try:
|
|
825
|
+
data = s_common.yamlload(fp)
|
|
826
|
+
except Exception as e:
|
|
827
|
+
outp.printf(f'Error parsing yaml from {fp=}: {e}')
|
|
828
|
+
continue
|
|
829
|
+
|
|
830
|
+
if opts.verbose:
|
|
831
|
+
outp.printf('Got the following data:')
|
|
832
|
+
outp.printf(pprint.pformat(data))
|
|
833
|
+
|
|
834
|
+
files_processed.append(fp)
|
|
835
|
+
|
|
836
|
+
s_schemas._reqChangelogSchema(data)
|
|
837
|
+
|
|
838
|
+
data.setdefault('prs', [])
|
|
839
|
+
prs = data.get('prs')
|
|
840
|
+
|
|
841
|
+
if opts.prs_from_git: # pragma: no cover
|
|
842
|
+
|
|
843
|
+
argv = ['git', 'log', '--pretty=oneline', fp]
|
|
844
|
+
ret = subprocess.run(argv, capture_output=True)
|
|
845
|
+
if opts.verbose:
|
|
846
|
+
outp.printf(f'stddout={ret.stdout}')
|
|
847
|
+
outp.printf(f'stderr={ret.stderr}')
|
|
848
|
+
ret.check_returncode()
|
|
849
|
+
|
|
850
|
+
for line in ret.stdout.splitlines():
|
|
851
|
+
line = line.decode()
|
|
852
|
+
line = line.strip()
|
|
853
|
+
if not line:
|
|
854
|
+
continue
|
|
855
|
+
match = re.search('\\(#(?P<pr>\\d{1,})\\)', line)
|
|
856
|
+
if match:
|
|
857
|
+
for pr in match.groups():
|
|
858
|
+
pr = int(pr)
|
|
859
|
+
if pr not in prs:
|
|
860
|
+
prs.append(pr)
|
|
861
|
+
if opts.verbose:
|
|
862
|
+
outp.printf(f'Added PR #{pr} to the pr list from [{line=}]')
|
|
863
|
+
|
|
864
|
+
if opts.enforce_prs and not prs:
|
|
865
|
+
outp.printf(f'Entry is missing PR numbers: {fp=}')
|
|
866
|
+
return 1
|
|
867
|
+
|
|
868
|
+
if opts.verbose:
|
|
869
|
+
outp.printf(f'Got data from {fp=}')
|
|
870
|
+
|
|
871
|
+
prs.sort() # sort the PRs inplace
|
|
872
|
+
entries[data.get('type')].append(data)
|
|
873
|
+
|
|
874
|
+
if not entries:
|
|
875
|
+
outp.printf(f'No files passed validation from {opts.cdir}')
|
|
876
|
+
return 1
|
|
877
|
+
|
|
878
|
+
date = opts.date
|
|
879
|
+
if date is None:
|
|
880
|
+
date = datetime.datetime.utcnow().strftime('%Y-%m-%d')
|
|
881
|
+
header = f'{opts.version} - {date}'
|
|
882
|
+
text = f'{header}\n{"=" * len(header)}\n'
|
|
883
|
+
|
|
884
|
+
modeldiff = False
|
|
885
|
+
clean_vers_ref = opts.version.replace(".", "_")
|
|
886
|
+
model_rst_ref = f'userguide_model_{clean_vers_ref}'
|
|
887
|
+
|
|
888
|
+
if opts.model_ref:
|
|
889
|
+
# TODO find previous model file automatically?
|
|
890
|
+
if opts.verbose:
|
|
891
|
+
outp.printf(f'Getting reference model from {opts.model_ref}')
|
|
892
|
+
|
|
893
|
+
ref_modl = _getModelFile(opts.model_ref)
|
|
894
|
+
|
|
895
|
+
if opts.model_current:
|
|
896
|
+
to_modl = _getModelFile(opts.model_current)
|
|
897
|
+
cur_modl = to_modl.get('model')
|
|
898
|
+
if opts.verbose:
|
|
899
|
+
outp.printf(f'Comparing {to_modl.get("version")} - {to_modl.get("commit")} vs {ref_modl.get("version")} - {ref_modl.get("commit")}')
|
|
900
|
+
else:
|
|
901
|
+
cur_modl = await _getCurrentModl(outp)
|
|
902
|
+
if opts.verbose:
|
|
903
|
+
outp.printf(f'Comparing current model vs {ref_modl.get("version")} - {ref_modl.get("commit")}')
|
|
904
|
+
|
|
905
|
+
differ = ModelDiffer(cur_modl, ref_modl.get('model'))
|
|
906
|
+
changes = differ.diffModl(outp)
|
|
907
|
+
has_changes = sum([len(v) for v in changes.values()])
|
|
908
|
+
if has_changes:
|
|
909
|
+
entries['model'].append({'prs': [], 'type': 'skip'})
|
|
910
|
+
modeldiff = True
|
|
911
|
+
rst = _gen_model_rst(opts.version, model_rst_ref, changes, cur_modl, outp, width=opts.width)
|
|
912
|
+
model_text = rst.getRstText()
|
|
913
|
+
if opts.verbose:
|
|
914
|
+
outp.printf(model_text)
|
|
915
|
+
if opts.model_doc_dir:
|
|
916
|
+
fp = s_common.genpath(opts.model_doc_dir, f'update_{clean_vers_ref}.rst')
|
|
917
|
+
with s_common.genfile(fp) as fd:
|
|
918
|
+
fd.truncate(0)
|
|
919
|
+
fd.write(model_text.encode())
|
|
920
|
+
outp.printf(f'Wrote model changes to {fp}')
|
|
921
|
+
if opts.model_doc_git: # pragma: no cover
|
|
922
|
+
if opts.verbose:
|
|
923
|
+
outp.printf(f'Adding file to git.')
|
|
924
|
+
argv = ['git', 'add', fp]
|
|
925
|
+
ret = subprocess.run(argv, capture_output=True)
|
|
926
|
+
if opts.verbose:
|
|
927
|
+
outp.printf(f'stddout={ret.stdout}')
|
|
928
|
+
outp.printf(f'stderr={ret.stderr}')
|
|
929
|
+
ret.check_returncode()
|
|
930
|
+
else:
|
|
931
|
+
outp.printf('Not adding model changes to git.')
|
|
932
|
+
else:
|
|
933
|
+
outp.printf(f'No model changes detected.')
|
|
934
|
+
|
|
935
|
+
for key, header in s_schemas._changelogTypes.items():
|
|
936
|
+
dataz = entries.get(key)
|
|
937
|
+
if dataz:
|
|
938
|
+
text = text + f'\n{header}\n{"-" * len(header)}'
|
|
939
|
+
dataz.sort(key=lambda x: x.get('prs'))
|
|
940
|
+
for data in dataz:
|
|
941
|
+
desc = data.get('desc') # type: str
|
|
942
|
+
if desc is None and data.get('type') == 'skip':
|
|
943
|
+
continue
|
|
944
|
+
desc_lines = desc.splitlines()
|
|
945
|
+
if data.get('desc:literal'):
|
|
946
|
+
for i, chunk in enumerate(desc_lines):
|
|
947
|
+
if i == 0:
|
|
948
|
+
if not chunk.startswith('- '):
|
|
949
|
+
raise s_exc.SynErr(mesg='desc line 0 must start with "- "', desc=s_common.trimText(chunk, 80))
|
|
950
|
+
else:
|
|
951
|
+
if chunk and not chunk.startswith(' '):
|
|
952
|
+
raise s_exc.SynErr(mesg=f'desc line {i} must start with " "', desc=s_common.trimText(chunk, 80))
|
|
953
|
+
if len(chunk) >= opts.width:
|
|
954
|
+
raise s_exc.SynErr(mesg=f'desc line {i} is too long, {len(chunk)} > {opts.width}',
|
|
955
|
+
desc=s_common.trimText(chunk, 80))
|
|
956
|
+
text = f'{text}\n{chunk}'
|
|
957
|
+
else:
|
|
958
|
+
for i, chunk in enumerate(desc_lines):
|
|
959
|
+
if i == 0:
|
|
960
|
+
for line in textwrap.wrap(chunk, initial_indent='- ', subsequent_indent=' ', width=opts.width):
|
|
961
|
+
text = f'{text}\n{line}'
|
|
962
|
+
else:
|
|
963
|
+
text = text + '\n'
|
|
964
|
+
for line in textwrap.wrap(chunk, initial_indent=' ', subsequent_indent=' ', width=opts.width):
|
|
965
|
+
text = f'{text}\n{line}'
|
|
966
|
+
|
|
967
|
+
if not opts.hide_prs:
|
|
968
|
+
for pr in data.get('prs'):
|
|
969
|
+
text = f'{text}\n (`#{pr} <https://github.com/vertexproject/synapse/pull/{pr}>`_)'
|
|
970
|
+
if key == 'migration':
|
|
971
|
+
text = text + '\n- See :ref:`datamigration` for more information about automatic migrations.'
|
|
972
|
+
elif key == 'model':
|
|
973
|
+
if modeldiff:
|
|
974
|
+
text = text + f'\n- See :ref:`{model_rst_ref}` for more detailed model changes.'
|
|
975
|
+
text = text + '\n'
|
|
976
|
+
|
|
977
|
+
if opts.rm: # pragma: no cover
|
|
978
|
+
if opts.verbose:
|
|
979
|
+
outp.printf('Staging file removals in git')
|
|
980
|
+
for fp in files_processed:
|
|
981
|
+
argv = ['git', 'rm', fp]
|
|
982
|
+
ret = subprocess.run(argv, capture_output=True)
|
|
983
|
+
if opts.verbose:
|
|
984
|
+
outp.printf(f'stddout={ret.stdout}')
|
|
985
|
+
outp.printf(f'stderr={ret.stderr}')
|
|
986
|
+
ret.check_returncode()
|
|
987
|
+
|
|
988
|
+
outp.printf('CHANGELOG ENTRY:\n\n')
|
|
989
|
+
outp.printf(text)
|
|
990
|
+
|
|
991
|
+
return 0
|
|
992
|
+
|
|
993
|
+
async def model(opts: s_cmd.argparse.Namespace,
|
|
994
|
+
outp: s_output.OutPut):
|
|
995
|
+
|
|
996
|
+
if opts.save:
|
|
997
|
+
modl = await _getCurrentModl(outp)
|
|
998
|
+
|
|
999
|
+
dirn = s_common.gendir(opts.cdir, 'modelrefs')
|
|
1000
|
+
current_commit = _getCurrentCommit(outp)
|
|
1001
|
+
if not current_commit:
|
|
1002
|
+
return 1
|
|
1003
|
+
wrapped_modl = {
|
|
1004
|
+
'model': modl,
|
|
1005
|
+
'commit': current_commit,
|
|
1006
|
+
'version': s_version.version,
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
fp = s_common.genpath(dirn, f'model_{s_version.verstring}_{current_commit}.yaml.gz')
|
|
1010
|
+
with s_common.genfile(fp) as fd:
|
|
1011
|
+
fd.truncate(0)
|
|
1012
|
+
bytz = s_common.yamldump(wrapped_modl)
|
|
1013
|
+
small_bytz = gzip.compress(bytz)
|
|
1014
|
+
_ = fd.write(small_bytz)
|
|
1015
|
+
|
|
1016
|
+
outp.printf(f'Saved model to {fp}')
|
|
1017
|
+
return 0
|
|
1018
|
+
|
|
1019
|
+
if opts.compare:
|
|
1020
|
+
ref_modl = _getModelFile(opts.compare)
|
|
1021
|
+
if opts.to:
|
|
1022
|
+
to_modl = _getModelFile(opts.to)
|
|
1023
|
+
modl = to_modl.get('model')
|
|
1024
|
+
outp.printf(f'Comparing {to_modl.get("version")} - {to_modl.get("commit")} vs {ref_modl.get("version")} - {ref_modl.get("commit")}')
|
|
1025
|
+
else:
|
|
1026
|
+
modl = await _getCurrentModl(outp)
|
|
1027
|
+
outp.printf(f'Comparing current model vs {ref_modl.get("version")} - {ref_modl.get("commit")}')
|
|
1028
|
+
differ = ModelDiffer(modl, ref_modl.get('model'))
|
|
1029
|
+
changes = differ.diffModl(outp)
|
|
1030
|
+
for line in pprint.pformat(changes).splitlines(keepends=False):
|
|
1031
|
+
outp.printf(line)
|
|
1032
|
+
return 0
|
|
1033
|
+
|
|
1034
|
+
async def main(argv, outp=s_output.stdout):
|
|
1035
|
+
pars = getArgParser(outp)
|
|
1036
|
+
|
|
1037
|
+
opts = pars.parse_args(argv)
|
|
1038
|
+
if opts.git_dir_check:
|
|
1039
|
+
if not os.path.exists(os.path.join(os.getcwd(), '.git')):
|
|
1040
|
+
outp.printf('Current working directory must be the root of the repository.')
|
|
1041
|
+
return 1
|
|
1042
|
+
|
|
1043
|
+
if opts.verbose:
|
|
1044
|
+
outp.printf(f'{opts=}')
|
|
1045
|
+
|
|
1046
|
+
try:
|
|
1047
|
+
return await opts.func(opts, outp)
|
|
1048
|
+
except Exception as e:
|
|
1049
|
+
outp.printf(f'Error running {opts.func}: {traceback.format_exc()}')
|
|
1050
|
+
return 1
|
|
1051
|
+
|
|
1052
|
+
def getArgParser(outp: s_output.OutPut):
|
|
1053
|
+
desc = '''Command line tool to manage changelog entries.
|
|
1054
|
+
This tool and any data formats associated with it may change at any time.
|
|
1055
|
+
'''
|
|
1056
|
+
pars = s_cmd.Parser(prog='synapse.tools.utils.changelog', outp=outp, description=desc)
|
|
1057
|
+
|
|
1058
|
+
subpars = pars.add_subparsers(required=True,
|
|
1059
|
+
title='subcommands',
|
|
1060
|
+
dest='cmd', )
|
|
1061
|
+
gen_pars = subpars.add_parser('gen', help='Generate a new changelog entry.')
|
|
1062
|
+
gen_pars.set_defaults(func=gen)
|
|
1063
|
+
gen_pars.add_argument('-t', '--type', required=True, choices=list(s_schemas._changelogTypes.keys()),
|
|
1064
|
+
help='The changelog type.')
|
|
1065
|
+
gen_pars.add_argument('desc', type=str,
|
|
1066
|
+
help='The description to populate the initial changelog entry with.', )
|
|
1067
|
+
gen_pars.add_argument('-p', '--pr', type=int, default=False,
|
|
1068
|
+
help='PR number associated with the changelog entry.')
|
|
1069
|
+
gen_pars.add_argument('-a', '--add', default=False, action='store_true',
|
|
1070
|
+
help='Add the newly created file to the current git staging area.')
|
|
1071
|
+
# Hidden name override. Mainly for testing.
|
|
1072
|
+
gen_pars.add_argument('-n', '--name', default=None, type=str,
|
|
1073
|
+
help=s_cmd.argparse.SUPPRESS)
|
|
1074
|
+
|
|
1075
|
+
format_pars = subpars.add_parser('format', help='Format existing files into a RST block.')
|
|
1076
|
+
format_pars.set_defaults(func=format)
|
|
1077
|
+
mux_prs = format_pars.add_mutually_exclusive_group()
|
|
1078
|
+
mux_prs.add_argument('--hide-prs', default=False, action='store_true',
|
|
1079
|
+
help='Hide PR entries.')
|
|
1080
|
+
mux_prs.add_argument('--enforce-prs', default=False, action='store_true',
|
|
1081
|
+
help='Enforce PRs list to be populated with at least one number.', )
|
|
1082
|
+
format_pars.add_argument('--prs-from-git', default=False, action='store_true',
|
|
1083
|
+
help='Attempt to populate any PR numbers from a given files commit history.')
|
|
1084
|
+
format_pars.add_argument('-w', '--width', help='Maximum column width to wrap descriptions at.',
|
|
1085
|
+
default=79, type=int)
|
|
1086
|
+
format_pars.add_argument('--version', required=True, action='store', type=str,
|
|
1087
|
+
help='Version number')
|
|
1088
|
+
format_pars.add_argument('-d', '--date', action='store', type=str,
|
|
1089
|
+
help='Date to use with the changelog entry')
|
|
1090
|
+
format_pars.add_argument('-r', '--rm', default=False, action='store_true',
|
|
1091
|
+
help='Stage the changelog files as deleted files in git.')
|
|
1092
|
+
format_pars.add_argument('-m', '--model-ref', default=None, action='store', type=str,
|
|
1093
|
+
help='Baseline model to use when generating model deltas. This is normally the previous releases model file.')
|
|
1094
|
+
format_pars.add_argument('--model-current', default=None, action='store',
|
|
1095
|
+
help='Optional model file to use as a reference as the current model.')
|
|
1096
|
+
format_pars.add_argument('--model-doc-dir', default=None, action='store',
|
|
1097
|
+
help='Directory to write the model changes too.')
|
|
1098
|
+
format_pars.add_argument('--model-doc-no-git', default=True, dest='model_doc_git',
|
|
1099
|
+
action='store_false', help='Do not add the model doc output to git.')
|
|
1100
|
+
|
|
1101
|
+
model_pars = subpars.add_parser('model', help='Helper for working with the Cortex data model.')
|
|
1102
|
+
model_pars.set_defaults(func=model)
|
|
1103
|
+
mux_model = model_pars.add_mutually_exclusive_group(required=True)
|
|
1104
|
+
mux_model.add_argument('-s', '--save', action='store_true', default=False,
|
|
1105
|
+
help='Save a copy of the current model to a file.')
|
|
1106
|
+
mux_model.add_argument('-c', '--compare', action='store', default=None,
|
|
1107
|
+
help='Model to compare the current model against. Useful for debugging modl diff functionality.'
|
|
1108
|
+
)
|
|
1109
|
+
model_pars.add_argument('-t', '--to', action='store', default=None,
|
|
1110
|
+
help='The model file to compare against. Will not use current model if specified.')
|
|
1111
|
+
|
|
1112
|
+
for p in (gen_pars, format_pars, model_pars):
|
|
1113
|
+
p.add_argument('-v', '--verbose', default=False, action='store_true',
|
|
1114
|
+
help='Enable verbose output')
|
|
1115
|
+
p.add_argument('--cdir', default='./changes', action='store',
|
|
1116
|
+
help='Directory of changelog files.')
|
|
1117
|
+
# Hidden name override. Mainly for testing.
|
|
1118
|
+
p.add_argument('--disable-git-dir-check', dest='git_dir_check', default=True, action='store_false',
|
|
1119
|
+
help=s_cmd.argparse.SUPPRESS)
|
|
1120
|
+
|
|
1121
|
+
return pars
|
|
1122
|
+
|
|
1123
|
+
if __name__ == '__main__': # pragma: no cover
|
|
1124
|
+
s_cmd.exitmain(main)
|