Show More
@@ -74,12 +74,6 line_transforms = [ | |||
|
74 | 74 | |
|
75 | 75 | # ----- |
|
76 | 76 | |
|
77 | def help_end(tokens_by_line): | |
|
78 | pass | |
|
79 | ||
|
80 | def escaped_command(tokens_by_line): | |
|
81 | pass | |
|
82 | ||
|
83 | 77 | def _find_assign_op(token_line): |
|
84 | 78 | # Find the first assignment in the line ('=' not inside brackets) |
|
85 | 79 | # We don't try to support multiple special assignment (a = b = %foo) |
@@ -114,9 +108,23 def assemble_continued_line(lines, start: Tuple[int, int], end_line: int): | |||
|
114 | 108 | return ' '.join([p[:-2] for p in parts[:-1]] # Strip backslash+newline |
|
115 | 109 | + [parts[-1][:-1]]) # Strip newline from last line |
|
116 | 110 | |
|
117 | class MagicAssign: | |
|
118 | @staticmethod | |
|
119 | def find(tokens_by_line): | |
|
111 | class TokenTransformBase: | |
|
112 | # Lower numbers -> higher priority (for matches in the same location) | |
|
113 | priority = 10 | |
|
114 | ||
|
115 | def sortby(self): | |
|
116 | return self.start_line, self.start_col, self.priority | |
|
117 | ||
|
118 | def __init__(self, start): | |
|
119 | self.start_line = start[0] - 1 # Shift from 1-index to 0-index | |
|
120 | self.start_col = start[1] | |
|
121 | ||
|
122 | def transform(self, lines: List[str]): | |
|
123 | raise NotImplementedError | |
|
124 | ||
|
125 | class MagicAssign(TokenTransformBase): | |
|
126 | @classmethod | |
|
127 | def find(cls, tokens_by_line): | |
|
120 | 128 | """Find the first magic assignment (a = %foo) in the cell. |
|
121 | 129 | |
|
122 | 130 | Returns (line, column) of the % if found, or None. *line* is 1-indexed. |
@@ -127,15 +135,12 class MagicAssign: | |||
|
127 | 135 | and (len(line) >= assign_ix + 2) \ |
|
128 | 136 | and (line[assign_ix+1].string == '%') \ |
|
129 | 137 | and (line[assign_ix+2].type == tokenize2.NAME): |
|
130 | return line[assign_ix+1].start | |
|
138 | return cls(line[assign_ix+1].start) | |
|
131 | 139 | |
|
132 | @staticmethod | |
|
133 | def transform(lines: List[str], start: Tuple[int, int]): | |
|
140 | def transform(self, lines: List[str]): | |
|
134 | 141 | """Transform a magic assignment found by find |
|
135 | 142 | """ |
|
136 | start_line = start[0] - 1 # Shift from 1-index to 0-index | |
|
137 | start_col = start[1] | |
|
138 | ||
|
143 | start_line, start_col = self.start_line, self.start_col | |
|
139 | 144 | lhs = lines[start_line][:start_col] |
|
140 | 145 | end_line = find_end_of_continued_line(lines, start_line) |
|
141 | 146 | rhs = assemble_continued_line(lines, (start_line, start_col), end_line) |
@@ -150,9 +155,9 class MagicAssign: | |||
|
150 | 155 | return lines_before + [new_line] + lines_after |
|
151 | 156 | |
|
152 | 157 | |
|
153 | class SystemAssign: | |
|
154 |
@ |
|
|
155 | def find(tokens_by_line): | |
|
158 | class SystemAssign(TokenTransformBase): | |
|
159 | @classmethod | |
|
160 | def find(cls, tokens_by_line): | |
|
156 | 161 | """Find the first system assignment (a = !foo) in the cell. |
|
157 | 162 | |
|
158 | 163 | Returns (line, column) of the ! if found, or None. *line* is 1-indexed. |
@@ -166,17 +171,15 class SystemAssign: | |||
|
166 | 171 | |
|
167 | 172 | while ix < len(line) and line[ix].type == tokenize2.ERRORTOKEN: |
|
168 | 173 | if line[ix].string == '!': |
|
169 | return line[ix].start | |
|
174 | return cls(line[ix].start) | |
|
170 | 175 | elif not line[ix].string.isspace(): |
|
171 | 176 | break |
|
172 | 177 | ix += 1 |
|
173 | 178 | |
|
174 | @staticmethod | |
|
175 | def transform(lines: List[str], start: Tuple[int, int]): | |
|
179 | def transform(self, lines: List[str]): | |
|
176 | 180 | """Transform a system assignment found by find |
|
177 | 181 | """ |
|
178 | start_line = start[0] - 1 # Shift from 1-index to 0-index | |
|
179 | start_col = start[1] | |
|
182 | start_line, start_col = self.start_line, self.start_col | |
|
180 | 183 | |
|
181 | 184 | lhs = lines[start_line][:start_col] |
|
182 | 185 | end_line = find_end_of_continued_line(lines, start_line) |
@@ -271,9 +274,9 tr = { ESC_SHELL : 'get_ipython().system({!r})'.format, | |||
|
271 | 274 | ESC_QUOTE2 : _tr_quote2, |
|
272 | 275 | ESC_PAREN : _tr_paren } |
|
273 | 276 | |
|
274 | class EscapedCommand: | |
|
275 |
@ |
|
|
276 | def find(tokens_by_line): | |
|
277 | class EscapedCommand(TokenTransformBase): | |
|
278 | @classmethod | |
|
279 | def find(cls, tokens_by_line): | |
|
277 | 280 | """Find the first escaped command (%foo, !foo, etc.) in the cell. |
|
278 | 281 | |
|
279 | 282 | Returns (line, column) of the escape if found, or None. *line* is 1-indexed. |
@@ -283,12 +286,10 class EscapedCommand: | |||
|
283 | 286 | while line[ix].type in {tokenize2.INDENT, tokenize2.DEDENT}: |
|
284 | 287 | ix += 1 |
|
285 | 288 | if line[ix].string in ESCAPE_SINGLES: |
|
286 | return line[ix].start | |
|
289 | return cls(line[ix].start) | |
|
287 | 290 | |
|
288 | @staticmethod | |
|
289 | def transform(lines, start): | |
|
290 | start_line = start[0] - 1 # Shift from 1-index to 0-index | |
|
291 | start_col = start[1] | |
|
291 | def transform(self, lines): | |
|
292 | start_line, start_col = self.start_line, self.start_col | |
|
292 | 293 | |
|
293 | 294 | indent = lines[start_line][:start_col] |
|
294 | 295 | end_line = find_end_of_continued_line(lines, start_line) |
@@ -306,6 +307,57 class EscapedCommand: | |||
|
306 | 307 | |
|
307 | 308 | return lines_before + [new_line] + lines_after |
|
308 | 309 | |
|
310 | _help_end_re = re.compile(r"""(%{0,2} | |
|
311 | [a-zA-Z_*][\w*]* # Variable name | |
|
312 | (\.[a-zA-Z_*][\w*]*)* # .etc.etc | |
|
313 | ) | |
|
314 | (\?\??)$ # ? or ?? | |
|
315 | """, | |
|
316 | re.VERBOSE) | |
|
317 | ||
|
318 | class HelpEnd(TokenTransformBase): | |
|
319 | # This needs to be higher priority (lower number) than EscapedCommand so | |
|
320 | # that inspecting magics (%foo?) works. | |
|
321 | priority = 5 | |
|
322 | ||
|
323 | def __init__(self, start, q_locn): | |
|
324 | super().__init__(start) | |
|
325 | self.q_line = q_locn[0] - 1 # Shift from 1-indexed to 0-indexed | |
|
326 | self.q_col = q_locn[1] | |
|
327 | ||
|
328 | @classmethod | |
|
329 | def find(cls, tokens_by_line): | |
|
330 | for line in tokens_by_line: | |
|
331 | # Last token is NEWLINE; look at last but one | |
|
332 | if len(line) > 2 and line[-2].string == '?': | |
|
333 | # Find the first token that's not INDENT/DEDENT | |
|
334 | ix = 0 | |
|
335 | while line[ix].type in {tokenize2.INDENT, tokenize2.DEDENT}: | |
|
336 | ix += 1 | |
|
337 | return cls(line[ix].start, line[-2].start) | |
|
338 | ||
|
339 | def transform(self, lines): | |
|
340 | piece = ''.join(lines[self.start_line:self.q_line+1]) | |
|
341 | indent, content = piece[:self.start_col], piece[self.start_col:] | |
|
342 | lines_before = lines[:self.start_line] | |
|
343 | lines_after = lines[self.q_line + 1:] | |
|
344 | ||
|
345 | m = _help_end_re.search(content) | |
|
346 | assert m is not None, content | |
|
347 | target = m.group(1) | |
|
348 | esc = m.group(3) | |
|
349 | ||
|
350 | # If we're mid-command, put it back on the next prompt for the user. | |
|
351 | next_input = None | |
|
352 | if (not lines_before) and (not lines_after) \ | |
|
353 | and content.strip() != m.group(0): | |
|
354 | next_input = content.rstrip('?\n') | |
|
355 | ||
|
356 | call = _make_help_call(target, esc, next_input=next_input) | |
|
357 | new_line = indent + call + '\n' | |
|
358 | ||
|
359 | return lines_before + [new_line] + lines_after | |
|
360 | ||
|
309 | 361 | def make_tokens_by_line(lines): |
|
310 | 362 | tokens_by_line = [[]] |
|
311 | 363 | for token in generate_tokens(iter(lines).__next__): |
@@ -330,6 +382,8 class TokenTransformers: | |||
|
330 | 382 | self.transformers = [ |
|
331 | 383 | MagicAssign, |
|
332 | 384 | SystemAssign, |
|
385 | EscapedCommand, | |
|
386 | HelpEnd, | |
|
333 | 387 | ] |
|
334 | 388 | |
|
335 | 389 | def do_one_transform(self, lines): |
@@ -348,17 +402,17 class TokenTransformers: | |||
|
348 | 402 | """ |
|
349 | 403 | tokens_by_line = make_tokens_by_line(lines) |
|
350 | 404 | candidates = [] |
|
351 | for transformer in self.transformers: | |
|
352 |
|
|
|
353 |
if |
|
|
354 |
candidates.append( |
|
|
355 | ||
|
405 | for transformer_cls in self.transformers: | |
|
406 | transformer = transformer_cls.find(tokens_by_line) | |
|
407 | if transformer: | |
|
408 | candidates.append(transformer) | |
|
409 | ||
|
356 | 410 | if not candidates: |
|
357 | 411 | # Nothing to transform |
|
358 | 412 | return False, lines |
|
359 | ||
|
360 |
|
|
|
361 |
return True, transformer.transform(lines |
|
|
413 | ||
|
414 | transformer = min(candidates, key=TokenTransformBase.sortby) | |
|
415 | return True, transformer.transform(lines) | |
|
362 | 416 | |
|
363 | 417 | def __call__(self, lines): |
|
364 | 418 | while True: |
@@ -366,9 +420,6 class TokenTransformers: | |||
|
366 | 420 | if not changed: |
|
367 | 421 | return lines |
|
368 | 422 | |
|
369 | def assign_from_system(tokens_by_line, lines): | |
|
370 | pass | |
|
371 | ||
|
372 | 423 | |
|
373 | 424 | def transform_cell(cell): |
|
374 | 425 | if not cell.endswith('\n'): |
@@ -8,7 +8,7 a = f() | |||
|
8 | 8 | %foo \\ |
|
9 | 9 | bar |
|
10 | 10 | g() |
|
11 | """.splitlines(keepends=True), """\ | |
|
11 | """.splitlines(keepends=True), (2, 0), """\ | |
|
12 | 12 | a = f() |
|
13 | 13 | get_ipython().run_line_magic('foo', ' bar') |
|
14 | 14 | g() |
@@ -17,7 +17,7 g() | |||
|
17 | 17 | INDENTED_MAGIC = ("""\ |
|
18 | 18 | for a in range(5): |
|
19 | 19 | %ls |
|
20 | """.splitlines(keepends=True), """\ | |
|
20 | """.splitlines(keepends=True), (2, 4), """\ | |
|
21 | 21 | for a in range(5): |
|
22 | 22 | get_ipython().run_line_magic('ls', '') |
|
23 | 23 | """.splitlines(keepends=True)) |
@@ -27,7 +27,7 a = f() | |||
|
27 | 27 | b = %foo \\ |
|
28 | 28 | bar |
|
29 | 29 | g() |
|
30 | """.splitlines(keepends=True), """\ | |
|
30 | """.splitlines(keepends=True), (2, 4), """\ | |
|
31 | 31 | a = f() |
|
32 | 32 | b = get_ipython().run_line_magic('foo', ' bar') |
|
33 | 33 | g() |
@@ -38,27 +38,78 a = f() | |||
|
38 | 38 | b = !foo \\ |
|
39 | 39 | bar |
|
40 | 40 | g() |
|
41 | """.splitlines(keepends=True), """\ | |
|
41 | """.splitlines(keepends=True), (2, 4), """\ | |
|
42 | 42 | a = f() |
|
43 | 43 | b = get_ipython().getoutput('foo bar') |
|
44 | 44 | g() |
|
45 | 45 | """.splitlines(keepends=True)) |
|
46 | 46 | |
|
47 | 47 | AUTOCALL_QUOTE = ( |
|
48 | [",f 1 2 3\n"], | |
|
48 | [",f 1 2 3\n"], (1, 0), | |
|
49 | 49 | ['f("1", "2", "3")\n'] |
|
50 | 50 | ) |
|
51 | 51 | |
|
52 | 52 | AUTOCALL_QUOTE2 = ( |
|
53 | [";f 1 2 3\n"], | |
|
53 | [";f 1 2 3\n"], (1, 0), | |
|
54 | 54 | ['f("1 2 3")\n'] |
|
55 | 55 | ) |
|
56 | 56 | |
|
57 | 57 | AUTOCALL_PAREN = ( |
|
58 | ["/f 1 2 3\n"], | |
|
58 | ["/f 1 2 3\n"], (1, 0), | |
|
59 | 59 | ['f(1, 2, 3)\n'] |
|
60 | 60 | ) |
|
61 | 61 | |
|
62 | SIMPLE_HELP = ( | |
|
63 | ["foo?\n"], (1, 0), | |
|
64 | ["get_ipython().run_line_magic('pinfo', 'foo')\n"] | |
|
65 | ) | |
|
66 | ||
|
67 | DETAILED_HELP = ( | |
|
68 | ["foo??\n"], (1, 0), | |
|
69 | ["get_ipython().run_line_magic('pinfo2', 'foo')\n"] | |
|
70 | ) | |
|
71 | ||
|
72 | MAGIC_HELP = ( | |
|
73 | ["%foo?\n"], (1, 0), | |
|
74 | ["get_ipython().run_line_magic('pinfo', '%foo')\n"] | |
|
75 | ) | |
|
76 | ||
|
77 | HELP_IN_EXPR = ( | |
|
78 | ["a = b + c?\n"], (1, 0), | |
|
79 | ["get_ipython().set_next_input('a = b + c');" | |
|
80 | "get_ipython().run_line_magic('pinfo', 'c')\n"] | |
|
81 | ) | |
|
82 | ||
|
83 | HELP_CONTINUED_LINE = ("""\ | |
|
84 | a = \\ | |
|
85 | zip? | |
|
86 | """.splitlines(keepends=True), (1, 0), | |
|
87 | [r"get_ipython().set_next_input('a = \\\nzip');get_ipython().run_line_magic('pinfo', 'zip')" + "\n"] | |
|
88 | ) | |
|
89 | ||
|
90 | HELP_MULTILINE = ("""\ | |
|
91 | (a, | |
|
92 | b) = zip? | |
|
93 | """.splitlines(keepends=True), (1, 0), | |
|
94 | [r"get_ipython().set_next_input('(a,\nb) = zip');get_ipython().run_line_magic('pinfo', 'zip')" + "\n"] | |
|
95 | ) | |
|
96 | ||
|
97 | def check_find(transformer, case, match=True): | |
|
98 | sample, expected_start, _ = case | |
|
99 | tbl = make_tokens_by_line(sample) | |
|
100 | res = transformer.find(tbl) | |
|
101 | if match: | |
|
102 | # start_line is stored 0-indexed, expected values are 1-indexed | |
|
103 | nt.assert_equal((res.start_line+1, res.start_col), expected_start) | |
|
104 | return res | |
|
105 | else: | |
|
106 | nt.assert_is(res, None) | |
|
107 | ||
|
108 | def check_transform(transformer_cls, case): | |
|
109 | lines, start, expected = case | |
|
110 | transformer = transformer_cls(start) | |
|
111 | nt.assert_equal(transformer.transform(lines), expected) | |
|
112 | ||
|
62 | 113 | def test_continued_line(): |
|
63 | 114 | lines = MULTILINE_MAGIC_ASSIGN[0] |
|
64 | 115 | nt.assert_equal(ipt2.find_end_of_continued_line(lines, 1), 2) |
@@ -66,58 +117,63 def test_continued_line(): | |||
|
66 | 117 | nt.assert_equal(ipt2.assemble_continued_line(lines, (1, 5), 2), "foo bar") |
|
67 | 118 | |
|
68 | 119 | def test_find_assign_magic(): |
|
69 | tbl = make_tokens_by_line(MULTILINE_MAGIC_ASSIGN[0]) | |
|
70 | nt.assert_equal(ipt2.MagicAssign.find(tbl), (2, 4)) | |
|
71 | ||
|
72 | tbl = make_tokens_by_line(MULTILINE_SYSTEM_ASSIGN[0]) # Nothing to find | |
|
73 | nt.assert_equal(ipt2.MagicAssign.find(tbl), None) | |
|
120 | check_find(ipt2.MagicAssign, MULTILINE_MAGIC_ASSIGN) | |
|
121 | check_find(ipt2.MagicAssign, MULTILINE_SYSTEM_ASSIGN, match=False) | |
|
74 | 122 | |
|
75 | 123 | def test_transform_assign_magic(): |
|
76 |
|
|
|
77 | nt.assert_equal(res, MULTILINE_MAGIC_ASSIGN[1]) | |
|
124 | check_transform(ipt2.MagicAssign, MULTILINE_MAGIC_ASSIGN) | |
|
78 | 125 | |
|
79 | 126 | def test_find_assign_system(): |
|
80 | tbl = make_tokens_by_line(MULTILINE_SYSTEM_ASSIGN[0]) | |
|
81 | nt.assert_equal(ipt2.SystemAssign.find(tbl), (2, 4)) | |
|
127 | check_find(ipt2.SystemAssign, MULTILINE_SYSTEM_ASSIGN) | |
|
128 | check_find(ipt2.SystemAssign, (["a = !ls\n"], (1, 5), None)) | |
|
129 | check_find(ipt2.SystemAssign, (["a=!ls\n"], (1, 2), None)) | |
|
130 | check_find(ipt2.SystemAssign, MULTILINE_MAGIC_ASSIGN, match=False) | |
|
82 | 131 | |
|
83 | tbl = make_tokens_by_line(["a = !ls\n"]) | |
|
84 | nt.assert_equal(ipt2.SystemAssign.find(tbl), (1, 5)) | |
|
132 | def test_transform_assign_system(): | |
|
133 | check_transform(ipt2.SystemAssign, MULTILINE_SYSTEM_ASSIGN) | |
|
85 | 134 | |
|
86 | tbl = make_tokens_by_line(["a=!ls\n"]) | |
|
87 | nt.assert_equal(ipt2.SystemAssign.find(tbl), (1, 2)) | |
|
135 | def test_find_magic_escape(): | |
|
136 | check_find(ipt2.EscapedCommand, MULTILINE_MAGIC) | |
|
137 | check_find(ipt2.EscapedCommand, INDENTED_MAGIC) | |
|
138 | check_find(ipt2.EscapedCommand, MULTILINE_MAGIC_ASSIGN, match=False) | |
|
88 | 139 | |
|
89 | tbl = make_tokens_by_line(MULTILINE_MAGIC_ASSIGN[0]) # Nothing to find | |
|
90 | nt.assert_equal(ipt2.SystemAssign.find(tbl), None) | |
|
140 | def test_transform_magic_escape(): | |
|
141 | check_transform(ipt2.EscapedCommand, MULTILINE_MAGIC) | |
|
142 | check_transform(ipt2.EscapedCommand, INDENTED_MAGIC) | |
|
91 | 143 | |
|
92 | def test_transform_assign_system(): | |
|
93 | res = ipt2.SystemAssign.transform(MULTILINE_SYSTEM_ASSIGN[0], (2, 4)) | |
|
94 | nt.assert_equal(res, MULTILINE_SYSTEM_ASSIGN[1]) | |
|
144 | def test_find_autocalls(): | |
|
145 | for case in [AUTOCALL_QUOTE, AUTOCALL_QUOTE2, AUTOCALL_PAREN]: | |
|
146 | print("Testing %r" % case[0]) | |
|
147 | check_find(ipt2.EscapedCommand, case) | |
|
95 | 148 | |
|
96 | def test_find_magic_escape(): | |
|
97 | tbl = make_tokens_by_line(MULTILINE_MAGIC[0]) | |
|
98 | nt.assert_equal(ipt2.EscapedCommand.find(tbl), (2, 0)) | |
|
149 | def test_transform_autocall(): | |
|
150 | for case in [AUTOCALL_QUOTE, AUTOCALL_QUOTE2, AUTOCALL_PAREN]: | |
|
151 | print("Testing %r" % case[0]) | |
|
152 | check_transform(ipt2.EscapedCommand, case) | |
|
99 | 153 | |
|
100 | tbl = make_tokens_by_line(INDENTED_MAGIC[0]) | |
|
101 | nt.assert_equal(ipt2.EscapedCommand.find(tbl), (2, 4)) | |
|
154 | def test_find_help(): | |
|
155 | for case in [SIMPLE_HELP, DETAILED_HELP, MAGIC_HELP, HELP_IN_EXPR]: | |
|
156 | check_find(ipt2.HelpEnd, case) | |
|
102 | 157 | |
|
103 | tbl = make_tokens_by_line(MULTILINE_MAGIC_ASSIGN[0]) # Shouldn't find a = %foo | |
|
104 |
nt.assert_equal( |
|
|
158 | tf = check_find(ipt2.HelpEnd, HELP_CONTINUED_LINE) | |
|
159 | nt.assert_equal(tf.q_line, 1) | |
|
160 | nt.assert_equal(tf.q_col, 3) | |
|
105 | 161 | |
|
106 | def test_transform_magic_escape(): | |
|
107 | res = ipt2.EscapedCommand.transform(MULTILINE_MAGIC[0], (2, 0)) | |
|
108 |
nt.assert_equal( |
|
|
162 | tf = check_find(ipt2.HelpEnd, HELP_MULTILINE) | |
|
163 | nt.assert_equal(tf.q_line, 1) | |
|
164 | nt.assert_equal(tf.q_col, 8) | |
|
109 | 165 | |
|
110 | res = ipt2.EscapedCommand.transform(INDENTED_MAGIC[0], (2, 4)) | |
|
111 | nt.assert_equal(res, INDENTED_MAGIC[1]) | |
|
166 | # ? in a comment does not trigger help | |
|
167 | check_find(ipt2.HelpEnd, (["foo # bar?\n"], None, None), match=False) | |
|
168 | # Nor in a string | |
|
169 | check_find(ipt2.HelpEnd, (["foo = '''bar?\n"], None, None), match=False) | |
|
112 | 170 | |
|
113 | def test_find_autocalls(): | |
|
114 | for sample, _ in [AUTOCALL_QUOTE, AUTOCALL_QUOTE2, AUTOCALL_PAREN]: | |
|
115 | print("Testing %r" % sample) | |
|
116 | tbl = make_tokens_by_line(sample) | |
|
117 | nt.assert_equal(ipt2.EscapedCommand.find(tbl), (1, 0)) | |
|
171 | def test_transform_help(): | |
|
172 | tf = ipt2.HelpEnd((1, 0), (1, 9)) | |
|
173 | nt.assert_equal(tf.transform(HELP_IN_EXPR[0]), HELP_IN_EXPR[2]) | |
|
118 | 174 | |
|
119 | def test_transform_autocall(): | |
|
120 | for sample, expected in [AUTOCALL_QUOTE, AUTOCALL_QUOTE2, AUTOCALL_PAREN]: | |
|
121 | print("Testing %r" % sample) | |
|
122 | res = ipt2.EscapedCommand.transform(sample, (1, 0)) | |
|
123 | nt.assert_equal(res, expected) | |
|
175 | tf = ipt2.HelpEnd((1, 0), (2, 3)) | |
|
176 | nt.assert_equal(tf.transform(HELP_CONTINUED_LINE[0]), HELP_CONTINUED_LINE[2]) | |
|
177 | ||
|
178 | tf = ipt2.HelpEnd((1, 0), (2, 8)) | |
|
179 | nt.assert_equal(tf.transform(HELP_MULTILINE[0]), HELP_MULTILINE[2]) |
General Comments 0
You need to be logged in to leave comments.
Login now