pythonanywhere-clis 1.0.0__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.
@@ -0,0 +1,141 @@
1
+ import re
2
+
3
+ import requests
4
+ import websocket
5
+ from bs4 import BeautifulSoup
6
+
7
+ from pa_cli.exceptions import APIError, AuthError, NetworkError
8
+
9
+
10
+ class ConsoleCrawler:
11
+ def __init__(self, host: str = "www.pythonanywhere.com"):
12
+ self.base_url = f"https://{host}"
13
+ self.session = requests.Session()
14
+ self.session.headers.update({
15
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
16
+ })
17
+
18
+ def login(self, username: str, password: str) -> bool:
19
+ login_url = f"{self.base_url}/login/"
20
+
21
+ try:
22
+ login_page_resp = self.session.get(login_url)
23
+ login_page_resp.raise_for_status()
24
+ except requests.RequestException as e:
25
+ raise NetworkError(f"Failed to fetch login page: {e}") from e
26
+
27
+ soup = BeautifulSoup(login_page_resp.text, "html.parser")
28
+ csrf_input = soup.find("input", {"name": "csrfmiddlewaretoken"})
29
+ if csrf_input is None:
30
+ raise APIError("CSRF token not found on login page")
31
+
32
+ data = {
33
+ "csrfmiddlewaretoken": csrf_input["value"],
34
+ "auth-username": username,
35
+ "auth-password": password,
36
+ "login_view-current_step": "auth",
37
+ }
38
+
39
+ headers = {
40
+ "Referer": login_url,
41
+ "Origin": self.base_url,
42
+ }
43
+
44
+ try:
45
+ login_resp = self.session.post(login_url, data=data, headers=headers)
46
+ login_resp.raise_for_status()
47
+ except requests.RequestException as e:
48
+ raise NetworkError(f"Login request failed: {e}") from e
49
+
50
+ if "/login/" in login_resp.url:
51
+ raise AuthError("Login failed. Check your username and password.")
52
+
53
+ return True
54
+
55
+ def list(self, username: str) -> list:
56
+ url = f"{self.base_url}/api/v0/user/{username}/consoles/"
57
+
58
+ try:
59
+ resp = self.session.get(url)
60
+ resp.raise_for_status()
61
+ except requests.RequestException as e:
62
+ raise NetworkError(f"Failed to list consoles: {e}") from e
63
+
64
+ return resp.json()
65
+
66
+ def create(self, username: str, executable: str = "bash") -> dict:
67
+ csrftoken = self.session.cookies.get("csrftoken")
68
+ if not csrftoken:
69
+ raise APIError("CSRF token not found in session cookies")
70
+
71
+ url = f"{self.base_url}/api/v0/user/{username}/consoles/"
72
+ headers = {
73
+ "Referer": f"{self.base_url}/user/{username}/consoles/",
74
+ "X-CSRFToken": csrftoken,
75
+ }
76
+
77
+ try:
78
+ resp = self.session.post(url, json={"executable": executable}, headers=headers)
79
+ resp.raise_for_status()
80
+ except requests.RequestException as e:
81
+ raise NetworkError(f"Failed to create console: {e}") from e
82
+
83
+ return resp.json()
84
+
85
+ def delete(self, username: str, console_id: int) -> None:
86
+ csrftoken = self.session.cookies.get("csrftoken")
87
+ if not csrftoken:
88
+ raise APIError("CSRF token not found in session cookies")
89
+
90
+ url = f"{self.base_url}/api/v0/user/{username}/consoles/{console_id}/"
91
+ headers = {
92
+ "Referer": f"{self.base_url}/user/{username}/consoles/",
93
+ "X-CSRFToken": csrftoken,
94
+ }
95
+
96
+ try:
97
+ resp = self.session.delete(url, headers=headers)
98
+ resp.raise_for_status()
99
+ except requests.RequestException as e:
100
+ raise NetworkError(f"Failed to delete console: {e}") from e
101
+
102
+ def get_or_create(self, username: str, executable: str = "bash") -> int:
103
+ consoles = self.list(username)
104
+
105
+ if len(consoles) >= 2:
106
+ self.delete(username, consoles[0]["id"])
107
+ consoles = []
108
+
109
+ if consoles:
110
+ return consoles[0]["id"]
111
+
112
+ new_console = self.create(username, executable=executable)
113
+ return new_console["id"]
114
+
115
+ def activate(self, username: str, console_id: int) -> None:
116
+ frame_url = f"{self.base_url}/user/{username}/consoles/{console_id}/frame/"
117
+
118
+ try:
119
+ resp = self.session.get(frame_url)
120
+ resp.raise_for_status()
121
+ except requests.RequestException as e:
122
+ raise NetworkError(f"Failed to fetch console frame page: {e}") from e
123
+
124
+ match = re.search(r'LoadConsole\("([^"]+)",\s*"([^"]+)",\s*"([^"]+)"', resp.text)
125
+ if not match:
126
+ raise APIError("Could not parse WebSocket info from frame page")
127
+
128
+ console_server = match.group(1)
129
+ session_key = match.group(2)
130
+ parsed_console_id = match.group(3)
131
+
132
+ ws = None
133
+ try:
134
+ ws = websocket.create_connection(f"wss://{console_server}/sj/websocket")
135
+ ws.send(f"\x1b[{session_key};{parsed_console_id};;a")
136
+ ws.send("\x1b[8;24;80t")
137
+ except Exception as e:
138
+ raise NetworkError(f"WebSocket connection failed: {e}") from e
139
+ finally:
140
+ if ws:
141
+ ws.close()
pa_cli/exceptions.py ADDED
@@ -0,0 +1,18 @@
1
+ class PACliError(Exception):
2
+ """Base exception for all pa-cli errors."""
3
+
4
+
5
+ class AuthError(PACliError):
6
+ """Authentication failed (login, token, password)."""
7
+
8
+
9
+ class APIError(PACliError):
10
+ """PythonAnywhere API returned an error."""
11
+
12
+
13
+ class NotFoundError(APIError):
14
+ """Requested resource does not exist (404)."""
15
+
16
+
17
+ class NetworkError(PACliError):
18
+ """Network connection failed."""
File without changes
@@ -0,0 +1,215 @@
1
+ import time
2
+ from pathlib import Path
3
+
4
+ import typer
5
+ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeRemainingColumn
6
+
7
+ from pa_cli.api.files import FilesClient
8
+ from pa_cli.api.consoles import ConsolesClient
9
+ from pa_cli.api.webapps import WebappsClient
10
+ from pa_cli.exceptions import PACliError
11
+
12
+ POLL_INTERVAL = 2 # seconds between output checks
13
+ MAX_WAIT = 300 # max seconds to wait for a command
14
+
15
+
16
+ def _wait_for_console(client: ConsolesClient, username: str, console_id: int) -> str:
17
+ """Poll console output until the prompt returns or timeout."""
18
+ elapsed = 0
19
+ last_output = ""
20
+ while elapsed < MAX_WAIT:
21
+ time.sleep(POLL_INTERVAL)
22
+ elapsed += POLL_INTERVAL
23
+ result = client.get_output(username, console_id)
24
+ output = result.get("output", "")
25
+ if output != last_output:
26
+ last_output = output
27
+ # PythonAnywhere bash prompt patterns:
28
+ # - "username@host:~$ " (standard)
29
+ # - "$ " (simple)
30
+ # - ">>> " (Python REPL)
31
+ stripped = output.rstrip()
32
+ if (stripped.endswith("$") or
33
+ stripped.endswith(">>>") or
34
+ stripped.endswith("]$") or
35
+ stripped.endswith("#")):
36
+ break
37
+ return last_output
38
+
39
+
40
+ def collect_files(local_dir: str) -> list:
41
+ """Collect all files in directory recursively."""
42
+ local_path = Path(local_dir)
43
+ return [f for f in local_path.rglob("*") if f.is_file()]
44
+
45
+
46
+ def deploy_preview(account: dict, local_dir: str, domain: str, python_version: str) -> None:
47
+ """Print deploy preview without executing any operations."""
48
+ local_path = Path(local_dir)
49
+ username = account["username"]
50
+ remote_base = f"/home/{username}/{local_path.name}"
51
+
52
+ typer.echo("=== 部署预览 (dry-run) ===\n")
53
+
54
+ # Step 1: File list
55
+ typer.echo("📁 Step 1: 将要上传的文件")
56
+ files = collect_files(local_dir)
57
+ max_display = 20
58
+ for f in files[:max_display]:
59
+ size = f.stat().st_size
60
+ typer.echo(f" - {f.relative_to(local_path)} ({size} bytes)")
61
+ if len(files) > max_display:
62
+ typer.echo(f" ... 还有 {len(files) - max_display} 个文件")
63
+ typer.echo(f" 共 {len(files)} 个文件\n")
64
+
65
+ # Step 2: Environment setup
66
+ typer.echo("📦 Step 2: 环境配置")
67
+ typer.echo(" - 创建 bash console")
68
+ typer.echo(f" - cd {remote_base}")
69
+ if (local_path / "requirements.txt").exists():
70
+ typer.echo(f" - mkvirtualenv {local_path.name} --python=/usr/bin/{python_version}")
71
+ typer.echo(f" - workon {local_path.name} && pip install -r requirements.txt")
72
+ typer.echo()
73
+
74
+ # Step 3: Webapp configuration
75
+ typer.echo("⚙️ Step 3: Webapp 配置")
76
+ typer.echo(f" - 创建/更新 webapp: {domain}")
77
+ typer.echo(f" - 源码目录: {remote_base}")
78
+ typer.echo()
79
+
80
+ # Step 4: Static file mapping
81
+ if (local_path / "static").exists():
82
+ typer.echo("📂 Step 4: 静态文件映射")
83
+ typer.echo(f" - /static/ -> {remote_base}/static")
84
+ typer.echo()
85
+ else:
86
+ typer.echo("📂 Step 4: 无 static 目录,跳过\n")
87
+
88
+ # Step 5: Reload
89
+ typer.echo("🔄 Step 5: Reload webapp\n")
90
+
91
+ typer.echo("以上为预览,使用不带 --dry-run 的命令执行:")
92
+ typer.echo(f" pa deploy {local_dir}")
93
+
94
+
95
+ def deploy(
96
+ local_dir: str,
97
+ username: str,
98
+ token: str,
99
+ host: str,
100
+ domain: str,
101
+ python_version: str = "python310",
102
+ dry_run: bool = False,
103
+ ) -> str:
104
+ local_path = Path(local_dir)
105
+ if not local_path.is_dir():
106
+ raise PACliError(f"{local_dir} is not a directory")
107
+
108
+ if dry_run:
109
+ account = {"username": username}
110
+ deploy_preview(account, local_dir, domain, python_version)
111
+ return ""
112
+
113
+ remote_base = f"/home/{username}/{local_path.name}"
114
+ files_client = FilesClient(token=token, host=host)
115
+ consoles_client = ConsolesClient(token=token, host=host)
116
+ webapps_client = WebappsClient(token=token, host=host)
117
+
118
+ # Step 1: Upload files with progress bar
119
+ print(f"[1/5] Uploading {local_dir} to {remote_base}...")
120
+ try:
121
+ files = collect_files(local_dir)
122
+ with Progress(
123
+ SpinnerColumn(),
124
+ TextColumn("[progress.description]{task.description}"),
125
+ BarColumn(),
126
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
127
+ TextColumn("({task.completed}/{task.total})"),
128
+ TimeRemainingColumn(),
129
+ ) as progress:
130
+ task = progress.add_task("Uploading files...", total=len(files))
131
+ for file in files:
132
+ relative = file.relative_to(local_path)
133
+ remote = f"{remote_base}/{relative}".replace("\\", "/")
134
+ content = file.read_bytes()
135
+ files_client.upload(username, remote, content)
136
+ progress.advance(task)
137
+ print(f" ✓ Uploaded {len(files)} files.")
138
+ except Exception as e:
139
+ print(f" ✗ Upload failed: {e}")
140
+ print(f" Hint: Retry with `pa deploy {local_dir} {domain}`")
141
+ raise
142
+
143
+ # Step 2: Setup environment via console
144
+ print(f"[2/5] Setting up environment...")
145
+ try:
146
+ console = consoles_client.create(username)
147
+ console_id = console["id"]
148
+
149
+ commands = [f"cd {remote_base}"]
150
+ if (local_path / "requirements.txt").exists():
151
+ commands.extend([
152
+ f"mkvirtualenv {local_path.name} --python=/usr/bin/{python_version}",
153
+ f"workon {local_path.name} && pip install -r requirements.txt",
154
+ ])
155
+
156
+ for cmd in commands:
157
+ consoles_client.send_input(username, console_id, cmd + "\n")
158
+ _wait_for_console(consoles_client, username, console_id)
159
+ print(f" ✓ Environment configured.")
160
+ except Exception as e:
161
+ print(f" ✗ Environment setup failed: {e}")
162
+ print(f" Hint: Check requirements.txt and retry.")
163
+ raise
164
+
165
+ # Step 3: Create and configure webapp
166
+ print(f"[3/5] Configuring webapp {domain}...")
167
+ try:
168
+ webapps_client.create(username, domain, python_version)
169
+ except Exception as e:
170
+ if "already exists" not in str(e).lower():
171
+ print(f" ✗ Webapp creation failed: {e}")
172
+ raise
173
+
174
+ try:
175
+ webapps_client.update(
176
+ username,
177
+ domain,
178
+ source_directory=remote_base,
179
+ )
180
+ print(f" ✓ Webapp configured.")
181
+ except Exception as e:
182
+ print(f" ✗ Webapp configuration failed: {e}")
183
+ raise
184
+
185
+ # Step 4: Add static file mapping if static dir exists
186
+ static_local = local_path / "static"
187
+ if static_local.exists():
188
+ print(f"[4/5] Adding static file mapping...")
189
+ try:
190
+ webapps_client.add_static_file(
191
+ username,
192
+ domain,
193
+ url="/static/",
194
+ path=f"{remote_base}/static",
195
+ )
196
+ print(f" ✓ Static files mapped.")
197
+ except Exception as e:
198
+ print(f" ✗ Static file mapping failed: {e}")
199
+ raise
200
+ else:
201
+ print(f"[4/5] No static directory, skipping.")
202
+
203
+ # Step 5: Reload
204
+ print(f"[5/5] Reloading webapp...")
205
+ try:
206
+ webapps_client.reload(username, domain)
207
+ print(f" ✓ Webapp reloaded.")
208
+ except Exception as e:
209
+ print(f" ✗ Reload failed: {e}")
210
+ print(f" Hint: Run `pa webapp reload {domain}` manually.")
211
+ raise
212
+
213
+ url = f"https://{domain}"
214
+ print(f"\nDeploy complete: {url}")
215
+ return url
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: pythonanywhere-clis
3
+ Version: 1.0.0
4
+ Summary: CLI tool for automating PythonAnywhere deployments
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Requires-Dist: typer>=0.9.0
8
+ Requires-Dist: requests>=2.28.0
9
+ Requires-Dist: beautifulsoup4>=4.12.0
10
+ Requires-Dist: websocket-client>=1.6.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+ Requires-Dist: pytest-mock>=3.10; extra == "dev"
14
+ Dynamic: license-file
@@ -0,0 +1,35 @@
1
+ pa_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ pa_cli/config.py,sha256=lspnQqOiy2IQvt3TbluBAFO1TMd_Pd-eVQp5yv_QPKo,9101
3
+ pa_cli/exceptions.py,sha256=8iCO-E0CmMFLPoc8DD42Q_pBwydMr9UTvdZu0v31atI,399
4
+ pa_cli/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ pa_cli/api/always_on.py,sha256=VdZH2fL6xoPm0oW9ZZhPqQ58WEALQz7s4GnMxg9jN54,854
6
+ pa_cli/api/client.py,sha256=2a9HhIEGo2km18Z1ZJ7e8cI8jOOIUrG2cLwUNmt3eGA,1693
7
+ pa_cli/api/consoles.py,sha256=Jlc7ESH-323Jc6U8AzQrtZCzkawxL69YX65nbPyB-Kc,1405
8
+ pa_cli/api/files.py,sha256=QgROylqN0JCMDmms9Vo9skPQ8tH13VepnEvSOvQbRPI,3731
9
+ pa_cli/api/system.py,sha256=jPtVim9yI9HDkW-p8Mp5iw5nvjERWHNv9dxbSk6XZ54,284
10
+ pa_cli/api/tasks.py,sha256=xqbD7pAIJZIoChvFJ5cu3D7FhV60mtNib1pnAo_PdwI,1735
11
+ pa_cli/api/webapps.py,sha256=BqTvWEd4vqGa-nJcLEqOOlspsq6a_mO52eOgn8jqH84,2347
12
+ pa_cli/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ pa_cli/cli/account_cmd.py,sha256=sQ6tDOQQdEYFVv3X96XMLSI8IGgo1g5VV3HRdONr4Bg,4399
14
+ pa_cli/cli/always_on_cmd.py,sha256=vXVIUYIU1-GDGZGaIfNxFaxqnsZnXEftgnfwA4Z87ls,3034
15
+ pa_cli/cli/consoles_cmd.py,sha256=iVM8A3eWA3TB5CqAoz2CxDTiNMndFfLCnORC0gy3IAs,5295
16
+ pa_cli/cli/deploy_cmd.py,sha256=HmEW9puQYyo0qQLycaSiCpY7EOvKh2ZaJrrSXRsY9lY,1601
17
+ pa_cli/cli/files_cmd.py,sha256=Pw69uT1p308AbMPtkh74TVAWh92TV6XsqAm3Th3pK5I,10762
18
+ pa_cli/cli/init_cmd.py,sha256=dF10pgI-JY0iViZMQPix9iBtWYZbn1Et_-UEsj-huj4,2415
19
+ pa_cli/cli/main.py,sha256=pZI3z17gy4jDnjXDn0AdIe1vFgnCXQKorWZcdvaYD1E,2451
20
+ pa_cli/cli/register_cmd.py,sha256=NgUptosvelNLpip74zBqsbBw1ZNmoXV8qWiY47n0dvU,1173
21
+ pa_cli/cli/status_cmd.py,sha256=Aq4B_GGcHCbLLkZpNp-1FjbHOWmnLYLNyvJY7kXBQLk,2067
22
+ pa_cli/cli/tasks_cmd.py,sha256=xhnHokyFQExYuhSAy78nMm0qioAQsvfLpPeGUdf27W4,4966
23
+ pa_cli/cli/utils.py,sha256=2YhPeBLG-I7YAjfS-GObw8_Ya5q5DrXxCFIgDMTZUm4,788
24
+ pa_cli/cli/webapps_cmd.py,sha256=s4UZd9Nvot1We3q4pn-oHKcc2bXU2kWWFCFlwBY-has,10044
25
+ pa_cli/crawler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
+ pa_cli/crawler/account_crawler.py,sha256=dckmsaDahu_Qku3tMS-1kRai07P4enD2P9YJT8nnjzU,14612
27
+ pa_cli/crawler/console_crawler.py,sha256=Kkf8zwjPADkhOAgP41ZzqD0ynqCYB2yyhWi-75DABVg,4928
28
+ pa_cli/workflows/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
+ pa_cli/workflows/deploy.py,sha256=eyL-kj9Vu850_zYu9jKd-V7o5uh-Q2NVGoeolHQe8wY,7570
30
+ pythonanywhere_clis-1.0.0.dist-info/licenses/LICENSE,sha256=SmW5HVgeBBg_e-WKSP-Fr2H1-Cz-K0uLb99Hl_rOSKE,1088
31
+ pythonanywhere_clis-1.0.0.dist-info/METADATA,sha256=sGmWcU3lsTC2p7X35AxEsw-LzPvY0Yrp5n57xX6G4to,454
32
+ pythonanywhere_clis-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
33
+ pythonanywhere_clis-1.0.0.dist-info/entry_points.txt,sha256=AfIEhKkIidqwUmJGZXyv6cRk8rajD39WhgpmdxBkp5E,43
34
+ pythonanywhere_clis-1.0.0.dist-info/top_level.txt,sha256=xYc8GaLj6sfmBa_9zcWs60tYHX-xQihKcB91Te74q2c,7
35
+ pythonanywhere_clis-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pa = pa_cli.cli.main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pythonanywhere-cli contributors
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
+ pa_cli