pyaether-cli 0.0.3__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.
aether_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.3"
aether_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,110 @@
1
+ from pathlib import Path
2
+
3
+ from aether import render
4
+ from bs4 import BeautifulSoup
5
+ from rich.console import Console
6
+
7
+ from .utils import load_build_function_instance
8
+
9
+
10
+ def _update_paths_in_soup(
11
+ console: Console,
12
+ soup: BeautifulSoup,
13
+ prefix: str,
14
+ static_assets_dir: Path,
15
+ static_css_dir: Path,
16
+ static_js_dir: Path,
17
+ output_dir: Path,
18
+ verbose: bool = False,
19
+ ) -> BeautifulSoup:
20
+ if verbose:
21
+ console.print("Updating paths in HTML...")
22
+
23
+ # Update link, img, script & a tags
24
+ for tag_name, attribute in [
25
+ ("link", "href"),
26
+ ("img", "src"),
27
+ ("script", "src"),
28
+ ("a", "href"),
29
+ ]:
30
+ for tag in soup.find_all(tag_name):
31
+ if attribute in tag.attrs:
32
+ if not tag[attribute].startswith(("http://", "https://", "/")):
33
+ old_path = Path(tag[attribute])
34
+ if Path("styles") in old_path.parents:
35
+ new_path = static_css_dir / old_path.relative_to("styles")
36
+ elif Path("js_scripts") in old_path.parents:
37
+ new_path = static_js_dir / old_path.relative_to("js_scripts")
38
+ elif Path("assets") in old_path.parents:
39
+ new_path = static_assets_dir / old_path.relative_to("assets")
40
+ elif Path("public") in old_path.parents:
41
+ new_path = output_dir / old_path.relative_to("public")
42
+ elif ".css" in old_path.suffixes:
43
+ new_path = static_css_dir / old_path.name
44
+ else:
45
+ # Note: Need to find a better way to handle pages condition. Right now, we are just shoving it in this else block.
46
+ # Note: Assuming the stem in href is same as the page_name mentioned in the config.
47
+ if ".html" in old_path.suffixes:
48
+ new_path = output_dir / old_path.relative_to(".")
49
+ else:
50
+ new_path = output_dir / old_path.name
51
+
52
+ new_path = new_path.relative_to(output_dir)
53
+ if prefix:
54
+ new_path = prefix / new_path
55
+
56
+ if verbose:
57
+ console.print(
58
+ f"Updating {attribute}: {old_path} -> /{new_path}"
59
+ )
60
+ tag[attribute] = f"/{new_path}"
61
+
62
+ return soup
63
+
64
+
65
+ def builder(
66
+ console: Console,
67
+ output_html_file_name: str,
68
+ output_dir: Path,
69
+ prefix: str,
70
+ file_target: Path,
71
+ function_target: str,
72
+ static_assets_dir: Path,
73
+ static_css_dir: Path,
74
+ static_js_dir: Path,
75
+ verbose: bool = False,
76
+ ) -> None:
77
+ if verbose:
78
+ console.print(
79
+ f"Loading build function instance for '{output_html_file_name}.html'..."
80
+ )
81
+ instance = load_build_function_instance(file_target, function_target)
82
+
83
+ if verbose:
84
+ console.print("Rendering HTML...")
85
+
86
+ rendered_html = render(instance())
87
+ soup = BeautifulSoup(rendered_html, "html.parser")
88
+ updated_soup = _update_paths_in_soup(
89
+ console=console,
90
+ soup=soup,
91
+ prefix=prefix,
92
+ output_dir=output_dir,
93
+ static_assets_dir=static_assets_dir,
94
+ static_css_dir=static_css_dir,
95
+ static_js_dir=static_js_dir,
96
+ verbose=verbose,
97
+ )
98
+
99
+ output_html_path = output_dir / output_html_file_name
100
+ if not output_html_path.parent.exists():
101
+ if verbose:
102
+ console.print(f"Creating routing directory: {output_html_path.parent}")
103
+
104
+ output_html_path.parent.mkdir(parents=True)
105
+
106
+ if verbose:
107
+ console.print("Writing final HTML to file...")
108
+
109
+ with open(output_html_path, "w", encoding="utf-8") as file:
110
+ file.write(updated_soup.decode(pretty_print=True, formatter="html5"))
aether_cli/cli.py ADDED
@@ -0,0 +1,197 @@
1
+ import shutil
2
+ import signal
3
+ import threading
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+ from rich.live import Live
9
+ from rich.spinner import Spinner
10
+ from rich.table import Table
11
+
12
+ from . import __version__
13
+ from .build_process import builder
14
+ from .configs import configs
15
+ from .run_server import tcp_server
16
+ from .utils import get_local_ip
17
+
18
+
19
+ @click.group()
20
+ def main() -> None:
21
+ pass
22
+
23
+
24
+ @main.command()
25
+ def version():
26
+ console = Console()
27
+ console.print(f"Aether CLI version: [green]{__version__}[/green]")
28
+
29
+
30
+ @main.command()
31
+ @click.option(
32
+ "--prefix",
33
+ type=str,
34
+ default="",
35
+ help="Inject 'base_path' prefix in the generated HTML file.",
36
+ )
37
+ @click.option("--verbose", is_flag=True, help="Enable verbose mode to echo steps.")
38
+ def build(prefix: str, verbose: bool) -> None:
39
+ console = Console()
40
+
41
+ def _create_dir_if_not_exists(directory: Path) -> None:
42
+ if not directory.exists():
43
+ if verbose:
44
+ console.print(f"Creating directory: {directory}")
45
+ directory.mkdir(parents=True)
46
+
47
+ _create_dir_if_not_exists(configs.build_config.output_dir)
48
+
49
+ static_dir = configs.build_config.output_dir / "static"
50
+ # _create_dir_if_not_exists(configs.build_config.output_dir)
51
+
52
+ static_css_dir = static_dir / "css"
53
+ _create_dir_if_not_exists(static_css_dir)
54
+
55
+ static_js_dir = static_dir / "js"
56
+ _create_dir_if_not_exists(static_js_dir)
57
+
58
+ static_assets_dir = static_dir / "assets"
59
+ _create_dir_if_not_exists(static_assets_dir)
60
+
61
+ def _copy_dir(src: Path | None, dest: Path, directory_name: str) -> None:
62
+ if src and src.exists():
63
+ if verbose:
64
+ console.print(f"Copying {directory_name} from {src} to {dest}")
65
+ shutil.copytree(src, dest, dirs_exist_ok=True)
66
+ else:
67
+ console.print(f"Project doesn't have a '{src}' directory.")
68
+
69
+ _copy_dir(configs.static_content_config.assets_dir, static_assets_dir, "assets")
70
+ _copy_dir(configs.static_content_config.styles_dir, static_css_dir, "styles")
71
+ _copy_dir(configs.static_content_config.js_scripts_dir, static_js_dir, "js_scripts")
72
+ _copy_dir(
73
+ configs.static_content_config.public_dir,
74
+ configs.build_config.output_dir,
75
+ "public",
76
+ )
77
+
78
+ if verbose:
79
+ console.print("Building Index HTML...")
80
+
81
+ builder(
82
+ console=console,
83
+ output_html_file_name="index.html",
84
+ output_dir=configs.build_config.output_dir,
85
+ prefix=prefix,
86
+ file_target=configs.build_config.index_page_file_target,
87
+ function_target=configs.build_config.index_page_function_target,
88
+ static_assets_dir=static_assets_dir,
89
+ static_css_dir=static_css_dir,
90
+ static_js_dir=static_js_dir,
91
+ verbose=verbose,
92
+ )
93
+
94
+ if configs.build_config.pages_file_targets:
95
+ if verbose:
96
+ console.print("Building Pages...")
97
+
98
+ for file_target, function_target, page_name in zip(
99
+ configs.build_config.pages_file_targets,
100
+ configs.build_config.pages_function_targets,
101
+ configs.build_config.pages_names,
102
+ strict=False,
103
+ ):
104
+ builder(
105
+ console=console,
106
+ output_html_file_name=f"{page_name}.html",
107
+ output_dir=configs.build_config.output_dir,
108
+ prefix=prefix,
109
+ file_target=file_target,
110
+ function_target=function_target,
111
+ static_assets_dir=static_assets_dir,
112
+ static_css_dir=static_css_dir,
113
+ static_js_dir=static_js_dir,
114
+ verbose=verbose,
115
+ )
116
+
117
+ console.print("\n[bold green]Build successful![/bold green]")
118
+
119
+
120
+ @main.command()
121
+ def run() -> None:
122
+ console = Console()
123
+ interrupt_event = threading.Event()
124
+
125
+ if not configs.build_config.output_dir.exists():
126
+ console.print("[bold red]Build directory not found.[/bold red]")
127
+ console.print("Please run 'pytempl-cli build' first.")
128
+ return
129
+
130
+ try:
131
+ server = tcp_server(
132
+ console=console,
133
+ host=configs.run_config.host,
134
+ port=configs.run_config.port,
135
+ directory_path=configs.build_config.output_dir,
136
+ )
137
+ except OSError:
138
+ console.print(
139
+ f"[bold red]Error: Could not bind to address http://{configs.run_config.host if configs.run_config.host is not None else '127.0.0.1'}:{configs.run_config.port}.[/bold red]"
140
+ )
141
+ console.print(f"Is port {configs.run_config.port} already in use?")
142
+ return
143
+
144
+ def shutdown_handler(signum, _frame):
145
+ """Gracefully shut down the server."""
146
+ signal_name = signal.Signals(signum).name
147
+ console.print(
148
+ f"\n[bold yellow]Received {signal_name}. Shutting down server...[/bold yellow]"
149
+ )
150
+
151
+ server.shutdown()
152
+ interrupt_event.set()
153
+
154
+ # original_sigint = signal.getsignal(signal.SIGINT)
155
+ # original_sigterm = signal.getsignal(signal.SIGTERM)
156
+
157
+ signal.signal(signal.SIGINT, shutdown_handler)
158
+ signal.signal(signal.SIGTERM, shutdown_handler)
159
+
160
+ with Live(
161
+ Spinner("dots", text=" Starting server..."), console=console, transient=True
162
+ ):
163
+ server_thread = threading.Thread(target=server.serve_forever)
164
+ server_thread.daemon = True
165
+ server_thread.start()
166
+
167
+ info_table = Table.grid(padding=(0, 1))
168
+ info_table.add_column()
169
+ info_table.add_column()
170
+
171
+ info_table.add_row("✓", "[bold green]Aether App[/bold green] is running!")
172
+ info_table.add_row(
173
+ " ",
174
+ f"[cyan]Local:[/cyan] http://{configs.run_config.host if configs.run_config.host is not None else '127.0.0.1'}:{configs.run_config.port}",
175
+ )
176
+
177
+ local_ip = get_local_ip()
178
+ if local_ip:
179
+ info_table.add_row(
180
+ " ",
181
+ f"[cyan]Network:[/cyan] http://{local_ip}:{configs.run_config.port}",
182
+ )
183
+
184
+ console.print(info_table)
185
+ console.print("\nPress CTRL+C to stop the server.\n")
186
+
187
+ try:
188
+ interrupt_event.wait()
189
+ finally:
190
+ # --- Cleanup: Restore original signal handlers ---
191
+ console.print("[bold red]Server has been shut down.[/bold red]")
192
+ signal.signal(signal.SIGINT, signal.getsignal(signal.SIGINT))
193
+ signal.signal(signal.SIGTERM, signal.getsignal(signal.SIGTERM))
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
aether_cli/configs.py ADDED
@@ -0,0 +1,108 @@
1
+ from pathlib import Path
2
+ from typing import Self
3
+
4
+ from pydantic import BaseModel, model_validator
5
+
6
+ try:
7
+ import tomllib # Python 3.11+
8
+ except ImportError:
9
+ import tomli as tomllib # type: ignore[import]
10
+
11
+
12
+ class BuildConfig(BaseModel):
13
+ index_page_file_target: Path
14
+ index_page_function_target: str
15
+
16
+ output_dir: Path
17
+
18
+ pages_file_targets: list[Path]
19
+ pages_function_targets: list[str]
20
+ pages_names: list[str]
21
+
22
+ @model_validator(mode="after")
23
+ def validate_attrs(self) -> Self:
24
+ if len(self.pages_file_targets) != len(self.pages_function_targets):
25
+ raise ValueError(
26
+ "Each 'pages_file_target' must have a corresponding 'pages_function_target'."
27
+ )
28
+ if len(self.pages_file_targets) != len(self.pages_names):
29
+ raise ValueError(
30
+ "Each 'pages_file_target' must have a corresponding 'pages_name'."
31
+ )
32
+
33
+ return self
34
+
35
+
36
+ class RunConfig(BaseModel):
37
+ host: str | None
38
+ port: int
39
+
40
+
41
+ class StaticContentConfig(BaseModel):
42
+ assets_dir: Path | None
43
+ js_scripts_dir: Path | None
44
+ public_dir: Path | None
45
+ styles_dir: Path | None
46
+
47
+
48
+ class Config(BaseModel):
49
+ build_config: BuildConfig
50
+ run_config: RunConfig
51
+ static_content_config: StaticContentConfig
52
+
53
+
54
+ def load_configs() -> Config:
55
+ pyproject_file = Path("pyproject.toml")
56
+ if not pyproject_file.exists():
57
+ raise FileNotFoundError("Unable to locate configuration file.")
58
+
59
+ with open(pyproject_file, "rb") as file:
60
+ raw_aether_config: dict = tomllib.load(file).get("tool", {}).get("aether", {})
61
+
62
+ raw_aether_build_config: dict = raw_aether_config.get("build", {})
63
+ raw_aether_run_config: dict = raw_aether_config.get("run", {})
64
+ raw_aether_static_content_config: dict = raw_aether_config.get(
65
+ "static_content", {}
66
+ )
67
+
68
+ # Decompose raw 'build' configurations.
69
+ # TODO: Add fallbacks/defaults if the lengths of file_targets and function_targets are different
70
+ parsed_build_config = BuildConfig(
71
+ index_page_file_target=raw_aether_build_config.get("index_page", {}).get(
72
+ "file_target", "main.py"
73
+ ),
74
+ index_page_function_target=raw_aether_build_config.get("index_page", {}).get(
75
+ "function_target", "main"
76
+ ),
77
+ output_dir=raw_aether_build_config.get("output_dir", "build/"),
78
+ pages_file_targets=raw_aether_build_config.get("pages", {}).get(
79
+ "file_targets", []
80
+ ),
81
+ pages_function_targets=raw_aether_build_config.get("pages", {}).get(
82
+ "function_targets", []
83
+ ),
84
+ pages_names=raw_aether_build_config.get("pages", {}).get("names", []),
85
+ )
86
+
87
+ # Decompose raw 'run' configurations.
88
+ parsed_run_config = RunConfig(
89
+ host=raw_aether_run_config.get("host", None),
90
+ port=raw_aether_run_config.get("port", 8080),
91
+ )
92
+
93
+ # Decompose raw 'static content' configurations.
94
+ parsed_static_content_config = StaticContentConfig(
95
+ assets_dir=raw_aether_static_content_config.get("assets_dir"),
96
+ js_scripts_dir=raw_aether_static_content_config.get("js_scripts_dir"),
97
+ public_dir=raw_aether_static_content_config.get("public_dir"),
98
+ styles_dir=raw_aether_static_content_config.get("styles_dir"),
99
+ )
100
+
101
+ return Config(
102
+ build_config=parsed_build_config,
103
+ run_config=parsed_run_config,
104
+ static_content_config=parsed_static_content_config,
105
+ )
106
+
107
+
108
+ configs = load_configs()
@@ -0,0 +1,44 @@
1
+ import http.server
2
+ import socketserver
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+
8
+
9
+ def request_handler(console: Console, directory_path: str):
10
+ class RichHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
11
+ def __init__(self, *args, **kwargs):
12
+ super().__init__(*args, directory=directory_path, **kwargs)
13
+
14
+ def log_message(self, format, *args):
15
+ status_code = args[1]
16
+ request_line = args[0]
17
+
18
+ if status_code.startswith("2"):
19
+ status_color = "green"
20
+ elif status_code.startswith("3"):
21
+ status_color = "yellow"
22
+ else:
23
+ status_color = "red"
24
+
25
+ timestamp = f"[[dim]{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}[/dim]]"
26
+
27
+ console.print(
28
+ f"{timestamp} INFO:\t"
29
+ f'{self.address_string()} - "{request_line}" '
30
+ f"[{status_color}]{status_code}[/{status_color}]"
31
+ )
32
+
33
+ return RichHTTPRequestHandler
34
+
35
+
36
+ def tcp_server(
37
+ console: Console, host: str | None, port: int, directory_path: Path
38
+ ) -> socketserver.TCPServer:
39
+ handler_factory = request_handler(console, str(directory_path))
40
+
41
+ if host is None:
42
+ host = ""
43
+
44
+ return socketserver.TCPServer((host, port), handler_factory)
aether_cli/utils.py ADDED
@@ -0,0 +1,50 @@
1
+ import importlib
2
+ import importlib.util
3
+ import socket
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ class ImportFromStringError(Exception):
10
+ pass
11
+
12
+
13
+ def load_build_function_instance(file_target: str, function_target: str) -> Any:
14
+ working_dir = Path.cwd()
15
+ working_file = Path(file_target)
16
+
17
+ if not working_file.exists():
18
+ raise FileNotFoundError(f"File '{file_target}' not found.")
19
+
20
+ module_path = working_dir / file_target
21
+ module_name = Path(file_target).stem
22
+
23
+ sys.path.append(str(working_dir))
24
+
25
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
26
+ if spec and spec.loader:
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ else:
30
+ raise ImportFromStringError(
31
+ f"Could not import module from path '{module_path}'"
32
+ )
33
+
34
+ try:
35
+ instance = getattr(module, function_target)
36
+ except AttributeError:
37
+ message = f'Attribute "{function_target}" not found in module "{module_name}".'
38
+ raise ImportFromStringError(message)
39
+
40
+ return instance
41
+
42
+
43
+ def get_local_ip():
44
+ """Tries to find the local network IP address of the machine."""
45
+ try:
46
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
47
+ s.connect(("8.8.8.8", 80))
48
+ return s.getsockname()[0]
49
+ except Exception:
50
+ return None
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyaether-cli
3
+ Version: 0.0.3
4
+ Summary: A CLI to build and run dev server for Aether apps.
5
+ Project-URL: homepage, https://github.com/pyaether/aether-cli
6
+ Project-URL: repository, https://github.com/pyaether/aether-cli
7
+ Project-URL: documentation, https://github.com/pyaether/aether-cli
8
+ Author-email: Saurabh Ghanekar <ghanekarsaurabh8@gmail.com>
9
+ License-Expression: BSD-2-Clause
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: beautifulsoup4>=4.13
14
+ Requires-Dist: click>=8.1
15
+ Requires-Dist: pyaether
16
+ Requires-Dist: pydantic>=2.10
17
+ Requires-Dist: rich>=13.9
18
+ Requires-Dist: typing-extensions>=4
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Aether CLI
22
+
23
+ Build and run your Aether apps as well as manage your Altar UI components from the command line.
@@ -0,0 +1,11 @@
1
+ aether_cli/__init__.py,sha256=4GZKi13lDTD25YBkGakhZyEQZWTER_OWQMNPoH_UM2c,22
2
+ aether_cli/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ aether_cli/build_process.py,sha256=4Dl-rM0O7RN2QzL0FHhBTxENSuv0yts-Q58sNkZWRQc,3874
4
+ aether_cli/cli.py,sha256=H06Yu21EgkK-nHYfORC-LtLZXUTA2DVGdm5wDG9Pxuw,6377
5
+ aether_cli/configs.py,sha256=5wSRoDEfvR-HxCfQoLB0-FhQJVnHVPf9vI0Y_6iEwUc,3506
6
+ aether_cli/run_server.py,sha256=19700YKa6WPancZLIkvgpp4-dhECHtc4IEiwIRjXMto,1346
7
+ aether_cli/utils.py,sha256=xKUvgSxp8oNddaTC7mlra_LDBqyUrOCiW7nFKiarJ-Y,1393
8
+ pyaether_cli-0.0.3.dist-info/METADATA,sha256=Q-LOLSEw2H5Ll2gJxQeCykcxioqe6kKpev-UvyPYSmM,850
9
+ pyaether_cli-0.0.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ pyaether_cli-0.0.3.dist-info/entry_points.txt,sha256=zbcg3c2Ry_eTiyEbTMGMAGnl_MpbS68oZ1q-Jm6rp-M,47
11
+ pyaether_cli-0.0.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aether = aether_cli.cli:main