trailhead-edu 0.2.0__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.
Files changed (40) hide show
  1. trailhead_edu-0.2.0/LICENSE +21 -0
  2. trailhead_edu-0.2.0/MANIFEST.in +1 -0
  3. trailhead_edu-0.2.0/PKG-INFO +70 -0
  4. trailhead_edu-0.2.0/README.md +31 -0
  5. trailhead_edu-0.2.0/pyproject.toml +100 -0
  6. trailhead_edu-0.2.0/setup.cfg +4 -0
  7. trailhead_edu-0.2.0/src/trailhead/__init__.py +3 -0
  8. trailhead_edu-0.2.0/src/trailhead/__main__.py +117 -0
  9. trailhead_edu-0.2.0/src/trailhead/analysis/__init__.py +1 -0
  10. trailhead_edu-0.2.0/src/trailhead/analysis/inspect.py +206 -0
  11. trailhead_edu-0.2.0/src/trailhead/app.py +163 -0
  12. trailhead_edu-0.2.0/src/trailhead/async_python_subprocess.py +589 -0
  13. trailhead_edu-0.2.0/src/trailhead/controller.py +141 -0
  14. trailhead_edu-0.2.0/src/trailhead/file_observer.py +116 -0
  15. trailhead_edu-0.2.0/src/trailhead/graphics/__init__.py +4 -0
  16. trailhead_edu-0.2.0/src/trailhead/graphics/circle.py +11 -0
  17. trailhead_edu-0.2.0/src/trailhead/graphics/shape.py +1 -0
  18. trailhead_edu-0.2.0/src/trailhead/models.py +33 -0
  19. trailhead_edu-0.2.0/src/trailhead/project.py +72 -0
  20. trailhead_edu-0.2.0/src/trailhead/static/assets/index-DlMzAOzZ.js +10 -0
  21. trailhead_edu-0.2.0/src/trailhead/static/assets/index-hMuY5Mhb.css +1 -0
  22. trailhead_edu-0.2.0/src/trailhead/static/index.html +16 -0
  23. trailhead_edu-0.2.0/src/trailhead/web_socket_event.py +13 -0
  24. trailhead_edu-0.2.0/src/trailhead/web_socket_manager.py +83 -0
  25. trailhead_edu-0.2.0/src/trailhead/websocket_origin.py +188 -0
  26. trailhead_edu-0.2.0/src/trailhead/wrappers/__init__.py +1 -0
  27. trailhead_edu-0.2.0/src/trailhead/wrappers/_traceback.py +196 -0
  28. trailhead_edu-0.2.0/src/trailhead/wrappers/function_call.py +34 -0
  29. trailhead_edu-0.2.0/src/trailhead/wrappers/interact/__init__.py +1 -0
  30. trailhead_edu-0.2.0/src/trailhead/wrappers/interact/codeop.py +171 -0
  31. trailhead_edu-0.2.0/src/trailhead/wrappers/interact/repl.py +339 -0
  32. trailhead_edu-0.2.0/src/trailhead/wrappers/module.py +32 -0
  33. trailhead_edu-0.2.0/src/trailhead/wrappers/repl.py +187 -0
  34. trailhead_edu-0.2.0/src/trailhead/wrappers/repl_gui.py +183 -0
  35. trailhead_edu-0.2.0/src/trailhead_edu.egg-info/PKG-INFO +70 -0
  36. trailhead_edu-0.2.0/src/trailhead_edu.egg-info/SOURCES.txt +38 -0
  37. trailhead_edu-0.2.0/src/trailhead_edu.egg-info/dependency_links.txt +1 -0
  38. trailhead_edu-0.2.0/src/trailhead_edu.egg-info/entry_points.txt +2 -0
  39. trailhead_edu-0.2.0/src/trailhead_edu.egg-info/requires.txt +16 -0
  40. trailhead_edu-0.2.0/src/trailhead_edu.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Kris Jordan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ prune tests
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: trailhead-edu
3
+ Version: 0.2.0
4
+ Summary: A local web environment for learning and exploring Python programs
5
+ Author-email: Kris Jordan <kris@cs.unc.edu>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/KrisJordan/trailhead
8
+ Project-URL: Issues, https://github.com/KrisJordan/trailhead/issues
9
+ Project-URL: Repository, https://github.com/KrisJordan/trailhead.git
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: cachetools<7,>=5.3
24
+ Requires-Dist: fastapi<1,>=0.108
25
+ Requires-Dist: pydantic<3,>=2.5
26
+ Requires-Dist: uvicorn<1,>=0.25
27
+ Requires-Dist: watchdog<7,>=3
28
+ Requires-Dist: websockets<17,>=12
29
+ Provides-Extra: student
30
+ Requires-Dist: ipykernel>=6.28; extra == "student"
31
+ Requires-Dist: matplotlib>=3.8; extra == "student"
32
+ Requires-Dist: numpy>=1.26; extra == "student"
33
+ Requires-Dist: pandas>=2.1; extra == "student"
34
+ Requires-Dist: pillow>=10.1; extra == "student"
35
+ Requires-Dist: seaborn>=0.13; extra == "student"
36
+ Requires-Dist: six>=1.16; extra == "student"
37
+ Requires-Dist: svgwrite>=1.4; extra == "student"
38
+ Dynamic: license-file
39
+
40
+ # Trailhead server
41
+
42
+ Trailhead is a local FastAPI server for exploring and running Python modules in
43
+ a browser. It supports Python 3.11 or newer on macOS, Windows, and Linux. The
44
+ PyPI distribution is named `trailhead-edu`; the import package and command remain
45
+ `trailhead`.
46
+
47
+ Install the command from PyPI in an isolated tool environment with:
48
+
49
+ ```console
50
+ uv tool install trailhead-edu
51
+ ```
52
+
53
+ For repository development, synchronize the locked environment from the
54
+ repository root:
55
+
56
+ ```console
57
+ uv sync --project server --locked --extra student
58
+ ```
59
+
60
+ The `student` extra supplies the optional data-science and teaching libraries;
61
+ the base installation contains only Trailhead's server runtime. Run a project
62
+ with:
63
+
64
+ ```console
65
+ trailhead --root demo
66
+ ```
67
+
68
+ Use `trailhead --help` for host, port, reload, and log-level options. The web
69
+ client must be built before creating a release wheel so that `static/index.html`
70
+ and `static/assets/` are included as package data.
@@ -0,0 +1,31 @@
1
+ # Trailhead server
2
+
3
+ Trailhead is a local FastAPI server for exploring and running Python modules in
4
+ a browser. It supports Python 3.11 or newer on macOS, Windows, and Linux. The
5
+ PyPI distribution is named `trailhead-edu`; the import package and command remain
6
+ `trailhead`.
7
+
8
+ Install the command from PyPI in an isolated tool environment with:
9
+
10
+ ```console
11
+ uv tool install trailhead-edu
12
+ ```
13
+
14
+ For repository development, synchronize the locked environment from the
15
+ repository root:
16
+
17
+ ```console
18
+ uv sync --project server --locked --extra student
19
+ ```
20
+
21
+ The `student` extra supplies the optional data-science and teaching libraries;
22
+ the base installation contains only Trailhead's server runtime. Run a project
23
+ with:
24
+
25
+ ```console
26
+ trailhead --root demo
27
+ ```
28
+
29
+ Use `trailhead --help` for host, port, reload, and log-level options. The web
30
+ client must be built before creating a release wheel so that `static/index.html`
31
+ and `static/assets/` are included as package data.
@@ -0,0 +1,100 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "trailhead-edu"
7
+ version = "0.2.0"
8
+ description = "A local web environment for learning and exploring Python programs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Kris Jordan", email = "kris@cs.unc.edu" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Web Environment",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: Microsoft :: Windows",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ ]
28
+ dependencies = [
29
+ "cachetools>=5.3,<7",
30
+ "fastapi>=0.108,<1",
31
+ "pydantic>=2.5,<3",
32
+ "uvicorn>=0.25,<1",
33
+ "watchdog>=3,<7",
34
+ "websockets>=12,<17",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ student = [
39
+ "ipykernel>=6.28",
40
+ "matplotlib>=3.8",
41
+ "numpy>=1.26",
42
+ "pandas>=2.1",
43
+ "pillow>=10.1",
44
+ "seaborn>=0.13",
45
+ "six>=1.16",
46
+ "svgwrite>=1.4",
47
+ ]
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "mypy>=1.7",
52
+ "pytest>=7.4",
53
+ "pytest-asyncio>=0.23",
54
+ "pytest-json-report>=1.5",
55
+ "ruff>=0.9",
56
+ "types-cachetools>=5.3",
57
+ ]
58
+
59
+ [project.scripts]
60
+ trailhead = "trailhead.__main__:main"
61
+
62
+ [project.urls]
63
+ Homepage = "https://github.com/KrisJordan/trailhead"
64
+ Issues = "https://github.com/KrisJordan/trailhead/issues"
65
+ Repository = "https://github.com/KrisJordan/trailhead.git"
66
+
67
+ [tool.setuptools]
68
+ include-package-data = true
69
+
70
+ [tool.setuptools.packages.find]
71
+ where = ["src"]
72
+ include = ["trailhead*"]
73
+
74
+ [tool.setuptools.package-data]
75
+ trailhead = [
76
+ "static/*",
77
+ "static/**/*",
78
+ ]
79
+
80
+ [tool.pytest.ini_options]
81
+ addopts = "-ra"
82
+ testpaths = ["tests"]
83
+ asyncio_mode = "auto"
84
+
85
+ [tool.mypy]
86
+ python_version = "3.11"
87
+ files = ["src/trailhead", "tests"]
88
+ check_untyped_defs = true
89
+ no_implicit_optional = true
90
+ show_error_codes = true
91
+ warn_redundant_casts = true
92
+ warn_unused_ignores = true
93
+
94
+ [tool.ruff]
95
+ target-version = "py311"
96
+ line-length = 88
97
+
98
+ [tool.ruff.format]
99
+ indent-style = "space"
100
+ quote-style = "double"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Trailhead's Python teaching server."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,117 @@
1
+ """Command-line entry point for the Trailhead server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ import sys
8
+ from typing import Sequence
9
+
10
+ import uvicorn
11
+
12
+ from . import __version__
13
+ from .project import set_project_root
14
+ from .websocket_origin import (
15
+ ALLOWED_ORIGINS_ENV,
16
+ add_allowed_websocket_origins,
17
+ )
18
+
19
+ PACKAGE_DIR = Path(__file__).resolve().parent
20
+
21
+
22
+ def _port(value: str) -> int:
23
+ port = int(value)
24
+ if not 1 <= port <= 65535:
25
+ raise argparse.ArgumentTypeError("port must be between 1 and 65535")
26
+ return port
27
+
28
+
29
+ def _url_host(host: str) -> str:
30
+ """Format a bind host for display in an HTTP URL."""
31
+
32
+ if ":" in host and not host.startswith("["):
33
+ return f"[{host}]"
34
+ return host
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ """Build Trailhead's command-line argument parser."""
39
+
40
+ parser = argparse.ArgumentParser(description="Run the Trailhead server")
41
+ parser.add_argument(
42
+ "--host",
43
+ default="127.0.0.1",
44
+ help="interface to bind (default: 127.0.0.1)",
45
+ )
46
+ parser.add_argument(
47
+ "--port",
48
+ "-p",
49
+ type=_port,
50
+ default=1110,
51
+ help="TCP port to bind (default: 1110)",
52
+ )
53
+ parser.add_argument(
54
+ "--root",
55
+ type=Path,
56
+ default=Path.cwd(),
57
+ help="project containing the Python modules to serve (default: current directory)",
58
+ )
59
+ parser.add_argument(
60
+ "--reload",
61
+ action="store_true",
62
+ help="restart when Trailhead server source files change",
63
+ )
64
+ parser.add_argument(
65
+ "--log-level",
66
+ choices=("critical", "error", "warning", "info", "debug", "trace"),
67
+ default="info",
68
+ help="Uvicorn log level (default: info)",
69
+ )
70
+ parser.add_argument(
71
+ "--allow-origin",
72
+ action="append",
73
+ default=[],
74
+ metavar="ORIGIN",
75
+ help=(
76
+ "allow an exact browser WebSocket origin (repeatable; additional "
77
+ f"origins may be set with {ALLOWED_ORIGINS_ENV})"
78
+ ),
79
+ )
80
+ parser.add_argument("--version", action="version", version=__version__)
81
+ return parser
82
+
83
+
84
+ def main(argv: Sequence[str] | None = None) -> int:
85
+ """Run Trailhead using an installable, host-independent configuration."""
86
+
87
+ args = build_parser().parse_args(argv)
88
+ try:
89
+ project_root = set_project_root(args.root)
90
+ add_allowed_websocket_origins(args.allow_origin)
91
+ except (OSError, ValueError) as error:
92
+ print(f"trailhead: {error}", file=sys.stderr)
93
+ return 2
94
+
95
+ print(f"Serving project {project_root}")
96
+ print(f"Starting Trailhead server at http://{_url_host(args.host)}:{args.port}")
97
+ print("Press Ctrl+C to stop the Trailhead server")
98
+
99
+ options: dict[str, object] = {
100
+ "host": args.host,
101
+ "port": args.port,
102
+ "log_level": args.log_level,
103
+ }
104
+ if args.reload:
105
+ options.update(
106
+ reload=True,
107
+ reload_dirs=[str(PACKAGE_DIR)],
108
+ )
109
+
110
+ # An import string works both normally and in Uvicorn's spawned reload
111
+ # worker. Any startup/import failure remains visible to the caller.
112
+ uvicorn.run("trailhead.app:app", **options) # type: ignore[arg-type]
113
+ return 0
114
+
115
+
116
+ if __name__ == "__main__":
117
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Static analysis helpers for Python source modules."""
@@ -0,0 +1,206 @@
1
+ """Tools for analyzing Python code."""
2
+
3
+ from _ast import AsyncFor
4
+ import argparse
5
+ import ast
6
+ import tokenize
7
+ from typing import Any, Dict
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class Parameter(BaseModel):
12
+ name: str
13
+ type: str
14
+
15
+
16
+ class Function(BaseModel):
17
+ name: str
18
+ doc: str
19
+ parameters: list[Parameter]
20
+ return_type: str
21
+ source: str
22
+
23
+
24
+ class Module(BaseModel):
25
+ name: str
26
+ doc: str
27
+ top_level_functions: list[Function]
28
+ top_level_calls: list[str]
29
+ global_vars: Dict[str, Any]
30
+
31
+
32
+ def main() -> None:
33
+ parser = argparse.ArgumentParser(description="Analyze Python Code")
34
+ parser.add_argument("filepath", help="The Python file to analyze")
35
+ args = parser.parse_args()
36
+ file = args.filepath
37
+ with tokenize.open(file) as source:
38
+ tree = ast.parse(source.read())
39
+ module = get_module(file, tree)
40
+ print(module)
41
+
42
+
43
+ def analyze_module(file: str) -> Module:
44
+ with tokenize.open(file) as source:
45
+ try:
46
+ tree = ast.parse(source.read())
47
+ return get_module(file, tree)
48
+ except Exception as e:
49
+ return Module(
50
+ name=file,
51
+ doc=f"{type(e).__name__} encountered when parsing",
52
+ top_level_functions=[],
53
+ top_level_calls=[],
54
+ global_vars={},
55
+ )
56
+
57
+
58
+ def get_module(path: str, tree: ast.Module) -> Module:
59
+ return Module(
60
+ name=path,
61
+ doc=ast.get_docstring(tree) or "",
62
+ top_level_functions=get_module_function_definitions(tree),
63
+ top_level_calls=get_top_level_function_calls(tree),
64
+ global_vars=extract_global_vars(tree),
65
+ )
66
+
67
+
68
+ def extract_global_vars(tree: ast.Module) -> Dict[str, Any]:
69
+ assignment_nodes: list[ast.Assign | ast.AnnAssign] = []
70
+ for n in tree.body:
71
+ if isinstance(n, ast.Assign) and isinstance(n.targets[0], ast.Name):
72
+ assignment_nodes.append(n)
73
+ elif isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name):
74
+ assignment_nodes.append(n)
75
+
76
+ variable_dict = {}
77
+ for node in assignment_nodes:
78
+ if isinstance(node, ast.AnnAssign):
79
+ if not isinstance(node.target, ast.Name):
80
+ continue
81
+ ref = node.target.id
82
+ value = node.value
83
+ else:
84
+ target = node.targets[0]
85
+ if not isinstance(target, ast.Name):
86
+ continue
87
+ ref = target.id
88
+ value = node.value
89
+
90
+ if isinstance(value, (ast.Constant, ast.List, ast.Tuple)):
91
+ assignment_value = ast.literal_eval(value)
92
+ elif isinstance(value, ast.Dict):
93
+ assignment_value = {}
94
+ for key, val in zip(value.keys, value.values):
95
+ if key is None:
96
+ continue
97
+ if isinstance(val, (ast.Constant, ast.List, ast.Tuple, ast.Dict)):
98
+ assignment_value[ast.literal_eval(key)] = ast.literal_eval(val)
99
+ else:
100
+ assignment_value[ast.literal_eval(key)] = str(val)
101
+ else:
102
+ assignment_value = None
103
+
104
+ variable_dict[ref] = assignment_value
105
+
106
+ return variable_dict
107
+
108
+
109
+ def get_module_function_definitions(tree: ast.AST) -> list[Function]:
110
+ class FunctionCollector(ast.NodeVisitor):
111
+ def __init__(self):
112
+ self.functions: list[Function] = []
113
+
114
+ def visit_ClassDef(self, node: ast.ClassDef):
115
+ # Not looking for method definitions
116
+ ...
117
+
118
+ def visit_FunctionDef(self, node: ast.FunctionDef):
119
+ ast_params = node.args.args
120
+ parameters = [
121
+ Parameter(name=param.arg, type=self._get_param_type(param.annotation))
122
+ for param in ast_params
123
+ ]
124
+ self.functions.append(
125
+ Function(
126
+ name=node.name,
127
+ doc=ast.get_docstring(node) or "",
128
+ parameters=parameters,
129
+ return_type=self._get_return_type(node.returns),
130
+ source=ast.unparse(node),
131
+ )
132
+ )
133
+
134
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
135
+ return self.visit_FunctionDef(node) # type: ignore
136
+
137
+ def _get_param_type(self, node: ast.expr | None) -> str:
138
+ if node is None:
139
+ return "Any"
140
+ elif isinstance(node, ast.Name):
141
+ return node.id
142
+ elif isinstance(node, ast.Constant):
143
+ return str(node.value)
144
+ else:
145
+ return "UnsupportedParamType"
146
+
147
+ def _get_return_type(self, node: ast.expr | None) -> str:
148
+ if isinstance(node, ast.Name):
149
+ return node.id
150
+ elif isinstance(node, ast.Constant):
151
+ return str(node.value)
152
+ else:
153
+ return "UnsupportedReturnType"
154
+
155
+ function_collector = FunctionCollector()
156
+ function_collector.visit(tree)
157
+ return function_collector.functions
158
+
159
+
160
+ def get_top_level_function_calls(tree: ast.AST) -> list[str]:
161
+ class FunctionCallCollector(ast.NodeVisitor):
162
+ def __init__(self):
163
+ self.function_calls: list[str] = []
164
+
165
+ def visit_Call(self, node: ast.Call):
166
+ self.function_calls.append(ast.unparse(node.func))
167
+
168
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
169
+ # Only looking for top-level function calls
170
+ ...
171
+
172
+ def visit_FunctionDef(self, node: ast.FunctionDef):
173
+ # Only looking for top-level function calls
174
+ ...
175
+
176
+ def visit_For(self, node: ast.For):
177
+ # Only looking for top-level function calls
178
+ ...
179
+
180
+ def visit_AsyncFor(self, node: AsyncFor) -> Any:
181
+ # Only looking for top-level function calls
182
+ ...
183
+
184
+ def visit_If(self, node: ast.If):
185
+ # Only looking for top-level function calls
186
+ ...
187
+
188
+ def visit_While(self, node: ast.While):
189
+ # Only looking for top-level function calls
190
+ ...
191
+
192
+ def visit_AsyncWith(self, node: ast.AsyncWith):
193
+ # Only looking for top-level function calls
194
+ ...
195
+
196
+ def visit_Match(self, node: ast.Match):
197
+ # Only looking for top-level function calls
198
+ ...
199
+
200
+ function_call_collector = FunctionCallCollector()
201
+ function_call_collector.visit(tree)
202
+ return function_call_collector.function_calls
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()