secator 0.22.0__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.
Files changed (150) hide show
  1. secator/.gitignore +162 -0
  2. secator/__init__.py +0 -0
  3. secator/celery.py +453 -0
  4. secator/celery_signals.py +138 -0
  5. secator/celery_utils.py +320 -0
  6. secator/cli.py +2035 -0
  7. secator/cli_helper.py +395 -0
  8. secator/click.py +87 -0
  9. secator/config.py +670 -0
  10. secator/configs/__init__.py +0 -0
  11. secator/configs/profiles/__init__.py +0 -0
  12. secator/configs/profiles/aggressive.yaml +8 -0
  13. secator/configs/profiles/all_ports.yaml +7 -0
  14. secator/configs/profiles/full.yaml +31 -0
  15. secator/configs/profiles/http_headless.yaml +7 -0
  16. secator/configs/profiles/http_record.yaml +8 -0
  17. secator/configs/profiles/insane.yaml +8 -0
  18. secator/configs/profiles/paranoid.yaml +8 -0
  19. secator/configs/profiles/passive.yaml +11 -0
  20. secator/configs/profiles/polite.yaml +8 -0
  21. secator/configs/profiles/sneaky.yaml +8 -0
  22. secator/configs/profiles/tor.yaml +5 -0
  23. secator/configs/scans/__init__.py +0 -0
  24. secator/configs/scans/domain.yaml +31 -0
  25. secator/configs/scans/host.yaml +23 -0
  26. secator/configs/scans/network.yaml +30 -0
  27. secator/configs/scans/subdomain.yaml +27 -0
  28. secator/configs/scans/url.yaml +19 -0
  29. secator/configs/workflows/__init__.py +0 -0
  30. secator/configs/workflows/cidr_recon.yaml +48 -0
  31. secator/configs/workflows/code_scan.yaml +29 -0
  32. secator/configs/workflows/domain_recon.yaml +46 -0
  33. secator/configs/workflows/host_recon.yaml +95 -0
  34. secator/configs/workflows/subdomain_recon.yaml +120 -0
  35. secator/configs/workflows/url_bypass.yaml +15 -0
  36. secator/configs/workflows/url_crawl.yaml +98 -0
  37. secator/configs/workflows/url_dirsearch.yaml +62 -0
  38. secator/configs/workflows/url_fuzz.yaml +68 -0
  39. secator/configs/workflows/url_params_fuzz.yaml +66 -0
  40. secator/configs/workflows/url_secrets_hunt.yaml +23 -0
  41. secator/configs/workflows/url_vuln.yaml +91 -0
  42. secator/configs/workflows/user_hunt.yaml +29 -0
  43. secator/configs/workflows/wordpress.yaml +38 -0
  44. secator/cve.py +718 -0
  45. secator/decorators.py +7 -0
  46. secator/definitions.py +168 -0
  47. secator/exporters/__init__.py +14 -0
  48. secator/exporters/_base.py +3 -0
  49. secator/exporters/console.py +10 -0
  50. secator/exporters/csv.py +37 -0
  51. secator/exporters/gdrive.py +123 -0
  52. secator/exporters/json.py +16 -0
  53. secator/exporters/table.py +36 -0
  54. secator/exporters/txt.py +28 -0
  55. secator/hooks/__init__.py +0 -0
  56. secator/hooks/gcs.py +80 -0
  57. secator/hooks/mongodb.py +281 -0
  58. secator/installer.py +694 -0
  59. secator/loader.py +128 -0
  60. secator/output_types/__init__.py +49 -0
  61. secator/output_types/_base.py +108 -0
  62. secator/output_types/certificate.py +78 -0
  63. secator/output_types/domain.py +50 -0
  64. secator/output_types/error.py +42 -0
  65. secator/output_types/exploit.py +58 -0
  66. secator/output_types/info.py +24 -0
  67. secator/output_types/ip.py +47 -0
  68. secator/output_types/port.py +55 -0
  69. secator/output_types/progress.py +36 -0
  70. secator/output_types/record.py +36 -0
  71. secator/output_types/stat.py +41 -0
  72. secator/output_types/state.py +29 -0
  73. secator/output_types/subdomain.py +45 -0
  74. secator/output_types/tag.py +69 -0
  75. secator/output_types/target.py +38 -0
  76. secator/output_types/url.py +112 -0
  77. secator/output_types/user_account.py +41 -0
  78. secator/output_types/vulnerability.py +101 -0
  79. secator/output_types/warning.py +30 -0
  80. secator/report.py +140 -0
  81. secator/rich.py +130 -0
  82. secator/runners/__init__.py +14 -0
  83. secator/runners/_base.py +1240 -0
  84. secator/runners/_helpers.py +218 -0
  85. secator/runners/celery.py +18 -0
  86. secator/runners/command.py +1178 -0
  87. secator/runners/python.py +126 -0
  88. secator/runners/scan.py +87 -0
  89. secator/runners/task.py +81 -0
  90. secator/runners/workflow.py +168 -0
  91. secator/scans/__init__.py +29 -0
  92. secator/serializers/__init__.py +8 -0
  93. secator/serializers/dataclass.py +39 -0
  94. secator/serializers/json.py +45 -0
  95. secator/serializers/regex.py +25 -0
  96. secator/tasks/__init__.py +8 -0
  97. secator/tasks/_categories.py +487 -0
  98. secator/tasks/arjun.py +113 -0
  99. secator/tasks/arp.py +53 -0
  100. secator/tasks/arpscan.py +70 -0
  101. secator/tasks/bbot.py +372 -0
  102. secator/tasks/bup.py +118 -0
  103. secator/tasks/cariddi.py +193 -0
  104. secator/tasks/dalfox.py +87 -0
  105. secator/tasks/dirsearch.py +84 -0
  106. secator/tasks/dnsx.py +186 -0
  107. secator/tasks/feroxbuster.py +93 -0
  108. secator/tasks/ffuf.py +135 -0
  109. secator/tasks/fping.py +85 -0
  110. secator/tasks/gau.py +102 -0
  111. secator/tasks/getasn.py +60 -0
  112. secator/tasks/gf.py +36 -0
  113. secator/tasks/gitleaks.py +96 -0
  114. secator/tasks/gospider.py +84 -0
  115. secator/tasks/grype.py +109 -0
  116. secator/tasks/h8mail.py +75 -0
  117. secator/tasks/httpx.py +167 -0
  118. secator/tasks/jswhois.py +36 -0
  119. secator/tasks/katana.py +203 -0
  120. secator/tasks/maigret.py +87 -0
  121. secator/tasks/mapcidr.py +42 -0
  122. secator/tasks/msfconsole.py +179 -0
  123. secator/tasks/naabu.py +85 -0
  124. secator/tasks/nmap.py +487 -0
  125. secator/tasks/nuclei.py +151 -0
  126. secator/tasks/search_vulns.py +225 -0
  127. secator/tasks/searchsploit.py +109 -0
  128. secator/tasks/sshaudit.py +299 -0
  129. secator/tasks/subfinder.py +48 -0
  130. secator/tasks/testssl.py +283 -0
  131. secator/tasks/trivy.py +130 -0
  132. secator/tasks/trufflehog.py +240 -0
  133. secator/tasks/urlfinder.py +100 -0
  134. secator/tasks/wafw00f.py +106 -0
  135. secator/tasks/whois.py +34 -0
  136. secator/tasks/wpprobe.py +116 -0
  137. secator/tasks/wpscan.py +202 -0
  138. secator/tasks/x8.py +94 -0
  139. secator/tasks/xurlfind3r.py +83 -0
  140. secator/template.py +294 -0
  141. secator/thread.py +24 -0
  142. secator/tree.py +196 -0
  143. secator/utils.py +922 -0
  144. secator/utils_test.py +297 -0
  145. secator/workflows/__init__.py +29 -0
  146. secator-0.22.0.dist-info/METADATA +447 -0
  147. secator-0.22.0.dist-info/RECORD +150 -0
  148. secator-0.22.0.dist-info/WHEEL +4 -0
  149. secator-0.22.0.dist-info/entry_points.txt +2 -0
  150. secator-0.22.0.dist-info/licenses/LICENSE +60 -0
secator/cli_helper.py ADDED
@@ -0,0 +1,395 @@
1
+ import datetime
2
+ import os
3
+ import re
4
+ import sys
5
+
6
+ from collections import OrderedDict
7
+ from contextlib import nullcontext
8
+
9
+ import psutil
10
+ import rich_click as click
11
+ from rich_click.rich_click import _get_rich_console
12
+
13
+ from secator.config import CONFIG
14
+ from secator.click import CLICK_LIST
15
+ from secator.definitions import ADDONS_ENABLED
16
+ from secator.runners import Scan, Task, Workflow
17
+ from secator.template import get_config_options
18
+ from secator.tree import build_runner_tree
19
+ from secator.utils import (deduplicate, expand_input, get_command_category)
20
+ from secator.loader import get_configs_by_type
21
+
22
+
23
+ WORKSPACES = next(os.walk(CONFIG.dirs.reports))[1]
24
+ WORKSPACES_STR = '|'.join([f'[dim yellow3]{_}[/]' for _ in WORKSPACES])
25
+ PROFILES_STR = ','.join([f'[dim yellow3]{_.name}[/]' for _ in get_configs_by_type('profile')])
26
+ DRIVERS_STR = ','.join([f'[dim yellow3]{_}[/]' for _ in ['mongodb', 'gcs']])
27
+ DRIVER_DEFAULTS_STR = ','.join(CONFIG.drivers.defaults) if CONFIG.drivers.defaults else None
28
+ PROFILE_DEFAULTS_STR = ','.join(CONFIG.profiles.defaults) if CONFIG.profiles.defaults else None
29
+ EXPORTERS_STR = ','.join([f'[dim yellow3]{_}[/]' for _ in ['csv', 'gdrive', 'json', 'table', 'txt']])
30
+
31
+ CLI_OUTPUT_OPTS = {
32
+ 'output': {'type': str, 'default': None, 'help': f'Output options [{EXPORTERS_STR}] [dim orange4](comma-separated)[/]', 'short': 'o'}, # noqa: E501
33
+ 'fmt': {'default': '', 'short': 'fmt', 'internal_name': 'print_format', 'help': 'Output formatting string'},
34
+ 'json': {'is_flag': True, 'short': 'json', 'internal_name': 'print_json', 'default': False, 'help': 'Print items as JSON lines'}, # noqa: E501
35
+ 'raw': {'is_flag': True, 'short': 'raw', 'internal_name': 'print_raw', 'default': False, 'help': 'Print items in raw format'}, # noqa: E501
36
+ 'stat': {'is_flag': True, 'short': 'stat', 'internal_name': 'print_stat', 'default': False, 'help': 'Print runtime statistics'}, # noqa: E501
37
+ 'quiet': {'is_flag': True, 'short': 'q', 'default': not CONFIG.cli.show_command_output, 'opposite': 'verbose', 'help': 'Hide or show original command output'}, # noqa: E501
38
+ 'yaml': {'is_flag': True, 'short': 'yaml', 'default': False, 'help': 'Show runner yaml'},
39
+ 'tree': {'is_flag': True, 'short': 'tree', 'default': False, 'help': 'Show runner tree'},
40
+ 'dry_run': {'is_flag': True, 'short': 'dry', 'default': False, 'help': 'Show dry run'},
41
+ 'process': {'is_flag': True, 'short': 'ps', 'default': True, 'help': 'Enable / disable secator processing', 'reverse': True}, # noqa: E501
42
+ 'version': {'is_flag': True, 'help': 'Show runner version'},
43
+ }
44
+
45
+ CLI_EXEC_OPTS = {
46
+ 'workspace': {'type': str, 'default': 'default', 'help': f'Workspace [{WORKSPACES_STR}|[dim orange4]<new>[/]]', 'short': 'ws'}, # noqa: E501
47
+ 'profiles': {'type': str, 'help': f'Profiles [{PROFILES_STR}] [dim orange4](comma-separated)[/]', 'default': PROFILE_DEFAULTS_STR, 'short': 'pf'}, # noqa: E501
48
+ 'driver': {'type': str, 'help': f'Drivers [{DRIVERS_STR}] [dim orange4](comma-separated)[/]', 'default': DRIVER_DEFAULTS_STR}, # noqa: E501
49
+ 'sync': {'is_flag': True, 'help': 'Run tasks locally or in worker', 'opposite': 'worker'},
50
+ 'no_poll': {'is_flag': True, 'short': 'np', 'default': False, 'help': 'Do not live poll for tasks results when running in worker'}, # noqa: E501
51
+ 'enable_pyinstrument': {'is_flag': True, 'short': 'pyinstrument', 'default': False, 'help': 'Enable pyinstrument profiling'}, # noqa: E501
52
+ 'enable_memray': {'is_flag': True, 'short': 'memray', 'default': False, 'help': 'Enable memray profiling'},
53
+ }
54
+
55
+ CLI_TYPE_MAPPING = {
56
+ 'str': str,
57
+ 'list': CLICK_LIST,
58
+ 'int': int,
59
+ 'float': float,
60
+ # 'choice': click.Choice,
61
+ # 'file': click.Path(exists=True, file_okay=True, dir_okay=False, readable=True),
62
+ # 'dir': click.Path(exists=True, file_okay=False, dir_okay=True, readable=True),
63
+ # 'path': click.Path(exists=True, file_okay=True, dir_okay=True, readable=True),
64
+ # 'url': click.URL,
65
+ }
66
+
67
+ DEFAULT_CLI_OPTIONS = list(CLI_OUTPUT_OPTS.keys()) + list(CLI_EXEC_OPTS.keys())
68
+
69
+
70
+ def decorate_command_options(opts):
71
+ """Add click.option decorator to decorate click command.
72
+
73
+ Args:
74
+ opts (dict): Dict of command options.
75
+
76
+ Returns:
77
+ function: Decorator.
78
+ """
79
+ def decorator(f):
80
+ reversed_opts = OrderedDict(list(opts.items())[::-1])
81
+ for opt_name, opt_conf in reversed_opts.items():
82
+ conf = opt_conf.copy()
83
+ short_opt = conf.pop('short', None)
84
+ internal = conf.pop('internal', False)
85
+ display = conf.pop('display', True)
86
+ internal_name = conf.pop('internal_name', None)
87
+ if internal and not display:
88
+ continue
89
+ conf.pop('shlex', None)
90
+ conf.pop('meta', None)
91
+ conf.pop('supported', None)
92
+ conf.pop('process', None)
93
+ conf.pop('pre_process', None)
94
+ conf.pop('requires_sudo', None)
95
+ conf.pop('prefix', None)
96
+ applies_to = conf.pop('applies_to', None)
97
+ default_from = conf.pop('default_from', None)
98
+ reverse = conf.pop('reverse', False)
99
+ opposite = conf.pop('opposite', None)
100
+ long = f'--{opt_name}'
101
+ short = f'-{short_opt}' if short_opt else f'-{opt_name}'
102
+ if reverse:
103
+ if opposite:
104
+ long += f'/--{opposite}'
105
+ short += f'/-{opposite}' if len(short) > 2 else f'/-{opposite[0]}'
106
+ conf['help'] = conf['help'].replace(opt_name, f'{opt_name} / {opposite}')
107
+ else:
108
+ long += f'/--no-{opt_name}'
109
+ short += f'/-n{short_opt}' if short_opt else f'/-n{opt_name}'
110
+ if applies_to:
111
+ applies_to_str = ", ".join(f'[bold yellow3]{_}[/]' for _ in applies_to)
112
+ conf['help'] += rf' \[[dim]{applies_to_str}[/]]'
113
+ if default_from:
114
+ conf['help'] += rf' \[[dim]default from: [dim yellow3]{default_from}[/][/]]'
115
+ args = [long, short]
116
+ if internal_name:
117
+ args.append(internal_name)
118
+ f = click.option(*args, **conf)(f)
119
+ return f
120
+ return decorator
121
+
122
+
123
+ def generate_cli_subcommand(cli_endpoint, func, **opts):
124
+ return cli_endpoint.command(**opts)(func)
125
+
126
+
127
+ def register_runner(cli_endpoint, config):
128
+ name = config.name
129
+ input_types = []
130
+ command_opts = {
131
+ 'no_args_is_help': True,
132
+ 'context_settings': {
133
+ 'ignore_unknown_options': False,
134
+ 'allow_extra_args': False
135
+ }
136
+ }
137
+
138
+ if cli_endpoint.name == 'scan':
139
+ runner_cls = Scan
140
+ short_help = config.description or ''
141
+ short_help += f' [dim]alias: {config.alias}' if config.alias else ''
142
+ command_opts.update({
143
+ 'name': name,
144
+ 'short_help': short_help,
145
+ 'no_args_is_help': False
146
+ })
147
+ input_types = config.input_types
148
+
149
+ elif cli_endpoint.name == 'workflow':
150
+ runner_cls = Workflow
151
+ short_help = config.description or ''
152
+ short_help = f'{short_help:<55} [dim](alias)[/][bold cyan] {config.alias}' if config.alias else ''
153
+ command_opts.update({
154
+ 'name': name,
155
+ 'short_help': short_help,
156
+ 'no_args_is_help': False
157
+ })
158
+ input_types = config.input_types
159
+
160
+ elif cli_endpoint.name == 'task':
161
+ runner_cls = Task
162
+ task_cls = Task.get_task_class(config.name)
163
+ task_category = get_command_category(task_cls)
164
+ short_help = f'[magenta]{task_category:<25}[/] {task_cls.__doc__}'
165
+ command_opts.update({
166
+ 'name': name,
167
+ 'short_help': short_help,
168
+ 'no_args_is_help': False
169
+ })
170
+ input_types = task_cls.input_types
171
+ else:
172
+ raise ValueError(f"Unrecognized runner endpoint name {cli_endpoint.name}")
173
+ input_types_str = '|'.join(input_types) if input_types else 'targets'
174
+ default_inputs = None if config.default_inputs == {} else config.default_inputs
175
+ input_required = default_inputs is None
176
+ options = get_config_options(
177
+ config,
178
+ exec_opts=CLI_EXEC_OPTS,
179
+ output_opts=CLI_OUTPUT_OPTS,
180
+ type_mapping=CLI_TYPE_MAPPING
181
+ )
182
+
183
+ # TODO: maybe allow this in the future
184
+ # def get_unknown_opts(ctx):
185
+ # return {
186
+ # (ctx.args[i][2:]
187
+ # if str(ctx.args[i]).startswith("--") \
188
+ # else ctx.args[i][1:]): ctx.args[i+1]
189
+ # for i in range(0, len(ctx.args), 2)
190
+ # }
191
+
192
+ @click.argument('inputs', metavar=input_types_str, required=False)
193
+ @decorate_command_options(options)
194
+ @click.pass_context
195
+ def func(ctx, **opts):
196
+ console = _get_rich_console()
197
+ version = opts['version']
198
+ sync = opts['sync']
199
+ ws = opts.pop('workspace')
200
+ driver = opts.pop('driver', '')
201
+ quiet = opts['quiet']
202
+ dry_run = opts['dry_run']
203
+ yaml = opts['yaml']
204
+ tree = opts['tree']
205
+ context = {'workspace_name': ws}
206
+ enable_pyinstrument = opts['enable_pyinstrument']
207
+ enable_memray = opts['enable_memray']
208
+ contextmanager = nullcontext()
209
+ process = None
210
+
211
+ # Set dry run
212
+ ctx.obj['dry_run'] = dry_run
213
+ ctx.obj['input_types'] = input_types
214
+ ctx.obj['input_required'] = input_required
215
+ ctx.obj['default_inputs'] = default_inputs
216
+
217
+ # Show version
218
+ if version:
219
+ if not cli_endpoint.name == 'task':
220
+ console.print(f'[bold red]Version information is not available for {cli_endpoint.name}.[/]')
221
+ sys.exit(1)
222
+ data = task_cls.get_version_info()
223
+ current = data['version']
224
+ latest = data['latest_version']
225
+ installed = data['installed']
226
+ if not installed:
227
+ console.print(f'[bold red]{task_cls.__name__} is not installed.[/]')
228
+ else:
229
+ console.print(f'{task_cls.__name__} version: [bold green]{current}[/] (recommended: [bold green]{latest}[/])')
230
+ sys.exit(0)
231
+
232
+ # Show runner yaml
233
+ if yaml:
234
+ config.print()
235
+ sys.exit(0)
236
+
237
+ # Show runner tree
238
+ if tree:
239
+ tree = build_runner_tree(config)
240
+ console.print(tree.render_tree())
241
+ sys.exit(0)
242
+
243
+ # TODO: maybe allow this in the future
244
+ # unknown_opts = get_unknown_opts(ctx)
245
+ # opts.update(unknown_opts)
246
+
247
+ # Expand input
248
+ inputs = opts.pop('inputs')
249
+ inputs = expand_input(inputs, ctx)
250
+
251
+ # Build hooks from driver name
252
+ hooks = []
253
+ drivers = driver.split(',') if driver else []
254
+ drivers = list(set(CONFIG.drivers.defaults + drivers))
255
+ supported_drivers = ['mongodb', 'gcs']
256
+ for driver in drivers:
257
+ if driver in supported_drivers:
258
+ if not ADDONS_ENABLED[driver]:
259
+ console.print(f'[bold red]Missing "{driver}" addon: please run `secator install addons {driver}`[/].')
260
+ sys.exit(1)
261
+ from secator.utils import import_dynamic
262
+ driver_hooks = import_dynamic(f'secator.hooks.{driver}', 'HOOKS')
263
+ if driver_hooks is None:
264
+ console.print(f'[bold red]Missing "secator.hooks.{driver}.HOOKS".[/]')
265
+ sys.exit(1)
266
+ hooks.append(driver_hooks)
267
+ else:
268
+ supported_drivers_str = ', '.join([f'[bold green]{_}[/]' for _ in supported_drivers])
269
+ console.print(f'[bold red]Driver "{driver}" is not supported.[/]')
270
+ console.print(f'Supported drivers: {supported_drivers_str}')
271
+ sys.exit(1)
272
+
273
+ if enable_pyinstrument or enable_memray:
274
+ if not ADDONS_ENABLED["trace"]:
275
+ console.print(
276
+ '[bold red]Missing "trace" addon: please run `secator install addons trace`[/].'
277
+ )
278
+ sys.exit(1)
279
+ import memray
280
+ output_file = f'trace_memray_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.bin'
281
+ contextmanager = memray.Tracker(output_file)
282
+
283
+ from secator.utils import deep_merge_dicts
284
+ hooks = deep_merge_dicts(*hooks)
285
+
286
+ # Enable sync or not
287
+ if sync or dry_run:
288
+ sync = True
289
+ else:
290
+ from secator.celery import is_celery_worker_alive
291
+ worker_alive = is_celery_worker_alive()
292
+ if not worker_alive and not sync:
293
+ sync = True
294
+ else:
295
+ sync = False
296
+ broker_protocol = CONFIG.celery.broker_url.split('://')[0]
297
+ backend_protocol = CONFIG.celery.result_backend.split('://')[0]
298
+ if CONFIG.celery.broker_url and \
299
+ (broker_protocol == 'redis' or backend_protocol == 'redis') \
300
+ and not ADDONS_ENABLED['redis']:
301
+ _get_rich_console().print('[bold red]Missing `redis` addon: please run `secator install addons redis`[/].')
302
+ sys.exit(1)
303
+
304
+ from secator.utils import debug
305
+ debug('Run options', obj=opts, sub='cli')
306
+
307
+ # Set run options
308
+ opts.update({
309
+ 'print_cmd': True,
310
+ 'print_item': True,
311
+ 'print_line': True,
312
+ 'print_progress': True,
313
+ 'print_profiles': True,
314
+ 'print_start': True,
315
+ 'print_target': True,
316
+ 'print_end': True,
317
+ 'print_remote_info': not sync,
318
+ 'piped_input': ctx.obj['piped_input'],
319
+ 'piped_output': ctx.obj['piped_output'],
320
+ 'caller': 'cli',
321
+ 'sync': sync,
322
+ 'quiet': quiet
323
+ })
324
+
325
+ # Start runner
326
+ with contextmanager:
327
+ if enable_memray:
328
+ process = psutil.Process()
329
+ console.print(
330
+ f"[bold yellow3]Initial RAM Usage: {process.memory_info().rss / 1024 ** 2} MB[/]"
331
+ )
332
+ item_count = 0
333
+ runner = runner_cls(
334
+ config, inputs, run_opts=opts, hooks=hooks, context=context
335
+ )
336
+ for item in runner:
337
+ del item
338
+ item_count += 1
339
+ if process and item_count % 100 == 0:
340
+ console.print(
341
+ f"[bold yellow3]RAM Usage: {process.memory_info().rss / 1024 ** 2} MB[/]"
342
+ )
343
+
344
+ if enable_memray:
345
+ console.print(f"[bold green]Memray output file: {output_file}[/]")
346
+ os.system(f"memray flamegraph {output_file}")
347
+
348
+ generate_cli_subcommand(cli_endpoint, func, **command_opts)
349
+ generate_rich_click_opt_groups(cli_endpoint, name, input_types, options)
350
+
351
+
352
+ def generate_rich_click_opt_groups(cli_endpoint, name, input_types, options):
353
+ sortorder = {
354
+ 'Execution': 0,
355
+ 'Output': 1,
356
+ 'Meta': 2,
357
+ 'Config.*': 3,
358
+ 'Shared task': 4,
359
+ 'Task.*': 5,
360
+ 'Workflow.*': 6,
361
+ 'Scan.*': 7,
362
+ }
363
+
364
+ def match_sort_order(prefix):
365
+ for k, v in sortorder.items():
366
+ if re.match(k, prefix):
367
+ return v
368
+ return 8
369
+
370
+ prefixes = deduplicate([opt['prefix'] for opt in options.values()])
371
+ prefixes = sorted(prefixes, key=match_sort_order)
372
+ opt_group = [
373
+ {
374
+ 'name': 'Targets',
375
+ 'options': input_types,
376
+ },
377
+ ]
378
+ for prefix in prefixes:
379
+ prefix_opts = [
380
+ opt for opt, conf in options.items()
381
+ if conf['prefix'] == prefix
382
+ ]
383
+ if prefix not in ['Execution', 'Output']:
384
+ prefix_opts = sorted(prefix_opts)
385
+ opt_names = [f'--{opt_name}' for opt_name in prefix_opts]
386
+ if prefix == 'Output':
387
+ opt_names.append('--help')
388
+ opt_group.append({
389
+ 'name': prefix + ' options',
390
+ 'options': opt_names
391
+ })
392
+ aliases = [cli_endpoint.name, *cli_endpoint.aliases]
393
+ for alias in aliases:
394
+ endpoint_name = f'secator {alias} {name}'
395
+ click.rich_click.OPTION_GROUPS[endpoint_name] = opt_group
secator/click.py ADDED
@@ -0,0 +1,87 @@
1
+ from collections import OrderedDict
2
+
3
+ import rich_click as click
4
+ from rich_click.rich_click import _get_rich_console
5
+ from rich_click.rich_group import RichGroup
6
+
7
+
8
+ class ListParamType(click.ParamType):
9
+ """Custom click param type to convert comma-separated strings to lists."""
10
+ name = "list"
11
+
12
+ def convert(self, value, param, ctx):
13
+ if value is None:
14
+ return []
15
+ if isinstance(value, list):
16
+ return value
17
+ return [v.strip() for v in value.split(',') if v.strip()]
18
+
19
+
20
+ CLICK_LIST = ListParamType()
21
+
22
+
23
+ class OrderedGroup(RichGroup):
24
+ def __init__(self, name=None, commands=None, **attrs):
25
+ super(OrderedGroup, self).__init__(name, commands, **attrs)
26
+ self.commands = commands or OrderedDict()
27
+
28
+ def command(self, *args, **kwargs):
29
+ """Behaves the same as `click.Group.command()` but supports aliases.
30
+ """
31
+ def decorator(f):
32
+ aliases = kwargs.pop("aliases", None)
33
+ if aliases:
34
+ max_width = _get_rich_console().width
35
+ aliases_str = ', '.join(f'[bold cyan]{alias}[/]' for alias in aliases)
36
+ padding = max_width // 4
37
+
38
+ name = kwargs.pop("name", None)
39
+ if not name:
40
+ raise click.UsageError("`name` command argument is required when using aliases.")
41
+
42
+ f.__doc__ = f.__doc__ or '\0'.ljust(padding+1)
43
+ f.__doc__ = f'{f.__doc__:<{padding}}[dim](aliases)[/] {aliases_str}'
44
+ base_command = super(OrderedGroup, self).command(
45
+ name, *args, **kwargs
46
+ )(f)
47
+ for alias in aliases:
48
+ cmd = super(OrderedGroup, self).command(alias, *args, hidden=True, **kwargs)(f)
49
+ cmd.help = f"Alias for '{name}'.\n\n{cmd.help}"
50
+ cmd.params = base_command.params
51
+
52
+ else:
53
+ cmd = super(OrderedGroup, self).command(*args, **kwargs)(f)
54
+
55
+ return cmd
56
+ return decorator
57
+
58
+ def group(self, *args, **kwargs):
59
+ """Behaves the same as `click.Group.group()` but supports aliases.
60
+ """
61
+ def decorator(f):
62
+ aliases = kwargs.pop('aliases', [])
63
+ aliased_group = []
64
+ if aliases:
65
+ max_width = _get_rich_console().width
66
+ aliases_str = ', '.join(f'[bold cyan]{alias}[/]' for alias in aliases)
67
+ padding = max_width // 4
68
+ f.__doc__ = f.__doc__ or '\0'.ljust(padding+1)
69
+ f.__doc__ = f'{f.__doc__:<{padding}}[dim](aliases)[/] {aliases_str}'
70
+ for alias in aliases:
71
+ grp = super(OrderedGroup, self).group(
72
+ alias, *args, hidden=True, **kwargs)(f)
73
+ aliased_group.append(grp)
74
+
75
+ # create the main group
76
+ grp = super(OrderedGroup, self).group(*args, **kwargs)(f)
77
+ grp.aliases = aliases
78
+
79
+ # for all of the aliased groups, share the main group commands
80
+ for aliased in aliased_group:
81
+ aliased.commands = grp.commands
82
+
83
+ return grp
84
+ return decorator
85
+
86
+ def list_commands(self, ctx):
87
+ return self.commands