opencos-eda 0.2.48__py3-none-any.whl → 0.2.50__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 (58) hide show
  1. opencos/__init__.py +4 -2
  2. opencos/_version.py +10 -7
  3. opencos/commands/flist.py +8 -7
  4. opencos/commands/multi.py +14 -15
  5. opencos/commands/sim.py +5 -0
  6. opencos/commands/sweep.py +3 -2
  7. opencos/deps/__init__.py +0 -0
  8. opencos/deps/defaults.py +69 -0
  9. opencos/deps/deps_commands.py +419 -0
  10. opencos/deps/deps_file.py +326 -0
  11. opencos/deps/deps_processor.py +670 -0
  12. opencos/deps_schema.py +7 -8
  13. opencos/eda.py +84 -64
  14. opencos/eda_base.py +585 -316
  15. opencos/eda_config.py +85 -14
  16. opencos/eda_config_defaults.yml +36 -4
  17. opencos/eda_extract_targets.py +22 -14
  18. opencos/eda_tool_helper.py +33 -7
  19. opencos/export_helper.py +166 -86
  20. opencos/export_json_convert.py +31 -23
  21. opencos/files.py +2 -1
  22. opencos/hw/__init__.py +0 -0
  23. opencos/{oc_cli.py → hw/oc_cli.py} +9 -4
  24. opencos/names.py +0 -4
  25. opencos/peakrdl_cleanup.py +13 -7
  26. opencos/seed.py +19 -11
  27. opencos/tests/helpers.py +3 -2
  28. opencos/tests/test_deps_helpers.py +35 -32
  29. opencos/tests/test_eda.py +36 -29
  30. opencos/tests/test_eda_elab.py +7 -4
  31. opencos/tests/test_eda_synth.py +1 -1
  32. opencos/tests/test_oc_cli.py +1 -1
  33. opencos/tests/test_tools.py +4 -2
  34. opencos/tools/iverilog.py +2 -2
  35. opencos/tools/modelsim_ase.py +24 -2
  36. opencos/tools/questa.py +5 -3
  37. opencos/tools/questa_fse.py +57 -0
  38. opencos/tools/riviera.py +1 -1
  39. opencos/tools/slang.py +9 -3
  40. opencos/tools/surelog.py +1 -1
  41. opencos/tools/verilator.py +26 -1
  42. opencos/tools/vivado.py +34 -27
  43. opencos/tools/yosys.py +4 -3
  44. opencos/util.py +532 -474
  45. opencos/utils/__init__.py +0 -0
  46. opencos/utils/markup_helpers.py +98 -0
  47. opencos/utils/str_helpers.py +111 -0
  48. opencos/utils/subprocess_helpers.py +108 -0
  49. {opencos_eda-0.2.48.dist-info → opencos_eda-0.2.50.dist-info}/METADATA +1 -1
  50. opencos_eda-0.2.50.dist-info/RECORD +89 -0
  51. {opencos_eda-0.2.48.dist-info → opencos_eda-0.2.50.dist-info}/entry_points.txt +1 -1
  52. opencos/deps_helpers.py +0 -1346
  53. opencos_eda-0.2.48.dist-info/RECORD +0 -79
  54. /opencos/{pcie.py → hw/pcie.py} +0 -0
  55. {opencos_eda-0.2.48.dist-info → opencos_eda-0.2.50.dist-info}/WHEEL +0 -0
  56. {opencos_eda-0.2.48.dist-info → opencos_eda-0.2.50.dist-info}/licenses/LICENSE +0 -0
  57. {opencos_eda-0.2.48.dist-info → opencos_eda-0.2.50.dist-info}/licenses/LICENSE.spdx +0 -0
  58. {opencos_eda-0.2.48.dist-info → opencos_eda-0.2.50.dist-info}/top_level.txt +0 -0
opencos/util.py CHANGED
@@ -1,27 +1,214 @@
1
+ '''opencos.util -- support global logging, argparser for printing (colors)'''
1
2
 
2
- # SPDX-License-Identifier: MPL-2.0
3
-
4
- import sys
5
- import subprocess
3
+ import argparse
4
+ import atexit
6
5
  import datetime
6
+ import json
7
7
  import os
8
- import time
9
- import atexit
8
+ import re
10
9
  import shutil
10
+ import subprocess
11
+ import sys
12
+ import time
11
13
  import traceback
12
- import argparse
13
- import yaml
14
- import re
15
- import textwrap
16
14
 
17
- from opencos import eda_config
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from importlib import import_module
18
+
19
+ global_exit_allowed = False # pylint: disable=invalid-name
20
+ progname = "UNKNOWN" # pylint: disable=invalid-name
21
+ progname_in_message = True # pylint: disable=invalid-name
22
+ debug_level = 0 # pylint: disable=invalid-name
23
+
24
+ args = { # pylint: disable=invalid-name
25
+ 'color' : False,
26
+ 'quiet' : False,
27
+ 'verbose' : False,
28
+ 'debug' : False,
29
+ 'fancy' : sys.stdout.isatty(),
30
+ 'warnings' : 0,
31
+ 'errors' : 0,
32
+ 'artifacts-json': True,
33
+ }
34
+
35
+ class Colors:
36
+ '''Namespace class for color printing help'''
37
+ red = "\x1B[31m"
38
+ green = "\x1B[32m"
39
+ orange = "\x1B[33m"
40
+ yellow = "\x1B[39m"
41
+ normal = "\x1B[0m"
42
+
43
+ @staticmethod
44
+ def color_text(text: str, color: str) -> str:
45
+ '''Wraps 'text' (str) with color (one of red|green|orange|yellow) prefix and
46
+
47
+ color (normal) suffix. Disables color prefix/suffix wrapping if args['color']=False
48
+ '''
49
+ if args['color']:
50
+ return color + text + "\x1B[0m" # (normal)
51
+ return text
52
+
53
+ def red_text(text: str) -> str:
54
+ '''Wraps text for printing as red, disabled if global args['color']=False'''
55
+ if args['color']:
56
+ return Colors.red + text + Colors.normal
57
+ return text
58
+
59
+ def green_text(text: str) -> str:
60
+ '''Wraps text for printing as green, disabled if global args['color']=False'''
61
+ if args['color']:
62
+ return Colors.green + text + Colors.normal
63
+ return text
64
+
65
+ def orange_text(text: str) -> str:
66
+ '''Wraps text for printing as orange, disabled if global args['color']=False'''
67
+ if args['color']:
68
+ return Colors.orange + text + Colors.normal
69
+ return text
70
+
71
+ def yellow_text(text: str) -> str:
72
+ '''Wraps text for printing as yellow, disabled if global args['color']=False'''
73
+ if args['color']:
74
+ return Colors.yellow + text + Colors.normal
75
+ return text
76
+
77
+ class ArtifactTypes(Enum):
78
+ '''Types that are allow-listed for artifacts.add* methods. If you don't use one of
79
+ these, you'll get a warning'''
80
+ TEXT = 0
81
+ JSON = 1
82
+ YAML = 2
83
+ WAVEFORM = 3
84
+ DOTF = 4
85
+ TCL = 5
86
+ SHELL = 6
87
+
88
+ class Artifacts:
89
+ '''Class to hold file artifacts, for logs generated by EDA, or other artifcats created
90
+ by a specific tool.
91
+
92
+ If enabled, will write out an artifacts.json file upon util.exit()
93
+ '''
94
+
95
+ data = {}
96
+ unresolved_data = {}
97
+ enabled = True
98
+ artifacts_json_filepath = os.path.join('eda.work', 'artifacts.json')
99
+
100
+ def _get_type_str(self, typ: str) -> (bool, str):
101
+ '''Gets the name from ArtifactTypes, and returns tuple of (True, str)
102
+
103
+ if the ArtifactTypes exists, else (False, str.upper())'''
104
+ ret = getattr(ArtifactTypes, typ.upper(), '')
105
+ if ret:
106
+ return True, ret.name.lower()
107
+ return False, typ.lower()
108
+
109
+ def add(self, name: str, typ: str, description: str = '') -> None:
110
+ '''Adds file to artifacts'''
111
+
112
+ if not self.enabled:
113
+ return # artifacts dumping disabled.
114
+
115
+ _, shortname = os.path.split(name)
116
+ if shortname in self.data:
117
+ return # artifact already registered
118
+
119
+ type_exists, type_str = self._get_type_str(typ)
120
+ if not type_exists:
121
+ warning(f'Unknown artifacts {typ=} add for {name=} {description=}')
122
+
123
+ self.data[shortname] = {
124
+ 'name': name,
125
+ 'type': type_str,
126
+ 'size': 0, # will be filled in when JSON written upon exit.
127
+ 'description': description,
128
+ }
129
+
130
+ def add_extension(
131
+ self, search_paths: list, file_extension: str, typ: str, description: str = ''
132
+ ) -> None:
133
+ '''Adds a placeholder for file extensions to add to artifacts
134
+
135
+ For example, when saving a Simulation waves, we may know it's a .fst or .vcd file,
136
+ but we do not know what the testbench SV file has set in $dumpfile(filepath)
137
+ '''
138
+ type_exists, type_str = self._get_type_str(typ)
139
+ if not type_exists:
140
+ warning(f'Unknown artifacts {typ=} add_extension for {search_paths=}',
141
+ f'{file_extension=} {description=}')
142
+
143
+ if isinstance(search_paths, str):
144
+ sp = [search_paths]
145
+ else:
146
+ sp = search_paths
147
+ self.unresolved_data[file_extension] = {
148
+ 'search_paths': sp,
149
+ 'type': type_str,
150
+ 'description': description
151
+ }
152
+
153
+ def set_artifacts_json_dir(self, dirpath: str) -> None:
154
+ '''Sets the artifacts_json_filepath'''
155
+ _, right = os.path.split(self.artifacts_json_filepath)
156
+ self.artifacts_json_filepath = os.path.join(dirpath, right)
157
+
158
+ def reset(self, enable: bool = True) -> None:
159
+ '''Clears internal data (dict). Called by util.process_tokens()'''
160
+ self.data = {}
161
+ self.unresolved_data = {}
162
+ self.enabled = enable or args['artifacts-json']
163
+ self.artifacts_json_filepath = os.path.join('eda.work', 'artifacts.json')
164
+
165
+ def _resolve_unresolved_data(self, ext: str) -> None:
166
+ '''Find files for this extension that were registered using add_extension(...)
167
+
168
+ adds to self.data'''
169
+ entry = self.unresolved_data[ext]
170
+ if not entry:
171
+ return
172
+
173
+ for search_path in entry['search_paths']:
174
+ p = Path(search_path)
175
+ for posix_filename in p.glob(f'*.{ext}'):
176
+ self.add(
177
+ name=str(posix_filename), typ=entry['type'], description=entry['description']
178
+ )
179
+
180
+ def write_json(self) -> None:
181
+ '''Write out the artifacts.json file, called by util.exit()'''
182
+
183
+ if not self.enabled:
184
+ return # artifacts dumping disabled.
185
+
186
+ if self.unresolved_data:
187
+ for ext in self.unresolved_data:
188
+ self._resolve_unresolved_data(ext)
189
+
190
+ if not self.data:
191
+ return # do nothing if we have no artifacts
192
+
193
+ # Update all file sizes:
194
+ for key, entry in self.data.items():
195
+ if os.path.isfile(entry['name']):
196
+ entry['size'] = os.path.getsize(entry['name'])
197
+ else:
198
+ # file doesn't exist, remove it from artifacts.
199
+ warning(f'Removing {key} ({entry["name"]}) from artifacts (file does not exist)')
200
+ del self.data[key]
201
+
202
+ with open(self.artifacts_json_filepath, 'w', encoding='utf-8') as f:
203
+ json.dump(self.data, f, indent=4)
204
+ info(f'Wrote artifacts JSON: {self.artifacts_json_filepath}')
205
+
206
+
207
+ artifacts = Artifacts()
18
208
 
19
- global_exit_allowed = False
20
- progname = "UNKNOWN"
21
- progname_in_message = True
22
- debug_level = 0
23
209
 
24
210
  class UtilLogger:
211
+ '''Class for the util.global_log'''
25
212
  file = None
26
213
  filepath = ''
27
214
  time_last = 0 #timestamp via time.time()
@@ -32,11 +219,13 @@ class UtilLogger:
32
219
  default_log_filepath = os.path.join('eda.work', 'eda.log')
33
220
 
34
221
  def clear(self) -> None:
222
+ '''Resets internals'''
35
223
  self.file = None
36
224
  self.filepath = ''
37
225
  self.time_last = 0
38
226
 
39
227
  def stop(self) -> None:
228
+ '''Closes open log, resets internals'''
40
229
  if self.file:
41
230
  self.write_timestamp(f'stop - {self.filepath}')
42
231
  info(f"Closing logfile: {self.filepath}")
@@ -44,8 +233,9 @@ class UtilLogger:
44
233
  self.clear()
45
234
 
46
235
  def start(self, filename: str, force: bool = False) -> None:
236
+ '''Starts (opens) log'''
47
237
  if not filename:
48
- error(f'Trying to start a logfile, but filename is missing')
238
+ error('Trying to start a logfile, but filename is missing')
49
239
  return
50
240
  if os.path.exists(filename):
51
241
  if force:
@@ -57,26 +247,35 @@ class UtilLogger:
57
247
  else:
58
248
  safe_mkdir_for_file(filename)
59
249
  try:
60
- self.file = open(filename, 'w')
250
+ self.file = open(filename, 'w', encoding='utf-8') # pylint: disable=consider-using-with
61
251
  debug(f"Opened logfile '{filename}' for writing")
62
252
  self.filepath = filename
63
253
  self.write_timestamp(f'start - {self.filepath}')
254
+ # add to global artifacts:
255
+ artifacts.add(name=filename, typ='text', description='EDA stdout logfile')
64
256
  except Exception as e:
65
257
  error(f"Error opening '{filename}' for writing, {e}")
66
258
  self.clear()
67
259
 
68
260
  def write_timestamp(self, text: str = "") -> None:
261
+ '''Writes timestamp to opened log'''
262
+ if not self.file:
263
+ return
69
264
  dt = datetime.datetime.now().ctime()
70
265
  print(f"INFO: [{progname}] Time: {dt} {text}", file=self.file)
71
266
  self.time_last = time.time()
72
267
 
73
- def write(self, text: str, end: str) -> None:
74
- sw = text.startswith(f"INFO: [{progname}]")
75
- if (((time.time() - self.time_last) > 10) and
76
- (text.startswith(f"DEBUG: [{progname}]") or
77
- text.startswith(f"INFO: [{progname}]") or
78
- text.startswith(f"WARNING: [{progname}]") or
79
- text.startswith(f"ERROR: [{progname}]"))):
268
+ def write(self, text: str, end: str = '\n') -> None:
269
+ '''Writes text to opened log'''
270
+ if not self.file:
271
+ return
272
+
273
+ if ((time.time() - self.time_last) > 10) and \
274
+ any(text.startswith(x) for x in [
275
+ f"DEBUG: [{progname}]",
276
+ f"INFO: [{progname}]",
277
+ f"WARNING: [{progname}]",
278
+ f"ERROR: [{progname}]"]):
80
279
  self.write_timestamp()
81
280
  print(text, end=end, file=self.file)
82
281
  self.file.flush()
@@ -86,134 +285,28 @@ class UtilLogger:
86
285
  global_log = UtilLogger()
87
286
 
88
287
 
89
- def start_log(filename, force=False):
288
+ def start_log(filename: str, force: bool = False) -> None:
289
+ '''Starts the global_log, if not already started'''
90
290
  global_log.start(filename=filename, force=force)
91
291
 
92
- def write_log(text, end):
292
+ def write_log(text: str, end: str = '\n') -> None:
293
+ '''Writes to the global_log, if started'''
93
294
  global_log.write(text=text, end=end)
94
295
 
95
- def stop_log():
296
+ def stop_log() -> None:
297
+ '''Stops/closed the global_log'''
96
298
  global_log.stop()
97
299
 
98
- atexit.register(stop_log)
99
-
100
-
101
- EDA_OUTPUT_CONFIG_FNAME = 'eda_output_config.yml'
102
-
103
- args = {
104
- 'color' : False,
105
- 'quiet' : False,
106
- 'verbose' : False,
107
- 'debug' : False,
108
- 'fancy' : sys.stdout.isatty(),
109
- 'warnings' : 0,
110
- 'errors' : 0,
111
- }
112
-
113
- def strip_all_quotes(s: str) -> str:
114
- return s.replace("'", '').replace('"', '')
115
-
116
- def strip_outer_quotes(s: str) -> str:
117
- ret = str(s)
118
- while (ret.startswith("'") and ret.endswith("'")) or \
119
- (ret.startswith('"') and ret.endswith('"')):
120
- ret = ret[1:-1]
121
- return ret
122
-
123
-
124
- def yaml_load_only_root_line_numbers(filepath:str):
125
- '''Returns a dict of {key: int line number}, very crude'''
126
- # Other solutions aren't as attractive, require a lot of mappers to get
127
- # line numbers on returned values that aren't dict
128
- data = None
129
- with open(filepath) as f:
130
- try:
131
- # Try to do a very lazy parse of root level keys only, returns dict{key:lineno}
132
- data = dict()
133
- for lineno,line in enumerate(f.readlines()):
134
- m = re.match(r'^(\w+):', line)
135
- if m:
136
- key = m.group(1)
137
- data[key] = lineno + 1
138
- except Exception as e:
139
- error(f"Error loading YAML {filepath=}:", e)
140
- return data
141
-
142
-
143
- def toml_load_only_root_line_numbers(filepath:str):
144
- '''Returns a dict of {key: int line number}, very crude'''
145
- data = None
146
- with open(filepath) as f:
147
- try:
148
- data = dict()
149
- for lineno, line in enumerate(f.readlines()):
150
- m = re.match(r'^\[(\w+)\]', line)
151
- if m:
152
- key = m.group(1)
153
- data[key] = lineno + 1
154
- except Exception as e:
155
- error(f'Error loading TOML {filepath=}', e)
156
- return data
157
300
 
301
+ atexit.register(stop_log)
158
302
 
159
- def yaml_safe_load(filepath:str, only_root_line_numbers=False):
160
- '''Returns dict or None from filepath (str), errors if return type not in assert_return_types.
161
303
 
162
- (assert_return_types can be empty list to avoid check.)
304
+ def get_argparse_bool_action_kwargs() -> dict:
305
+ '''Returns args for BooleanOptionalAction kwargs for an ArgumentParser.add_argument
163
306
 
164
- only_root_line_numbers -- if True, will return a dict of {key: line number (int)} for
165
- all the root level keys. Used for debugging DEPS.yml in
166
- eda.CommandDesign.resolve_target_core
307
+ This is mostly for python compatibility to support --some-enable and --no-some-enable
167
308
  '''
168
-
169
- data = None
170
-
171
- if only_root_line_numbers:
172
- return yaml_load_only_root_line_numbers(filepath)
173
-
174
- with open(filepath) as f:
175
- debug(f'Opening {filepath=}')
176
- try:
177
- data = yaml.safe_load(f)
178
- except yaml.YAMLError as e:
179
-
180
- # if yamllint is installed, then use it to get all errors in the .yml|.yaml
181
- # file, instead of the single exception.
182
- if shutil.which('yamllint'):
183
- try:
184
- sp_out = subprocess.run(
185
- f'yamllint -d relaxed --no-warnings {filepath}'.split(),
186
- capture_output=True, text=True )
187
- for x in sp_out.stdout.split('\n'):
188
- if x:
189
- info('yamllint: ' + x)
190
- except:
191
- pass
192
-
193
- if hasattr(e, 'problem_mark'):
194
- mark = e.problem_mark
195
- error(f"Error parsing {filepath=}: line {mark.line + 1},",
196
- f"column {mark.column +1}: {e.problem}")
197
- else:
198
- error(f"Error loading YAML {filepath=}:", e)
199
- except Exception as e:
200
- error(f"Error loading YAML {filepath=}:", e)
201
-
202
- return data
203
-
204
-
205
- def yaml_safe_writer(data:dict, filepath:str) -> None:
206
-
207
- if filepath.endswith('.yml') or filepath.endswith('.yaml'):
208
- with open(filepath, 'w', encoding='utf-8') as f:
209
- yaml.dump(data, f, allow_unicode=True,
210
- default_flow_style=False, sort_keys=False, encoding=('utf-8'))
211
- else:
212
- warning(f'{filepath=} to be written for this extension not implemented.')
213
-
214
-
215
- def get_argparse_bool_action_kwargs() -> dict:
216
- bool_kwargs = dict()
309
+ bool_kwargs = {}
217
310
  x = getattr(argparse, 'BooleanOptionalAction', None)
218
311
  if x is not None:
219
312
  bool_kwargs['action'] = x
@@ -221,8 +314,12 @@ def get_argparse_bool_action_kwargs() -> dict:
221
314
  bool_kwargs['action'] = 'store_true'
222
315
  return bool_kwargs
223
316
 
317
+
224
318
  def get_argparser() -> argparse.ArgumentParser:
225
- parser = argparse.ArgumentParser(prog='opencos common options', add_help=False, allow_abbrev=False)
319
+ '''Returns the opencos.util ArgumentParser'''
320
+ parser = argparse.ArgumentParser(
321
+ prog='opencos common options', add_help=False, allow_abbrev=False
322
+ )
226
323
  # We set allow_abbrev=False so --force-logfile won't try to attempt parsing shorter similarly
227
324
  # named args like --force, we want those to go to unparsed list.
228
325
  # For bools, support --color and --no-color with this action=argparse.BooleanOptionalAction
@@ -254,9 +351,13 @@ def get_argparser() -> argparse.ArgumentParser:
254
351
  parser.add_argument('--no-respawn', action='store_true',
255
352
  help=('Legacy mode (default respawn disabled) for respawning eda.py'
256
353
  ' using $OC_ROOT/bin'))
354
+ parser.add_argument('--artifacts-json', **bool_action_kwargs,
355
+ help='Store a work-dir/artifacts.json file of known tool output files')
257
356
  return parser
258
357
 
259
- def get_argparser_short_help(parser=None) -> str:
358
+
359
+ def get_argparser_short_help(parser: object = None) -> str:
360
+ '''Returns short help for our ArgumentParser'''
260
361
  if not parser:
261
362
  parser = get_argparser()
262
363
  full_lines = parser.format_help().split('\n')
@@ -268,18 +369,25 @@ def get_argparser_short_help(parser=None) -> str:
268
369
  return f'{parser.prog}:\n' + '\n'.join(full_lines[lineno + 1:])
269
370
 
270
371
 
271
- def process_token(arg:str) -> bool:
272
- # This is legacy holdover for oc_cli.py, that would process one token at a time.
273
- # Simply run through our full argparser.
274
- parsed, unparsed = process_tokens(tokens[arg])
275
- if len(unparsed) == 0:
276
- debug(f"Processed command: {arg}")
372
+ def process_token(arg: list) -> bool:
373
+ '''Returns true if we parsed arg (list, because we may pass --arg value)
374
+
375
+ This is legacy holdover for oc_cli.py, that would process one token at a time.
376
+ Simply run through our full argparser.
377
+ '''
378
+ _, unparsed = process_tokens(arg)
379
+ if not unparsed: # empty list
380
+ debug(f"Processed token or pair: {arg}")
277
381
  return True
278
382
  return False
279
383
 
280
384
 
281
385
  def process_tokens(tokens:list) -> (argparse.Namespace, list):
282
- global debug_level
386
+ '''Processes tokens (unparsed args list) on util's ArgumentParser
387
+
388
+ Returns tuple of (parsed Namespace, unparsed args list)
389
+ '''
390
+ global debug_level # pylint: disable=global-statement
283
391
 
284
392
  parser = get_argparser()
285
393
  try:
@@ -288,256 +396,328 @@ def process_tokens(tokens:list) -> (argparse.Namespace, list):
288
396
  except argparse.ArgumentError:
289
397
  error(f'problem attempting to parse_known_args for {tokens=}')
290
398
 
291
- if parsed.debug_level: set_debug_level(parsed.debug_level)
292
- elif parsed.debug: set_debug_level(1)
293
- else: debug_level = 0
399
+ if parsed.debug_level:
400
+ set_debug_level(parsed.debug_level)
401
+ elif parsed.debug:
402
+ set_debug_level(1)
403
+ else:
404
+ debug_level = 0
294
405
 
295
406
  debug(f'util.process_tokens: {parsed=} {unparsed=} from {tokens=}')
296
407
 
408
+ # clear existing artifacts dicts (mostly for pytests repeatedly calling eda.main),
409
+ # set artifacts.enabled based on args['artifacts-json']
410
+ artifacts.reset(enable=parsed.artifacts_json)
411
+
297
412
  if parsed.force_logfile:
298
413
  start_log(parsed.force_logfile, force=True)
299
414
  elif parsed.logfile:
300
415
  start_log(parsed.logfile, force=False)
301
416
  elif parsed.default_log and \
417
+ not any(x in unparsed for x in ('help', '-h', '--help')) and \
302
418
  (parsed.force_logfile is None and parsed.logfile is None):
303
419
  # Use a forced logfile in the eda.work/eda.log:
420
+ # avoid this if someone has --help arg not yet parsed.
304
421
  start_log(global_log.default_log_filepath, force=True)
305
422
 
306
423
 
307
424
  parsed_as_dict = vars(parsed)
308
425
  for key,value in parsed_as_dict.items():
426
+ key = key.replace('_', '-')
309
427
  if value is not None:
310
428
  args[key] = value # only update with non-None values to our global 'args' dict
311
- return parsed, unparsed
312
429
 
313
- def indent_wrap_long_text(text, width=80, initial_indent=0, indent=4):
314
- """Returns str, wraps text to a specified width and indents subsequent lines."""
315
- wrapped_lines = textwrap.wrap(text, width=width,
316
- initial_indent=' ' * initial_indent,
317
- subsequent_indent=' ' * indent)
318
- return '\n'.join(wrapped_lines)
430
+
431
+ return parsed, unparsed
319
432
 
320
433
  # ********************
321
434
  # fancy support
322
- # In fancy mode, we take the bottom fancy_lines_ lines of the screen to be written using fancy_print,
323
- # while the lines above that show regular scrolling content (via info, debug, warning, error above).
324
- # User should not use print() when in fancy mode
325
-
326
- fancy_lines_ = []
327
-
328
- def fancy_start(fancy_lines = 4, min_vanilla_lines = 4):
329
- global fancy_lines_
330
- (columns,lines) = shutil.get_terminal_size()
331
- if (fancy_lines < 2):
332
- error(f"Fancy mode requires at least 2 fancy lines")
333
- if (fancy_lines > (lines-min_vanilla_lines)):
334
- error(f"Fancy mode supports at most {(lines-min_vanilla_lines)} fancy lines, given {min_vanilla_lines} non-fancy lines")
335
- if len(fancy_lines_): error(f"We are already in fancy line mode??")
336
- for _ in range(fancy_lines-1):
337
- print("") # create the requisite number of blank lines
338
- fancy_lines_.append("")
435
+ # In fancy mode, we take the bottom _FANCY_LINES lines of the screen to be written using
436
+ # fancy_print, while the lines above that show regular scrolling content (via info, debug, warning,
437
+ # error above). User should not use print() when in fancy mode
438
+
439
+ _FANCY_LINES = []
440
+
441
+ def fancy_start(fancy_lines: int = 4, min_vanilla_lines: int = 4) -> None:
442
+ '''Starts fancy line support. This is not called by util internally
443
+
444
+ It is called by an opencos.eda Command handling class, it can check if util.args['fancy']
445
+ is set
446
+ '''
447
+ _, lines = shutil.get_terminal_size()
448
+ if fancy_lines < 2:
449
+ error("Fancy mode requires at least 2 fancy lines")
450
+ if fancy_lines > (lines - min_vanilla_lines):
451
+ error(f"Fancy mode supports at most {(lines - min_vanilla_lines)} fancy lines, given",
452
+ f"{min_vanilla_lines} non-fancy lines")
453
+ if _FANCY_LINES:
454
+ error("We are already in fancy line mode, cannot call fancy_start() again")
455
+ for _ in range(fancy_lines - 1):
456
+ print() # create the requisite number of blank lines
457
+ _FANCY_LINES.append("")
339
458
  print("", end="") # the last line has no "\n" because we don't want ANOTHER blank line below
340
- fancy_lines_.append("")
459
+ _FANCY_LINES.append("")
341
460
  # the cursor remains at the leftmost character of the bottom line of the screen
342
461
 
343
- def fancy_stop():
344
- global fancy_lines_
345
- if len(fancy_lines_): # don't do anything if we aren't in fancy mode
346
- # user is expected to have painted something into the fancy lines, we can't "pull down" the regular
347
- # lines above, and we don't want fancy_lines_ blank or garbage lines either, that's not pretty
348
- fancy_lines_ = []
349
- # since cursor is always left at the leftmost character of the bottom line of the screen, which was
350
- # one of the fancy lines which now has the above-mentioned "something", we want to move one lower
351
- print("")
352
-
353
- def fancy_print(text, line):
354
- global fancy_lines_
462
+ def fancy_stop() -> None:
463
+ '''Stops fancy mode. Intended to be called by an opencos.eda Command handling class'''
464
+ if not _FANCY_LINES:
465
+ # don't do anything if we aren't in fancy mode.
466
+ return
467
+
468
+ # user is expected to have painted something into the fancy lines, we can't "pull down"
469
+ # the regular lines above, and we don't want _FANCY_LINES blank or garbage lines either,
470
+ # that's not pretty
471
+ _FANCY_LINES.clear()
472
+ # since cursor is always left at the leftmost character of the bottom line of the screen,
473
+ # which was one of the fancy lines which now has the above-mentioned "something", we want
474
+ # to move one lower
475
+ print()
476
+
477
+
478
+ def fancy_print(text: str, line: int) -> None:
479
+ '''Fancy print, intended to be called by an opencos.eda Command handling class'''
480
+
355
481
  # strip any newline, we don't want to print that
356
- if text.endswith("\n"): text.rstrip()
357
- lines_above = len(fancy_lines_) - line - 1
482
+ text = text.rstrip("\n")
483
+ lines_above = len(_FANCY_LINES) - line - 1
358
484
  if lines_above:
359
- print(f"\033[{lines_above}A"+ # move cursor up
360
- text+f"\033[1G"+ # desired text, then move cursor to the first character of the line
361
- f"\033[{lines_above}B", # move the cursor down
362
- end="", flush=True)
485
+ print(
486
+ (f"\033[{lines_above}A" # move cursor up
487
+ f"{text}\033[1G" # desired text, then move cursor to the first character of the line
488
+ f"\033[{lines_above}B" # move the cursor down
489
+ ),
490
+ end="", flush=True
491
+ )
363
492
  else:
364
- print(text+f"\033[1G", # desired text, then move cursor to the first character of the line
365
- end="", flush=True)
366
- fancy_lines_[line] = text
367
-
368
- def print_pre():
493
+ print(
494
+ f"{text}\033[1G", # desired text, then move cursor to the first character of the line
495
+ end="", flush=True
496
+ )
497
+ _FANCY_LINES[line] = text
498
+
499
+ def print_pre() -> None:
500
+ '''called by all util info/warning/debug/error. Handles fancy mode'''
369
501
  # stuff we do before printing any line
370
- if len(fancy_lines_):
371
- # Also, note that in fancy mode we don't allow the "above lines" to be partially written, they
372
- # are assumed to be full lines ending in "\n"
373
- # As always, we expect the cursor was left in the leftmost character of bottom line of screen
374
- print(f"\033[{len(fancy_lines_)-1}A"+ # move the cursor up to where the first fancy line is drawn
375
- f"\033[0K", # clear the old fancy line 0
376
- end="",flush=True)
377
-
378
- def print_post(text, end):
502
+ if not _FANCY_LINES:
503
+ return
504
+
505
+ # Also, note that in fancy mode we don't allow the "above lines" to be partially written, they
506
+ # are assumed to be full lines ending in "\n"
507
+ # As always, we expect the cursor was left in the leftmost character of bottom line of screen
508
+ print(
509
+ (f"\033[{len(_FANCY_LINES)-1}A" # move the cursor up to where the first fancy line is drawn
510
+ f"\033[0K" # clear the old fancy line 0
511
+ ),
512
+ end="", flush=True
513
+ )
514
+
515
+ def print_post(text: str, end: str) -> None:
516
+ '''called by all util info/warning/debug/error. Handles fancy mode'''
379
517
  # stuff we do after printing any line
380
- if len(fancy_lines_):
381
- #time.sleep(1)
382
- # we just printed a line, including a new line, on top of where fancy line 0 used to be, so cursor
383
- # is now at the start of fancy line 1.
384
- # move cursor down to the beginning of the final fancy line (i.e. standard fancy cursor resting place)
385
- for x in range(len(fancy_lines_)):
386
- print("\033[0K",end="") # erase the line to the right
387
- print(fancy_lines_[x],flush=True,end=('' if x==(len(fancy_lines_)-1) else '\n'))
388
- #time.sleep(1)
518
+ if _FANCY_LINES:
519
+ # we just printed a line, including a new line, on top of where fancy line 0 used to be,
520
+ # so cursor is now at the start of fancy line 1. move cursor down to the beginning of the
521
+ # final fancy line (i.e. standard fancy cursor resting place)
522
+ for x, line in enumerate(_FANCY_LINES):
523
+ print("\033[0K", end="") # erase the line to the right
524
+ print(line, flush=True,
525
+ end=('' if x == (len(_FANCY_LINES) - 1) else '\n'))
526
+
389
527
  print("\033[1G", end="", flush=True)
390
- if global_log.file: write_log(text, end=end)
528
+ if global_log.file:
529
+ write_log(text, end=end)
391
530
 
392
- string_red = f"\x1B[31m"
393
- string_green = f"\x1B[32m"
394
- string_orange = f"\x1B[33m"
395
- string_yellow = f"\x1B[39m"
396
- string_normal = f"\x1B[0m"
397
531
 
398
- def print_red(text, end='\n'):
532
+ def print_color(text: str, color: str, end: str = '\n') -> None:
533
+ '''Note that color(str) must be one of Colors.[red|green|orange|yellow|normal]'''
399
534
  print_pre()
400
- print(f"{string_red}{text}{string_normal}" if args['color'] else f"{text}", end=end, flush=True)
535
+ print(Colors.color_text(text, color), end=end, flush=True)
401
536
  print_post(text, end)
402
537
 
403
- def print_green(text, end='\n'):
538
+ def print_red(text: str, end: str = '\n') -> None:
539
+ '''Print text as red, goes back to normal color'''
404
540
  print_pre()
405
- print(f"{string_green}{text}{string_normal}" if args['color'] else f"{text}", end=end, flush=True)
541
+ print(red_text(text), end=end, flush=True)
406
542
  print_post(text, end)
407
543
 
408
- def print_orange(text, end='\n'):
544
+ def print_green(text: str, end: str = '\n') -> None:
545
+ '''Print text as green, goes back to normal color'''
409
546
  print_pre()
410
- print(f"{string_orange}{text}{string_normal}" if args['color'] else f"{text}", end=end, flush=True)
547
+ print(green_text(text), end=end, flush=True)
411
548
  print_post(text, end)
412
549
 
413
- def print_yellow(text, end='\n'):
550
+ def print_orange(text: str, end: str = '\n') -> None:
551
+ '''Print text as orange, goes back to normal color'''
414
552
  print_pre()
415
- print(f"{string_yellow}{text}{string_normal}" if args['color'] else f"{text}", end=end, flush=True)
553
+ print(orange_text(text), end=end, flush=True)
416
554
  print_post(text, end)
417
555
 
418
- def set_debug_level(level):
419
- global debug_level
556
+ def print_yellow(text: str, end: str = '\n') -> None:
557
+ '''Print text as yellow, goes back to normal color'''
558
+ print_pre()
559
+ print(yellow_text(text), end=end, flush=True)
560
+ print_post(text, end)
561
+
562
+
563
+ def set_debug_level(level) -> None:
564
+ '''Sets global debug level, sets args['debug'] and args['verbose']'''
565
+ global debug_level # pylint: disable=global-statement
420
566
  debug_level = level
421
- args['debug'] = (level > 0)
422
- args['verbose'] = (level > 1)
567
+ args['debug'] = level > 0
568
+ args['verbose'] = level > 1
423
569
  info(f"Set debug level to {debug_level}")
424
570
 
425
- # the <<d>> stuff is because we change progname after this is read in. if we instead infer progname or
426
- # get it passed somehow, we can avoid this ugliness / performance impact (lots of calls to debug happen)
427
- def debug(*text, level=1, start='<<d>>', end='\n'):
428
- if start=='<<d>>': start = f"DEBUG: " + (f"[{progname}] " if progname_in_message else "")
429
- if args['debug'] and (((level==1) and args['verbose']) or (debug_level >= level)):
571
+
572
+ def debug(*text, level: int = 1, start: object = None, end: str = '\n') -> None:
573
+ '''Print debug messaging (in yellow if possible). If args['debug'] is false, prints nothing.
574
+
575
+ *text: (positional str args) to be printed
576
+ level: (int) debug level to decide if printed or not.
577
+ start: (optional str) prefix to message; if None: chooses default start str
578
+ end: (optional str) suffix to print
579
+
580
+ Note these messages append to global logging (but require args['debug'] to be set)
581
+ '''
582
+ if start is None:
583
+ start = "DEBUG: " + (f"[{progname}] " if progname_in_message else "")
584
+ if args['debug'] and \
585
+ (((level==1) and args['verbose']) or (debug_level >= level)):
430
586
  print_yellow(f"{start}{' '.join(list(text))}", end=end)
431
587
 
432
- def info(*text, start='<<d>>', end='\n'):
433
- if start=='<<d>>': start = f"INFO: " + (f"[{progname}] " if progname_in_message else "")
588
+
589
+ def info(*text, start: object = None, end='\n') -> None:
590
+ '''Print information messaging (in green if possible). If args['quiet'], prints nothing.
591
+
592
+ *text: (positional str args) to be printed
593
+ start: (optional str) prefix to message; if None: chooses default start str
594
+ end: (optional str) suffix to print
595
+
596
+ Note these messages append to global logging even if args['quiet'] is set
597
+ '''
598
+ if start is None:
599
+ start = "INFO: " + (f"[{progname}] " if progname_in_message else "")
434
600
  if not args['quiet']:
435
601
  print_green(f"{start}{' '.join(list(text))}", end=end)
436
602
 
437
- def warning(*text, start='<<d>>', end='\n'):
438
- if start=='<<d>>': start = f"WARNING: " + (f"[{progname}] " if progname_in_message else "")
603
+ def warning(*text, start: object = None, end: str = '\n') -> None:
604
+ '''Print warning messaging (in orange if possible).
605
+
606
+ *text: (positional str args) to be printed
607
+ start: (optional str) prefix to message; if None: chooses default start str
608
+ end: (optional str) suffix to print
609
+
610
+ Note these messages append to global logging. Increments global args['warnings'] int.
611
+ '''
612
+ if start is None:
613
+ start = "WARNING: " + (f"[{progname}] " if progname_in_message else "")
439
614
  args['warnings'] += 1
440
615
  print_orange(f"{start}{' '.join(list(text))}", end=end)
441
616
 
442
- def error(*text, error_code=-1, do_exit=True, start='<<d>>', end='\n') -> int:
443
- if start=='<<d>>': start = f"ERROR: " + (f"[{progname}] " if progname_in_message else "")
617
+
618
+ def error(
619
+ *text, error_code: int = 255, do_exit: bool = True, start: object = None, end: str = '\n'
620
+ ) -> int:
621
+ '''Print error messaging (in red if possible).
622
+
623
+ *text: (positional str args) to be printed
624
+ error_code: (int) shell style return code (non-zero is error, but prefer > 1 b/c those are
625
+ python exceptions)
626
+ do_exit: (bool) if True will call exit based on global_exit_allowed.
627
+ start: (optional str) prefix to message; if None: chooses default start str
628
+ end: (optional str) suffix to print
629
+
630
+ Note these messages append to global logging. Increments global args['errors'] int.
631
+ '''
632
+ if start is None:
633
+ start = "ERROR: " + (f"[{progname}] " if progname_in_message else "")
444
634
  args['errors'] += 1
445
635
  print_red(f"{start}{' '.join(list(text))}", end=end)
446
636
  if do_exit:
447
- if args['debug']: print(traceback.print_stack())
448
- return exit(error_code)
449
- else:
450
- if error_code is None:
451
- return 0
452
- else:
453
- return abs(int(error_code))
637
+ if args['debug']:
638
+ print(traceback.print_stack())
639
+ # Call our overriden-builtin function for 'exit':
640
+ return exit(error_code) # pylint: disable=consider-using-sys-exit
641
+
642
+ if error_code is None:
643
+ return 0
644
+ return abs(int(error_code))
645
+
646
+
647
+ def exit( # pylint: disable=redefined-builtin
648
+ error_code: int = 0, quiet: bool = False
649
+ ) -> int:
650
+ '''sys.exit(int) wrapper, returns the error_code if global_exit_allowed=False'''
651
+
652
+ # Save out artifacts file:
653
+ artifacts.write_json()
454
654
 
455
- def exit(error_code=0, quiet=False):
456
655
  if global_exit_allowed:
457
- if not quiet: info(f"Exiting with {args['warnings']} warnings, {args['errors']} errors")
656
+ if not quiet:
657
+ info(f"Exiting with {args['warnings']} warnings, {args['errors']} errors")
458
658
  sys.exit(error_code)
459
659
 
460
660
  if error_code is None:
461
661
  return 0
462
- else:
463
- return abs(int(error_code))
662
+
663
+ return abs(int(error_code))
664
+
464
665
 
465
666
  def getcwd():
667
+ '''Wrapper for os.getcwd() for current working dir'''
466
668
  try:
467
- cc = os.getcwd()
669
+ return os.getcwd()
468
670
  except Exception as e:
469
- error("Unable to getcwd(), did it get deleted from under us?")
470
- return cc
471
-
472
- _oc_root=None
473
- _oc_root_set=False
474
- def get_oc_root(error_on_fail:bool=False):
475
- global _oc_root
476
- global _oc_root_set
671
+ error(f"Unable to getcwd(), did it get deleted from under us? Exception: {e}")
672
+ return None
673
+
674
+ _OC_ROOT = None
675
+ _OC_ROOT_SET = False
676
+
677
+ def get_oc_root(error_on_fail: bool = False) -> None:
477
678
  '''Returns a str or False for the root directory of *this* repo.
478
679
 
479
680
  If environment variable OC_ROOT is set, that is used instead, otherwise attempts to use
480
681
  `git rev-parse`
481
682
  '''
683
+
684
+ global _OC_ROOT # pylint: disable=global-statement
685
+ global _OC_ROOT_SET # pylint: disable=global-statement
482
686
  # if we've already run through here once, just return the memorized result
483
- if _oc_root_set: return _oc_root
687
+ if _OC_ROOT_SET:
688
+ return _OC_ROOT
484
689
 
485
690
  # try looking for an env var
486
691
  s = os.environ.get('OC_ROOT')
487
692
  if s:
488
693
  debug(f'get_oc_root() -- returning from env: {s=}')
489
- _oc_root = s.strip()
694
+ _OC_ROOT = s.strip()
490
695
  else:
491
696
  # try asking GIT
492
- cp = subprocess.run('git rev-parse --show-toplevel', stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
493
- shell=True, universal_newlines=True)
697
+ cp = subprocess.run(
698
+ 'git rev-parse --show-toplevel', stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
699
+ shell=True, check=False, universal_newlines=True
700
+ )
494
701
  if cp.returncode != 0:
495
- # TODO(drew): at some point, address the fact that not all repos are oc_root. Is this function asking for
496
- # the repo we are in? or a pointer to the oc_root which maybe elsewhere on the system?
702
+ # TODO(drew): at some point, address the fact that not all repos are oc_root.
703
+ # Is this function asking for the repo we are in? or a pointer to the oc_root which
704
+ # maybe elsewhere on the system?
497
705
  print_didnt_find_it = debug
498
706
  if error_on_fail:
499
707
  print_didnt_find_it = error
500
- print_didnt_find_it(f'Unable to get a OC_ROOT directory using git rev-parse')
708
+ print_didnt_find_it('Unable to get a OC_ROOT directory using git rev-parse')
501
709
  else:
502
- _oc_root = cp.stdout.strip()
503
- if sys.platform == 'win32':
504
- _oc_root = _oc_root.replace('/', '\\') # git gives us /, but we need \
710
+ _OC_ROOT = cp.stdout.strip()
711
+ if sys.platform.startswith('win'):
712
+ _OC_ROOT = _OC_ROOT.replace('/', '\\') # git gives us /, but we need \
505
713
 
506
714
  # there is no sense running through this code more than once
507
- _oc_root_set = True
508
- return _oc_root
715
+ _OC_ROOT_SET = True
716
+ return _OC_ROOT
509
717
 
510
- def string_or_space(text, whitespace=False):
511
- if whitespace:
512
- return " " * len(text)
513
- else:
514
- return text
515
718
 
516
- def sprint_time(s):
517
- s = int(s)
518
- txt = ""
519
- do_all = False
520
- # days
521
- if (s >= (24*60*60)): # greater than 24h, we show days
522
- d = int(s/(24*60*60))
523
- txt += f"{d}d:"
524
- s -= (d*24*60*60)
525
- do_all = True
526
- # hours
527
- if do_all or (s >= (60*60)):
528
- d = int(s/(60*60))
529
- txt += f"{d:2}:"
530
- s -= (d*60*60)
531
- do_all = True
532
- # minutes
533
- d = int(s/(60))
534
- txt += f"{d:02}:"
535
- s -= (d*60)
536
- # seconds
537
- txt += f"{s:02}"
538
- return txt
539
-
540
- def safe_cp(source:str, destination:str, create_dirs:bool=False):
719
+ def safe_cp(source: str, destination: str, create_dirs: bool = False) -> None:
720
+ '''shutil.copy2 wrapper to optionally make the destination directories'''
541
721
  try:
542
722
  # Infer if destination is a directory
543
723
  if destination.endswith('/') or os.path.isdir(destination):
@@ -551,51 +731,45 @@ def safe_cp(source:str, destination:str, create_dirs:bool=False):
551
731
  os.makedirs(parent_dir, exist_ok=True)
552
732
  # actually copy the file
553
733
  shutil.copy2(source, destination)
734
+ info(f"Copied {source} to {destination}")
554
735
  except Exception as e:
555
736
  print(f"Error copying file from '{source}' to '{destination}': {e}")
556
- info(f"Copied {source} to {destination}")
557
737
 
558
- def safe_rmdir(path):
559
- """Safely and reliably remove a non-empty directory."""
560
- try:
561
- # Ensure the path exists
562
- if os.path.exists(path):
563
- shutil.rmtree(path)
564
- info(f"Directory '{path}' has been removed successfully.")
565
- else:
566
- debug(f"Directory '{path}' does not exist.")
567
- except Exception as e:
568
- error(f"An error occurred while removing the directory '{path}': {e}")
569
738
 
570
- def safe_mkdir(path : str):
739
+ def safe_mkdir(path : str) -> None:
740
+ '''Attempt to make dir at path, and make all subdirs up to that path'''
571
741
  if os.path.exists(path):
572
742
  return
573
- left, right = os.path.split(os.path.relpath(path))
743
+ left, _ = os.path.split(os.path.relpath(path))
574
744
  if left and left not in ['.', '..', os.path.sep]:
575
745
  safe_mkdir(left)
576
746
  try:
577
747
  os.mkdir(path)
578
748
  except FileExistsError:
579
749
  pass
580
- except:
750
+ except Exception as e1:
581
751
  try:
582
752
  os.system(f'mkdir -p {path}')
583
- except Exception as e:
584
- error(f'unable to mkdir {path=}, exception {e=}')
753
+ except Exception as e2:
754
+ error(f'unable to mkdir {path=}, exceptions {e1}, {e2=}')
755
+
585
756
 
586
- def safe_mkdirs(base : str, new_dirs : list):
757
+ def safe_mkdirs(base : str, new_dirs : list) -> None:
758
+ '''Create new_dirs at base'''
587
759
  for p in new_dirs:
588
760
  safe_mkdir( os.path.join(base, p) )
589
761
 
590
- def safe_mkdir_for_file(filepath: str):
591
- left, right = os.path.split(filepath)
762
+
763
+ def safe_mkdir_for_file(filepath: str) -> None:
764
+ '''Given a new filepath, create dir for that filepath'''
765
+ left, _ = os.path.split(filepath)
592
766
  if left:
593
767
  safe_mkdir(left)
594
768
 
595
769
 
596
- def import_class_from_string(full_class_name):
770
+ def import_class_from_string(full_class_name: str) -> None:
597
771
  """
598
- Imports a class given its full name as a string.
772
+ Imports a class given its full name as a str.
599
773
 
600
774
  Args:
601
775
  full_class_name: The full name of the class,
@@ -604,18 +778,21 @@ def import_class_from_string(full_class_name):
604
778
  Returns:
605
779
  The imported class, or None if an error occurs.
606
780
  """
607
- from importlib import import_module
608
781
  try:
609
782
  module_path, class_name = full_class_name.rsplit(".", 1)
610
783
  module = import_module(module_path)
611
784
  return getattr(module, class_name)
612
- except (ImportError, AttributeError) as e:
613
- print(f"Error importing class {full_class_name=}: {e=}")
785
+ except Exception as e:
786
+ print(f"Error importing class {full_class_name=}: {e}")
614
787
  return None
615
788
 
616
789
 
617
790
  class ShellCommandList(list):
618
- def __init__(self, obj=None, tee_fpath=None):
791
+ '''Wrapper around a list, of str that we'll run as a subprocess command
792
+
793
+ included member var for tee_path, to save a log from this subprocess commands list
794
+ '''
795
+ def __init__(self, obj: object = None, tee_fpath: str = ''):
619
796
  super().__init__(obj)
620
797
  for k in ['tee_fpath']:
621
798
  setattr(self, k, getattr(obj, k, None))
@@ -623,29 +800,35 @@ class ShellCommandList(list):
623
800
  self.tee_fpath = tee_fpath
624
801
 
625
802
 
626
- def write_shell_command_file(dirpath : str, filename : str, command_lists : list, line_breaks : bool = False):
627
- ''' Writes new file at {dirpath}/{filename} as a bash shell command, using command_lists (list of lists)
803
+ def write_shell_command_file(
804
+ dirpath : str, filename : str, command_lists : list, line_breaks : bool = False
805
+ ) -> None:
806
+ ''' Writes new file at {dirpath}/{filename} as a bash shell command, using command_lists
807
+ (list of lists)
628
808
 
629
809
  -- dirpath (str) -- directory where file is written (usually eda.work/{target}_sim
630
810
  -- filename (str) -- filename, for example compile_only.sh
631
- -- command_lists (list) -- list of (list or ShellCommandList), each item in the list is a list of commands (aka, how
632
- subprocess.run(args) uses a list of commands.
633
- -- line_breaks (bool) -- Set to True to have 1 word per line in the file followed by a line break.
634
- Default False has an entry in command_lists all on a single line.
811
+ -- command_lists (list) -- list of (list or ShellCommandList), each item in the list is a
812
+ list of commands (aka, how subprocess.run(args) uses a list of
813
+ commands.
814
+ -- line_breaks (bool) -- Set to True to have 1 word per line in the file followed by a line
815
+ break. Default False has an entry in command_lists all on a single
816
+ line.
635
817
 
636
818
  Returns None, writes the file and chmod's it to 0x755.
637
-
638
819
  '''
820
+
639
821
  # command_lists should be a list-of-lists.
640
822
  bash_path = shutil.which('bash')
641
- assert type(command_lists) is list, f'{command_lists=}'
823
+ assert isinstance(command_lists, list), f'{command_lists=}'
642
824
  fullpath = os.path.join(dirpath, filename)
643
- with open(fullpath, 'w') as f:
825
+ with open(fullpath, 'w', encoding='utf-8') as f:
644
826
  if not bash_path:
645
827
  bash_path = "/bin/bash" # we may not get far, but we'll try
646
828
  f.write('#!' + bash_path + '\n\n')
647
829
  for obj in command_lists:
648
- assert isinstance(obj, list), f'{obj=} (obj must be list/ShellCommandList) {command_lists=}'
830
+ assert isinstance(obj, list), \
831
+ f'{obj=} (obj must be list/ShellCommandList) {command_lists=}'
649
832
  clist = list(obj).copy()
650
833
  tee_fpath = getattr(obj, 'tee_fpath', None)
651
834
  if tee_fpath:
@@ -657,7 +840,7 @@ def write_shell_command_file(dirpath : str, filename : str, command_lists : list
657
840
  else:
658
841
  clist.append(f'2>&1 | tee {tee_fpath}')
659
842
 
660
- if len(clist) > 0:
843
+ if clist:
661
844
  if line_breaks:
662
845
  # line_breaks=True - have 1 word per line, followed by \ and \n
663
846
  sep = " \\" + "\n"
@@ -674,26 +857,6 @@ def write_shell_command_file(dirpath : str, filename : str, command_lists : list
674
857
  os.chmod(fullpath, 0o755)
675
858
 
676
859
 
677
- def write_eda_config_and_args(dirpath : str, filename=EDA_OUTPUT_CONFIG_FNAME, command_obj_ref=None):
678
- import copy
679
- if command_obj_ref is None:
680
- return
681
- fullpath = os.path.join(dirpath, filename)
682
- data = dict()
683
- for x in ['command_name', 'config', 'target', 'args', 'modified_args', 'defines',
684
- 'incdirs', 'files_v', 'files_sv', 'files_vhd']:
685
- # Use deep copy b/c otherwise these are references to opencos.eda.
686
- data[x] = copy.deepcopy(getattr(command_obj_ref, x, ''))
687
-
688
- # fix some burried class references in command_obj_ref.config,
689
- # otherwise we won't be able to safe load this yaml, so cast as str repr.
690
- for k, v in command_obj_ref.config.items():
691
- if k == 'command_handler':
692
- data['config'][k] = str(v)
693
-
694
- yaml_safe_writer(data=data, filepath=fullpath)
695
-
696
-
697
860
  def get_inferred_top_module_name(module_guess: str, module_fpath: str) -> str:
698
861
  '''Returns the best guess as the 'top' module name name, given a fpath where
699
862
 
@@ -718,114 +881,9 @@ def get_inferred_top_module_name(module_guess: str, module_fpath: str) -> str:
718
881
  if bool(re.fullmatch(r'^\w+$', module_name)):
719
882
  if module_name == module_guess:
720
883
  return module_guess
721
- elif module_name:
884
+ if module_name:
722
885
  best_guess = module_name
723
886
  if best_guess:
724
887
  return best_guess
725
- else:
726
- return ''
727
-
728
-
729
- def subprocess_run(work_dir, command_list, fake:bool=False, shell=False) -> int:
730
- ''' Run command_list in the foreground, with preference to use bash if shell=True.'''
731
-
732
- if work_dir is not None:
733
- os.chdir(work_dir)
734
888
 
735
- is_windows = sys.platform.startswith('win')
736
-
737
- proc_kwargs = {'shell': shell}
738
- bash_exec = shutil.which('bash')
739
- if shell and bash_exec and not is_windows:
740
- proc_kwargs.update({'executable': bash_exec})
741
-
742
- if not is_windows and shell:
743
- c = ' '.join(command_list)
744
- else:
745
- c = command_list
746
-
747
- if fake:
748
- info(f"util.subprocess_run FAKE: would have called subprocess.run({c}, **{proc_kwargs}")
749
- return 0
750
- else:
751
- debug(f"util.subprocess_run: About to call subprocess.run({c}, **{proc_kwargs}")
752
- proc = subprocess.run(c, **proc_kwargs)
753
- return proc.returncode
754
-
755
-
756
- def subprocess_run_background(work_dir, command_list, background=True, fake:bool=False,
757
- shell=False, tee_fpath=None) -> (str, str, int):
758
- ''' Run command_list in the background, with preference to use bash if shell=True
759
-
760
- tee_fpath is relative to work_dir.
761
- '''
762
-
763
-
764
- is_windows = sys.platform.startswith('win')
765
-
766
- debug(f'util.subprocess_run_background: {background=} {tee_fpath=} {shell=}')
767
-
768
- if fake:
769
- # let subprocess_run handle it (won't run anything)
770
- rc = subprocess_run(work_dir, command_list, fake=fake, shell=shell)
771
- return '', '', rc
772
-
773
- if work_dir is not None:
774
- os.chdir(work_dir)
775
-
776
- proc_kwargs = {'shell': shell,
777
- 'stdout': subprocess.PIPE,
778
- 'stderr': subprocess.STDOUT,
779
- }
780
-
781
- bash_exec = shutil.which('bash')
782
- if shell and bash_exec and not is_windows:
783
- # Note - windows powershell will end up calling: /bin/bash /c, which won't work
784
- proc_kwargs.update({'executable': bash_exec})
785
-
786
- if not is_windows and shell:
787
- c = ' '.join(command_list)
788
- else:
789
- c = command_list # leave as list.
790
-
791
- debug(f"util.subprocess_run_background: about to call subprocess.Popen({c}, **{proc_kwargs})")
792
- proc = subprocess.Popen(c, **proc_kwargs)
793
-
794
- stdout = ''
795
- stderr = ''
796
- tee_fpath_f = None
797
- if tee_fpath:
798
- try:
799
- tee_fpath_f = open(tee_fpath, 'w')
800
- except Exception as e:
801
- error(f'Unable to open file "{tee_fpath}" for writing, {e}')
802
-
803
- for line in iter(proc.stdout.readline, b''):
804
- line = line.rstrip().decode("utf-8", errors="replace")
805
- if not background:
806
- print(line)
807
- if tee_fpath_f:
808
- tee_fpath_f.write(line + '\n')
809
- if global_log.file:
810
- global_log.write(line, '\n')
811
- stdout += line + '\n'
812
-
813
- proc.communicate()
814
- rc = proc.returncode
815
- if tee_fpath_f:
816
- tee_fpath_f.write(f'INFO: [{progname}] util.subprocess_run_background: returncode={rc}\n')
817
- tee_fpath_f.close()
818
- info('util.subprocess_run_background: wrote: ' + os.path.abspath(tee_fpath))
819
-
820
- return stdout, stderr, rc
821
-
822
-
823
- def sanitize_defines_for_sh(value):
824
- # Need to sanitize this for shell in case someone sends a +define+foo+1'b0,
825
- # which needs to be escaped as +define+foo+1\'b0, otherwise bash or sh will
826
- # think this is an unterminated string.
827
- # TODO(drew): decide if we should instead us shlex.quote('+define+key=value')
828
- # instead of this function.
829
- if type(value) is str:
830
- value = value.replace("'", "\\" + "'")
831
- return value
889
+ return ''