Coverage for klayout_pex/netlist/spice_syntax.py: 100%

56 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-06 13:14 +0000

1# 

2# -------------------------------------------------------------------------------- 

3# SPDX-FileCopyrightText: 2024-2026 Martin Jan Köhler and Harald Pretl 

4# Johannes Kepler University, Institute for Integrated Circuits. 

5# 

6# This file is part of KPEX 

7# (see https://github.com/iic-jku/klayout-pex). 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21# SPDX-License-Identifier: GPL-3.0-or-later 

22# -------------------------------------------------------------------------------- 

23# 

24 

25""" 

26The lines of a SPICE netlist as ngspice reads them, for the netlist tools: 

27 - a statement continues on the lines starting with '+', also across comment and blank lines 

28 - comments: lines starting with '*', and end-of-line comments from '$' or '//' at the start of a word, 

29 or from ';' (KLayout writes the net $1 as \\$1) 

30 - the first line of a deck (a netlist with .end) is its title 

31 - ground: the nodes '0' and 'gnd' 

32 

33NOTE: imports neither KLayout nor generated protobuf code, so that format-only paths can use it 

34""" 

35 

36from __future__ import annotations 

37 

38from dataclasses import dataclass 

39import re 

40from typing import * 

41 

42 

43GROUND_NODE_NAMES = ('0', 'gnd') 

44"""Ground in SPICE: '0', and 'gnd', which ngspice reads as '0'""" 

45 

46_END = re.compile(r'\s*\.end\s*$', re.IGNORECASE) 

47_END_OF_LINE_COMMENT = re.compile(r'(^|\s)(\$|//)|;') 

48 

49 

50class SpiceSyntaxError(Exception): 

51 """A netlist whose lines ngspice can't read, e.g. a continuation line without a line to continue""" 

52 

53 def __init__(self, line_number: int, message: str): 

54 super().__init__(f"line {line_number}: {message}") 

55 self.line_number = line_number 

56 self.message = message 

57 

58 

59@dataclass 

60class SpiceLine: 

61 """ 

62 A statement with its continuation lines, or a comment line, a blank line or the title 

63 """ 

64 

65 number: int 

66 """the number of its first physical line, counted from 1""" 

67 physical_lines: List[str] 

68 """as written: the statement, its continuation lines and the comment and blank lines between them""" 

69 text: Optional[str] 

70 """the statement in one line, without comments and the '+' of its continuation lines, 

71 None for a comment line, a blank line or the title""" 

72 

73 @property 

74 def verbatim(self) -> str: 

75 return '\n'.join(self.physical_lines) 

76 

77 

78def strip_comment(line: str) -> str: 

79 """ 

80 :return: the physical line without its comment, '' for a comment line 

81 """ 

82 if line.lstrip().startswith('*'): 

83 return '' 

84 match = _END_OF_LINE_COMMENT.search(line) 

85 return line if match is None else line[:match.start()] 

86 

87 

88def spice_lines(text: str) -> List[SpiceLine]: 

89 """ 

90 The lines of the netlist, in their order (see the module docs) 

91 

92 :raises SpiceSyntaxError: for a continuation line without a statement to continue 

93 """ 

94 physical_lines = text.splitlines() 

95 has_title = any(_END.match(line) for line in physical_lines) 

96 

97 lines: List[SpiceLine] = [] 

98 statement: Optional[SpiceLine] = None 

99 between: List[SpiceLine] = [] # NOTE: comment and blank lines after the statement, a continuation may follow 

100 

101 for number, line in enumerate(physical_lines, start=1): 

102 if number == 1 and has_title: 

103 lines.append(SpiceLine(number=number, physical_lines=[line], text=None)) 

104 continue 

105 stripped = strip_comment(line).strip() 

106 if not stripped: 

107 between.append(SpiceLine(number=number, physical_lines=[line], text=None)) 

108 elif stripped.startswith('+'): 

109 if statement is None: 

110 raise SpiceSyntaxError(number, "continuation line without a line to continue") 

111 for other in between: 

112 statement.physical_lines.extend(other.physical_lines) 

113 between = [] 

114 statement.physical_lines.append(line) 

115 statement.text += ' ' + stripped[1:].strip() 

116 else: 

117 lines.extend(between) 

118 between = [] 

119 statement = SpiceLine(number=number, physical_lines=[line], text=stripped) 

120 lines.append(statement) 

121 lines.extend(between) 

122 return lines