shell-lite 0.4.3__tar.gz → 0.4.5__tar.gz
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.
- {shell_lite-0.4.3/shell_lite.egg-info → shell_lite-0.4.5}/PKG-INFO +1 -1
- {shell_lite-0.4.3 → shell_lite-0.4.5}/pyproject.toml +1 -1
- shell_lite-0.4.3/shell_lite/interpreter_final.py → shell_lite-0.4.5/shell_lite/interpreter.py +53 -28
- shell_lite-0.4.3/shell_lite/lexer_new.py → shell_lite-0.4.5/shell_lite/lexer.py +2 -1
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite/main.py +8 -9
- shell_lite-0.4.3/shell_lite/parser_new.py → shell_lite-0.4.5/shell_lite/parser.py +18 -4
- {shell_lite-0.4.3 → shell_lite-0.4.5/shell_lite.egg-info}/PKG-INFO +1 -1
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite.egg-info/SOURCES.txt +0 -10
- shell_lite-0.4.3/shell_lite/fix_nulls.py +0 -29
- shell_lite-0.4.3/shell_lite/formatter.py +0 -75
- shell_lite-0.4.3/shell_lite/interpreter.py +0 -1779
- shell_lite-0.4.3/shell_lite/interpreter_backup.py +0 -1781
- shell_lite-0.4.3/shell_lite/interpreter_new.py +0 -1773
- shell_lite-0.4.3/shell_lite/js_compiler.py +0 -220
- shell_lite-0.4.3/shell_lite/lexer.py +0 -245
- shell_lite-0.4.3/shell_lite/minimal_interpreter.py +0 -25
- shell_lite-0.4.3/shell_lite/parser.py +0 -2093
- shell_lite-0.4.3/shell_lite/patch_parser.py +0 -41
- {shell_lite-0.4.3 → shell_lite-0.4.5}/LICENSE +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/README.md +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/setup.cfg +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite/__init__.py +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite/ast_nodes.py +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite/cli.py +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite/compiler.py +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite/runtime.py +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite.egg-info/dependency_links.txt +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite.egg-info/entry_points.txt +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite.egg-info/requires.txt +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/shell_lite.egg-info/top_level.txt +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/tests/test_interpreter.py +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/tests/test_lexer.py +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/tests/test_parser.py +0 -0
- {shell_lite-0.4.3 → shell_lite-0.4.5}/tests/test_stdlib.py +0 -0
shell_lite-0.4.3/shell_lite/interpreter_final.py → shell_lite-0.4.5/shell_lite/interpreter.py
RENAMED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from typing import Any, Dict, List, Callable
|
|
2
2
|
from .ast_nodes import *
|
|
3
|
-
from .
|
|
4
|
-
from .
|
|
3
|
+
from .lexer import Token, Lexer
|
|
4
|
+
from .parser import Parser
|
|
5
5
|
import importlib
|
|
6
|
+
import types
|
|
6
7
|
import operator
|
|
7
8
|
import re
|
|
8
9
|
import os
|
|
@@ -128,7 +129,7 @@ class WebBuilder:
|
|
|
128
129
|
pass
|
|
129
130
|
class Interpreter:
|
|
130
131
|
def __init__(self):
|
|
131
|
-
print('DEBUG:
|
|
132
|
+
# print('DEBUG: ShellLite v0.04.5')
|
|
132
133
|
self.global_env = Environment()
|
|
133
134
|
self.global_env.set('str', str)
|
|
134
135
|
self.global_env.set('int', int)
|
|
@@ -278,6 +279,12 @@ class Interpreter:
|
|
|
278
279
|
def _builtin_push(self, lst, item):
|
|
279
280
|
lst.append(item)
|
|
280
281
|
return None
|
|
282
|
+
def _builtin_upper(self, s):
|
|
283
|
+
return str(s).upper()
|
|
284
|
+
def _builtin_sum_range(self, start, end):
|
|
285
|
+
return sum(range(int(start), int(end)))
|
|
286
|
+
def _builtin_range_list(self, start, end):
|
|
287
|
+
return list(range(int(start), int(end)))
|
|
281
288
|
def _init_std_modules(self):
|
|
282
289
|
self.std_modules = {
|
|
283
290
|
'math': {
|
|
@@ -770,7 +777,12 @@ class Interpreter:
|
|
|
770
777
|
if node.path in self.std_modules:
|
|
771
778
|
self.current_env.set(node.path, self.std_modules[node.path])
|
|
772
779
|
return
|
|
780
|
+
|
|
781
|
+
# 1. Check File System (ShellLite modules)
|
|
773
782
|
import os
|
|
783
|
+
import importlib
|
|
784
|
+
target_path = None
|
|
785
|
+
|
|
774
786
|
if os.path.exists(node.path):
|
|
775
787
|
target_path = node.path
|
|
776
788
|
else:
|
|
@@ -783,32 +795,45 @@ class Interpreter:
|
|
|
783
795
|
global_path_ext = global_path + ".shl"
|
|
784
796
|
if os.path.exists(global_path_ext):
|
|
785
797
|
target_path = global_path_ext
|
|
786
|
-
|
|
787
|
-
|
|
798
|
+
|
|
799
|
+
# 2. If found on FS, load as ShellLite
|
|
800
|
+
if target_path:
|
|
801
|
+
if os.path.isdir(target_path):
|
|
802
|
+
main_shl = os.path.join(target_path, "main.shl")
|
|
803
|
+
pkg_shl = os.path.join(target_path, f"{os.path.basename(target_path)}.shl")
|
|
804
|
+
if os.path.exists(main_shl):
|
|
805
|
+
target_path = main_shl
|
|
806
|
+
elif os.path.exists(pkg_shl):
|
|
807
|
+
target_path = pkg_shl
|
|
788
808
|
else:
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
809
|
+
raise FileNotFoundError(f"Package '{node.path}' is a folder but has no 'main.shl' or '{os.path.basename(target_path)}.shl'.")
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
with open(target_path, 'r', encoding='utf-8') as f:
|
|
813
|
+
code = f.read()
|
|
814
|
+
except FileNotFoundError:
|
|
815
|
+
raise FileNotFoundError(f"Could not find imported file: {node.path}")
|
|
816
|
+
|
|
817
|
+
from .lexer import Lexer
|
|
818
|
+
from .parser import Parser
|
|
819
|
+
lexer = Lexer(code)
|
|
820
|
+
tokens = lexer.tokenize()
|
|
821
|
+
parser = Parser(tokens)
|
|
822
|
+
statements = parser.parse()
|
|
823
|
+
for stmt in statements:
|
|
824
|
+
self.visit(stmt)
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
# 3. BRIDGE: Try importing as a raw Python module
|
|
799
828
|
try:
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
parser = Parser(tokens)
|
|
809
|
-
statements = parser.parse()
|
|
810
|
-
for stmt in statements:
|
|
811
|
-
self.visit(stmt)
|
|
829
|
+
py_module = importlib.import_module(node.path)
|
|
830
|
+
self.current_env.set(node.path, py_module)
|
|
831
|
+
return
|
|
832
|
+
except ImportError:
|
|
833
|
+
pass # Fall through to error
|
|
834
|
+
|
|
835
|
+
raise FileNotFoundError(f"Could not find module '{node.path}'. Searched:\n - ShellLite Local/Global\n - Python Site-Packages (The Bridge)")
|
|
836
|
+
|
|
812
837
|
def _get_class_properties(self, class_def: ClassDef) -> List[tuple[str, Optional[Node]]]:
|
|
813
838
|
if not hasattr(class_def, 'properties'): return []
|
|
814
839
|
# Support both old string list and new tuple list for backward compat if needed, though we updated AST
|
|
@@ -1534,7 +1559,7 @@ class Interpreter:
|
|
|
1534
1559
|
self.wfile.write(str(e).encode())
|
|
1535
1560
|
except: pass
|
|
1536
1561
|
server = HTTPServer(('0.0.0.0', port_val), ShellLiteHandler)
|
|
1537
|
-
print(f"\n ShellLite Server v0.04.
|
|
1562
|
+
print(f"\n ShellLite Server v0.04.5 is running!")
|
|
1538
1563
|
print(f" \u001b[1;36m➜\u001b[0m Local: \u001b[1;4;36mhttp://localhost:{port_val}/\u001b[0m\n")
|
|
1539
1564
|
try: server.serve_forever()
|
|
1540
1565
|
except KeyboardInterrupt:
|
|
@@ -165,7 +165,8 @@ class Lexer:
|
|
|
165
165
|
'while': 'WHILE', 'until': 'UNTIL',
|
|
166
166
|
'repeat': 'REPEAT', 'forever': 'FOREVER',
|
|
167
167
|
'stop': 'STOP', 'skip': 'SKIP', 'exit': 'EXIT',
|
|
168
|
-
'each': '
|
|
168
|
+
'each': 'EACH',
|
|
169
|
+
'check': 'CHECK',
|
|
169
170
|
'unless': 'UNLESS', 'when': 'WHEN', 'otherwise': 'OTHERWISE',
|
|
170
171
|
'then': 'THEN', 'do': 'DO',
|
|
171
172
|
'print': 'PRINT', 'say': 'SAY', 'show': 'SAY',
|
|
@@ -5,9 +5,9 @@ import urllib.request
|
|
|
5
5
|
import zipfile
|
|
6
6
|
import io
|
|
7
7
|
import subprocess
|
|
8
|
-
from .
|
|
9
|
-
from .
|
|
10
|
-
from .
|
|
8
|
+
from .lexer import Lexer
|
|
9
|
+
from .parser import Parser
|
|
10
|
+
from .interpreter import Interpreter
|
|
11
11
|
from .ast_nodes import *
|
|
12
12
|
import json
|
|
13
13
|
def execute_source(source: str, interpreter: Interpreter):
|
|
@@ -58,10 +58,9 @@ def run_file(filename: str):
|
|
|
58
58
|
print(f"Error: File '{filename}' not found.")
|
|
59
59
|
return
|
|
60
60
|
import sys
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
from .
|
|
64
|
-
print(f"DEBUG: Interpreter class: {Interpreter}")
|
|
61
|
+
|
|
62
|
+
# Debug prints removed for production
|
|
63
|
+
from .interpreter import Interpreter
|
|
65
64
|
|
|
66
65
|
with open(filename, 'r', encoding='utf-8') as f:
|
|
67
66
|
source = f.read()
|
|
@@ -72,7 +71,7 @@ def run_repl():
|
|
|
72
71
|
print("\n" + "="*40)
|
|
73
72
|
print(" ShellLite REPL - English Syntax")
|
|
74
73
|
print("="*40)
|
|
75
|
-
print("Version: v0.04.
|
|
74
|
+
print("Version: v0.04.5 | Made by Shrey Naithani")
|
|
76
75
|
print("Commands: Type 'exit' to quit, 'help' for examples.")
|
|
77
76
|
print("Note: Terminal commands (like 'shl install') must be run in CMD/PowerShell, not here.")
|
|
78
77
|
|
|
@@ -203,7 +202,7 @@ def install_globally():
|
|
|
203
202
|
ps_cmd = f'$oldPath = [Environment]::GetEnvironmentVariable("Path", "User"); if ($oldPath -notlike "*ShellLite*") {{ [Environment]::SetEnvironmentVariable("Path", "$oldPath;{install_dir}", "User") }}'
|
|
204
203
|
subprocess.run(["powershell", "-Command", ps_cmd], capture_output=True)
|
|
205
204
|
|
|
206
|
-
print(f"\n[SUCCESS] ShellLite (v0.04.
|
|
205
|
+
print(f"\n[SUCCESS] ShellLite (v0.04.5) is installed!")
|
|
207
206
|
print(f"Location: {install_dir}")
|
|
208
207
|
print("\nIMPORTANT STEP REQUIRED:")
|
|
209
208
|
print("1. Close ALL open terminal windows (CMD, PowerShell, VS Code).")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from typing import List, Optional
|
|
2
|
-
from .
|
|
2
|
+
from .lexer import Token, Lexer
|
|
3
3
|
from .ast_nodes import *
|
|
4
4
|
import re
|
|
5
5
|
class Parser:
|
|
@@ -80,10 +80,20 @@ class Parser:
|
|
|
80
80
|
return self.parse_make()
|
|
81
81
|
elif self.check('INPUT'):
|
|
82
82
|
next_t = self.peek(1)
|
|
83
|
-
if next_t.type in ('ID', 'TYPE', 'STRING', 'NAME', 'VALUE', 'CLASS', 'STYLE', 'ONCLICK', 'SRC', 'HREF', 'ACTION', 'METHOD'):
|
|
83
|
+
if next_t.type in ('ID', 'TYPE', 'STRING', 'NAME', 'VALUE', 'CLASS', 'STYLE', 'ONCLICK', 'SRC', 'HREF', 'ACTION', 'METHOD', 'PLACEHOLDER'):
|
|
84
84
|
input_token = self.consume()
|
|
85
85
|
return self.parse_id_start_statement(passed_name_token=input_token)
|
|
86
86
|
return self.parse_expression_stmt()
|
|
87
|
+
elif self.check('BUTTON'):
|
|
88
|
+
return self.parse_id_start_statement(passed_name_token=self.consume('BUTTON'))
|
|
89
|
+
elif self.check('COLUMN'):
|
|
90
|
+
return self.parse_id_start_statement(passed_name_token=self.consume('COLUMN'))
|
|
91
|
+
elif self.check('ROW'):
|
|
92
|
+
return self.parse_id_start_statement(passed_name_token=self.consume('ROW'))
|
|
93
|
+
elif self.check('IMAGE'):
|
|
94
|
+
return self.parse_id_start_statement(passed_name_token=self.consume('IMAGE'))
|
|
95
|
+
elif self.check('SIZE'):
|
|
96
|
+
return self.parse_id_start_statement(passed_name_token=self.consume('SIZE'))
|
|
87
97
|
elif self.check('ID'):
|
|
88
98
|
return self.parse_id_start_statement()
|
|
89
99
|
elif self.check('SPAWN'):
|
|
@@ -173,6 +183,9 @@ class Parser:
|
|
|
173
183
|
# Standalone ask statement? e.g. ask "Questions?"
|
|
174
184
|
# Or ask is expression. If statement, maybe just expression statement.
|
|
175
185
|
return self.parse_expression_statement()
|
|
186
|
+
elif self.check('CHECK'):
|
|
187
|
+
self.consume('CHECK')
|
|
188
|
+
return self.parse_if()
|
|
176
189
|
elif self.check('SET'):
|
|
177
190
|
return self.parse_set()
|
|
178
191
|
else:
|
|
@@ -940,8 +953,8 @@ class Parser:
|
|
|
940
953
|
return node
|
|
941
954
|
def parse_start_server(self) -> Node:
|
|
942
955
|
token = self.consume('START')
|
|
943
|
-
if self.check('SERVER'):
|
|
944
|
-
self.consume(
|
|
956
|
+
if self.check('SERVER') or self.check('WEBSITE') or (self.check('ID') and self.peek().value == 'website'):
|
|
957
|
+
self.consume()
|
|
945
958
|
port = Number(8080)
|
|
946
959
|
if self.check('ON'):
|
|
947
960
|
self.consume('ON')
|
|
@@ -1667,6 +1680,7 @@ class Parser:
|
|
|
1667
1680
|
node.line = start_token.line
|
|
1668
1681
|
return node
|
|
1669
1682
|
start_token = self.consume('FOR')
|
|
1683
|
+
if self.check('EACH'): self.consume('EACH')
|
|
1670
1684
|
if self.check('ID') and self.peek(1).type == 'IN':
|
|
1671
1685
|
var_name = self.consume('ID').value
|
|
1672
1686
|
self.consume('IN')
|
|
@@ -5,20 +5,10 @@ shell_lite/__init__.py
|
|
|
5
5
|
shell_lite/ast_nodes.py
|
|
6
6
|
shell_lite/cli.py
|
|
7
7
|
shell_lite/compiler.py
|
|
8
|
-
shell_lite/fix_nulls.py
|
|
9
|
-
shell_lite/formatter.py
|
|
10
8
|
shell_lite/interpreter.py
|
|
11
|
-
shell_lite/interpreter_backup.py
|
|
12
|
-
shell_lite/interpreter_final.py
|
|
13
|
-
shell_lite/interpreter_new.py
|
|
14
|
-
shell_lite/js_compiler.py
|
|
15
9
|
shell_lite/lexer.py
|
|
16
|
-
shell_lite/lexer_new.py
|
|
17
10
|
shell_lite/main.py
|
|
18
|
-
shell_lite/minimal_interpreter.py
|
|
19
11
|
shell_lite/parser.py
|
|
20
|
-
shell_lite/parser_new.py
|
|
21
|
-
shell_lite/patch_parser.py
|
|
22
12
|
shell_lite/runtime.py
|
|
23
13
|
shell_lite.egg-info/PKG-INFO
|
|
24
14
|
shell_lite.egg-info/SOURCES.txt
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import sys
|
|
3
|
-
import glob
|
|
4
|
-
import os
|
|
5
|
-
|
|
6
|
-
files = [
|
|
7
|
-
r'c:\Users\shrey\OneDrive\Desktop\oka\shell-lite\shell_lite\parser.py',
|
|
8
|
-
r'c:\Users\shrey\OneDrive\Desktop\oka\shell-lite\shell_lite\lexer.py',
|
|
9
|
-
r'c:\Users\shrey\OneDrive\Desktop\oka\shell-lite\shell_lite\interpreter.py',
|
|
10
|
-
r'c:\Users\shrey\OneDrive\Desktop\oka\shell-lite\shell_lite\main.py',
|
|
11
|
-
r'c:\Users\shrey\OneDrive\Desktop\oka\shell-lite\shell_lite\ast_nodes.py',
|
|
12
|
-
r'c:\Users\shrey\OneDrive\Desktop\oka\tests_suite\repro_issues.shl'
|
|
13
|
-
]
|
|
14
|
-
|
|
15
|
-
for path in files:
|
|
16
|
-
try:
|
|
17
|
-
with open(path, 'rb') as f:
|
|
18
|
-
content = f.read()
|
|
19
|
-
|
|
20
|
-
if b'\x00' in content:
|
|
21
|
-
print(f"Null bytes found in {path}! Fixing...")
|
|
22
|
-
new_content = content.replace(b'\x00', b'')
|
|
23
|
-
with open(path, 'wb') as f:
|
|
24
|
-
f.write(new_content)
|
|
25
|
-
print(f"Fixed {path}.")
|
|
26
|
-
else:
|
|
27
|
-
print(f"No null bytes in {path}.")
|
|
28
|
-
except Exception as e:
|
|
29
|
-
print(f"Error checking {path}: {e}")
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
from typing import List
|
|
2
|
-
from .lexer import Lexer, Token
|
|
3
|
-
class Formatter:
|
|
4
|
-
def __init__(self, source_code: str):
|
|
5
|
-
self.source_code = source_code
|
|
6
|
-
self.indent_size = 4
|
|
7
|
-
def format(self) -> str:
|
|
8
|
-
lexer = Lexer(self.source_code)
|
|
9
|
-
try:
|
|
10
|
-
tokens = lexer.tokenize()
|
|
11
|
-
except Exception:
|
|
12
|
-
raise
|
|
13
|
-
formatted_lines = []
|
|
14
|
-
current_indent = 0
|
|
15
|
-
current_line_tokens: List[Token] = []
|
|
16
|
-
def flush_line():
|
|
17
|
-
nonlocal current_line_tokens
|
|
18
|
-
if not current_line_tokens:
|
|
19
|
-
pass
|
|
20
|
-
line_str = self._format_line_tokens(current_line_tokens, current_indent)
|
|
21
|
-
formatted_lines.append(line_str)
|
|
22
|
-
current_line_tokens.clear()
|
|
23
|
-
for token in tokens:
|
|
24
|
-
if token.type == 'EOF':
|
|
25
|
-
if current_line_tokens:
|
|
26
|
-
flush_line()
|
|
27
|
-
break
|
|
28
|
-
elif token.type == 'INDENT':
|
|
29
|
-
current_indent += 1
|
|
30
|
-
elif token.type == 'DEDENT':
|
|
31
|
-
current_indent -= 1
|
|
32
|
-
if current_indent < 0: current_indent = 0
|
|
33
|
-
elif token.type == 'NEWLINE':
|
|
34
|
-
flush_line()
|
|
35
|
-
pass
|
|
36
|
-
else:
|
|
37
|
-
current_line_tokens.append(token)
|
|
38
|
-
return '\n'.join(formatted_lines)
|
|
39
|
-
def _format_line_tokens(self, tokens: List[Token], indent_level: int) -> str:
|
|
40
|
-
if not tokens:
|
|
41
|
-
return ''
|
|
42
|
-
line_parts = []
|
|
43
|
-
line_parts.append(' ' * (indent_level * self.indent_size))
|
|
44
|
-
for i, token in enumerate(tokens):
|
|
45
|
-
val = token.value
|
|
46
|
-
type = token.type
|
|
47
|
-
if type == 'STRING':
|
|
48
|
-
if '"' in val and "'" not in val:
|
|
49
|
-
val = f"'{val}'"
|
|
50
|
-
else:
|
|
51
|
-
val = val.replace('"', '\\"')
|
|
52
|
-
val = f'"{val}"'
|
|
53
|
-
elif type == 'REGEX':
|
|
54
|
-
val = f"/{val}/"
|
|
55
|
-
if i > 0:
|
|
56
|
-
prev = tokens[i-1]
|
|
57
|
-
need_space = True
|
|
58
|
-
if prev.type in ('LPAREN', 'LBRACKET', 'LBRACE', 'DOT', 'AT'):
|
|
59
|
-
need_space = False
|
|
60
|
-
if type in ('RPAREN', 'RBRACKET', 'RBRACE', 'DOT', 'COMMA', 'COLON'):
|
|
61
|
-
need_space = False
|
|
62
|
-
if type == 'LPAREN':
|
|
63
|
-
if prev.type == 'ID':
|
|
64
|
-
need_space = False
|
|
65
|
-
elif prev.type in ('RPAREN', 'RBRACKET', 'STRING'):
|
|
66
|
-
need_space = False
|
|
67
|
-
else:
|
|
68
|
-
pass
|
|
69
|
-
if type == 'LBRACKET':
|
|
70
|
-
if prev.type in ('ID', 'STRING', 'RPAREN', 'RBRACKET'):
|
|
71
|
-
need_space = False
|
|
72
|
-
if need_space:
|
|
73
|
-
line_parts.append(' ')
|
|
74
|
-
line_parts.append(val)
|
|
75
|
-
return "".join(line_parts).rstrip()
|