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.
pa_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
pa_cli/api/__init__.py ADDED
File without changes
@@ -0,0 +1,22 @@
1
+ from pa_cli.api.client import BaseClient
2
+
3
+
4
+ class AlwaysOnClient(BaseClient):
5
+ def list(self, username: str) -> list:
6
+ """List all always-on tasks."""
7
+ response = self._request("GET", "/api/v0/user/{username}/always_on/", username=username)
8
+ return response.json()
9
+
10
+ def create(self, username: str, command: str, enabled: bool = True) -> dict:
11
+ """Create a new always-on task."""
12
+ response = self._request(
13
+ "POST",
14
+ "/api/v0/user/{username}/always_on/",
15
+ username=username,
16
+ json={"command": command, "enabled": enabled},
17
+ )
18
+ return response.json()
19
+
20
+ def delete(self, username: str, task_id: int) -> None:
21
+ """Delete an always-on task."""
22
+ self._request("DELETE", "/api/v0/user/{username}/always_on/{id}/", username=username, id=task_id)
pa_cli/api/client.py ADDED
@@ -0,0 +1,45 @@
1
+ import requests
2
+
3
+ from pa_cli.exceptions import APIError, NetworkError, NotFoundError
4
+
5
+
6
+ class BaseClient:
7
+ def __init__(self, token: str, host: str = "www.pythonanywhere.com"):
8
+ self.host = host
9
+ self.base_url = f"https://{host}"
10
+ self.session = requests.Session()
11
+ self.session.headers.update({"Authorization": f"Token {token}"})
12
+
13
+ def _build_url(self, path: str, **kwargs) -> str:
14
+ return f"{self.base_url}{path.format(**kwargs)}"
15
+
16
+ def _request(self, method: str, path: str, **kwargs) -> requests.Response:
17
+ url = self._build_url(path, **kwargs)
18
+
19
+ # Extract path params from kwargs (used in URL formatting)
20
+ path_params = {k for k in kwargs if "{" + k + "}" in path}
21
+ request_kwargs = {k: v for k, v in kwargs.items() if k not in path_params}
22
+
23
+ try:
24
+ response = self.session.request(method, url, **request_kwargs)
25
+ except requests.ConnectionError as e:
26
+ raise NetworkError(f"Connection failed: {e}") from e
27
+ except requests.Timeout as e:
28
+ raise NetworkError(f"Request timed out: {e}") from e
29
+ except requests.RequestException as e:
30
+ raise NetworkError(f"Request failed: {e}") from e
31
+
32
+ if response.status_code == 404:
33
+ raise NotFoundError(f"Not found: {path}")
34
+
35
+ try:
36
+ response.raise_for_status()
37
+ except requests.HTTPError as e:
38
+ detail = ""
39
+ try:
40
+ detail = response.json().get("detail", "")
41
+ except Exception:
42
+ detail = response.text
43
+ raise APIError(f"API error {response.status_code}: {detail}") from e
44
+
45
+ return response
pa_cli/api/consoles.py ADDED
@@ -0,0 +1,46 @@
1
+ from pa_cli.api.client import BaseClient
2
+
3
+
4
+ class ConsolesClient(BaseClient):
5
+ def list(self, username: str) -> list:
6
+ response = self._request(
7
+ "GET",
8
+ "/api/v0/user/{username}/consoles/",
9
+ username=username,
10
+ )
11
+ return response.json()
12
+
13
+ def create(self, username: str, executable: str = "bash") -> dict:
14
+ response = self._request(
15
+ "POST",
16
+ "/api/v0/user/{username}/consoles/",
17
+ username=username,
18
+ json={"executable": executable},
19
+ )
20
+ return response.json()
21
+
22
+ def send_input(self, username: str, console_id: int, input_text: str) -> None:
23
+ self._request(
24
+ "POST",
25
+ "/api/v0/user/{username}/consoles/{id}/send_input/",
26
+ username=username,
27
+ id=console_id,
28
+ data={"input": input_text},
29
+ )
30
+
31
+ def get_output(self, username: str, console_id: int) -> dict:
32
+ response = self._request(
33
+ "GET",
34
+ "/api/v0/user/{username}/consoles/{id}/get_latest_output/",
35
+ username=username,
36
+ id=console_id,
37
+ )
38
+ return response.json()
39
+
40
+ def kill(self, username: str, console_id: int) -> None:
41
+ self._request(
42
+ "DELETE",
43
+ "/api/v0/user/{username}/consoles/{id}/",
44
+ username=username,
45
+ id=console_id,
46
+ )
pa_cli/api/files.py ADDED
@@ -0,0 +1,95 @@
1
+ from pa_cli.api.client import BaseClient
2
+ from pa_cli.exceptions import APIError, NotFoundError
3
+
4
+
5
+ class FilesClient(BaseClient):
6
+ def upload(self, username: str, remote_path: str, content: bytes) -> int:
7
+ url = self._build_url(
8
+ "/api/v0/user/{username}/files/path{remote_path}",
9
+ username=username,
10
+ remote_path=remote_path,
11
+ )
12
+ response = self.session.post(url, files={"content": content})
13
+ if response.status_code == 404:
14
+ raise NotFoundError(f"Not found: {remote_path}")
15
+ try:
16
+ response.raise_for_status()
17
+ except Exception as e:
18
+ raise APIError(f"Upload failed: {response.status_code} {response.text}") from e
19
+ return response.status_code
20
+
21
+ def list(self, username: str, remote_path: str) -> dict:
22
+ """List files and directories at remote path. Returns dict of {name: {type, url}}."""
23
+ url = self._build_url(
24
+ "/api/v0/user/{username}/files/path{remote_path}",
25
+ username=username,
26
+ remote_path=remote_path,
27
+ )
28
+ response = self.session.get(url)
29
+ if response.status_code == 404:
30
+ raise NotFoundError(f"Not found: {remote_path}")
31
+ try:
32
+ response.raise_for_status()
33
+ except Exception as e:
34
+ raise APIError(f"List failed: {response.status_code} {response.text}") from e
35
+ return response.json()
36
+
37
+ def download(self, username: str, remote_path: str) -> bytes:
38
+ """Download a file from remote path. Returns file content as bytes."""
39
+ url = self._build_url(
40
+ "/api/v0/user/{username}/files/path{remote_path}",
41
+ username=username,
42
+ remote_path=remote_path,
43
+ )
44
+ response = self.session.get(url)
45
+ if response.status_code == 404:
46
+ raise NotFoundError(f"Not found: {remote_path}")
47
+ try:
48
+ response.raise_for_status()
49
+ except Exception as e:
50
+ raise APIError(f"Download failed: {response.status_code} {response.text}") from e
51
+ return response.content
52
+
53
+ def delete(self, username: str, remote_path: str) -> None:
54
+ """Delete a file or directory at remote path."""
55
+ url = self._build_url(
56
+ "/api/v0/user/{username}/files/path{remote_path}",
57
+ username=username,
58
+ remote_path=remote_path,
59
+ )
60
+ response = self.session.delete(url)
61
+ if response.status_code == 404:
62
+ raise NotFoundError(f"Not found: {remote_path}")
63
+ try:
64
+ response.raise_for_status()
65
+ except Exception as e:
66
+ raise APIError(f"Delete failed: {response.status_code} {response.text}") from e
67
+
68
+ def share(self, username: str, remote_path: str) -> str:
69
+ """Share a file and return the share URL."""
70
+ response = self._request(
71
+ "POST",
72
+ "/api/v0/user/{username}/files/sharing/",
73
+ username=username,
74
+ json={"path": remote_path},
75
+ )
76
+ return response.json()["url"]
77
+
78
+ def unshare(self, username: str, remote_path: str) -> None:
79
+ """Stop sharing a file."""
80
+ self._request(
81
+ "DELETE",
82
+ "/api/v0/user/{username}/files/sharing/",
83
+ username=username,
84
+ params={"path": remote_path},
85
+ )
86
+
87
+ def get_share_status(self, username: str, remote_path: str) -> str:
88
+ """Get share status for a file. Returns share URL or raises NotFoundError."""
89
+ response = self._request(
90
+ "GET",
91
+ "/api/v0/user/{username}/files/sharing/",
92
+ username=username,
93
+ params={"path": remote_path},
94
+ )
95
+ return response.json()["url"]
pa_cli/api/system.py ADDED
@@ -0,0 +1,8 @@
1
+ from pa_cli.api.client import BaseClient
2
+
3
+
4
+ class SystemClient(BaseClient):
5
+ def get_cpu_usage(self, username: str) -> dict:
6
+ """Get CPU usage stats."""
7
+ response = self._request("GET", "/api/v0/user/{username}/cpu/", username=username)
8
+ return response.json()
pa_cli/api/tasks.py ADDED
@@ -0,0 +1,47 @@
1
+ from pa_cli.api.client import BaseClient
2
+
3
+
4
+ class TasksClient(BaseClient):
5
+ def list(self, username: str) -> list:
6
+ """List all scheduled tasks."""
7
+ response = self._request("GET", "/api/v0/user/{username}/schedule/", username=username)
8
+ return response.json()
9
+
10
+ def get(self, username: str, task_id: int) -> dict:
11
+ """Get a specific scheduled task."""
12
+ response = self._request("GET", "/api/v0/user/{username}/schedule/{id}/", username=username, id=task_id)
13
+ return response.json()
14
+
15
+ def create(self, username: str, command: str, interval: str = "daily",
16
+ hour: int = 0, minute: int = 0, enabled: bool = True,
17
+ description: str = "") -> dict:
18
+ """Create a new scheduled task."""
19
+ response = self._request(
20
+ "POST",
21
+ "/api/v0/user/{username}/schedule/",
22
+ username=username,
23
+ json={
24
+ "command": command,
25
+ "interval": interval,
26
+ "hour": hour,
27
+ "minute": minute,
28
+ "enabled": enabled,
29
+ "description": description,
30
+ },
31
+ )
32
+ return response.json()
33
+
34
+ def update(self, username: str, task_id: int, **kwargs) -> dict:
35
+ """Update a scheduled task."""
36
+ response = self._request(
37
+ "PATCH",
38
+ "/api/v0/user/{username}/schedule/{id}/",
39
+ username=username,
40
+ id=task_id,
41
+ json=kwargs,
42
+ )
43
+ return response.json()
44
+
45
+ def delete(self, username: str, task_id: int) -> None:
46
+ """Delete a scheduled task."""
47
+ self._request("DELETE", "/api/v0/user/{username}/schedule/{id}/", username=username, id=task_id)
pa_cli/api/webapps.py ADDED
@@ -0,0 +1,71 @@
1
+ from pa_cli.api.client import BaseClient
2
+
3
+
4
+ class WebappsClient(BaseClient):
5
+ def create(self, username: str, domain_name: str, python_version: str) -> None:
6
+ self._request(
7
+ "POST",
8
+ "/api/v0/user/{username}/webapps/",
9
+ username=username,
10
+ data={"domain_name": domain_name, "python_version": python_version},
11
+ )
12
+
13
+ def update(self, username: str, domain_name: str, **kwargs) -> None:
14
+ self._request(
15
+ "PUT",
16
+ "/api/v0/user/{username}/webapps/{domain_name}/",
17
+ username=username,
18
+ domain_name=domain_name,
19
+ json=kwargs,
20
+ )
21
+
22
+ def delete(self, username: str, domain_name: str) -> None:
23
+ self._request(
24
+ "DELETE",
25
+ "/api/v0/user/{username}/webapps/{domain_name}/",
26
+ username=username,
27
+ domain_name=domain_name,
28
+ )
29
+
30
+ def enable(self, username: str, domain_name: str) -> None:
31
+ self._request(
32
+ "POST",
33
+ "/api/v0/user/{username}/webapps/{domain_name}/enable/",
34
+ username=username,
35
+ domain_name=domain_name,
36
+ )
37
+
38
+ def disable(self, username: str, domain_name: str) -> None:
39
+ self._request(
40
+ "POST",
41
+ "/api/v0/user/{username}/webapps/{domain_name}/disable/",
42
+ username=username,
43
+ domain_name=domain_name,
44
+ )
45
+
46
+ def add_static_file(self, username: str, domain_name: str, url: str, path: str) -> None:
47
+ self._request(
48
+ "POST",
49
+ "/api/v0/user/{username}/webapps/{domain_name}/static_files/",
50
+ username=username,
51
+ domain_name=domain_name,
52
+ data={"url": url, "path": path},
53
+ )
54
+
55
+ def reload(self, username: str, domain_name: str) -> None:
56
+ self._request(
57
+ "POST",
58
+ "/api/v0/user/{username}/webapps/{domain_name}/reload/",
59
+ username=username,
60
+ domain_name=domain_name,
61
+ )
62
+
63
+ def get_ssl_info(self, username: str, domain_name: str) -> dict:
64
+ """Get SSL certificate information."""
65
+ response = self._request(
66
+ "GET",
67
+ "/api/v0/user/{username}/webapps/{domain_name}/ssl/",
68
+ username=username,
69
+ domain_name=domain_name,
70
+ )
71
+ return response.json()
pa_cli/cli/__init__.py ADDED
File without changes
@@ -0,0 +1,131 @@
1
+ import typer
2
+
3
+ from pa_cli.config import Config
4
+ from pa_cli.crawler.account_crawler import AccountCrawler
5
+ from pa_cli.exceptions import AuthError, NetworkError, NotFoundError
6
+
7
+ app = typer.Typer(help="Account management commands.")
8
+
9
+
10
+ @app.command()
11
+ def switch(
12
+ username: str = typer.Argument(..., help="Username to switch to"),
13
+ ):
14
+ """Switch the default account."""
15
+ try:
16
+ Config.set_default(username)
17
+ typer.echo(f"Switched to account '{username}'.")
18
+ except Exception as e:
19
+ typer.echo(f"Error: {e}", err=True)
20
+ raise typer.Exit(code=1)
21
+
22
+
23
+ @app.command()
24
+ def remove(
25
+ username: str = typer.Argument(..., help="Username to remove"),
26
+ ):
27
+ """Remove an account from config."""
28
+ try:
29
+ new_default = Config.remove(username)
30
+ typer.echo(f"Removed account '{username}'.")
31
+ if new_default:
32
+ typer.echo(f"Switched to account '{new_default}'.")
33
+ except Exception as e:
34
+ typer.echo(f"Error: {e}", err=True)
35
+ raise typer.Exit(code=1)
36
+
37
+
38
+ @app.command("list")
39
+ def list_accounts():
40
+ """List all configured accounts."""
41
+ accounts = Config.list_accounts()
42
+ if not accounts:
43
+ typer.echo("No accounts configured. Run 'pa init' to add one.")
44
+ return
45
+
46
+ try:
47
+ current = Config.load(verbose=True)
48
+ current_user = current["username"]
49
+ except (FileNotFoundError, ValueError):
50
+ current_user = ""
51
+
52
+ for account in accounts:
53
+ prefix = "* " if account["username"] == current_user else " "
54
+ token = account.get("token", "")
55
+ token_display = f"token: {token[:8]}..." if token else "(no token)"
56
+ host = account.get("host", "www.pythonanywhere.com")
57
+ typer.echo(f"{prefix}{account['username']} {host} {token_display}")
58
+
59
+
60
+ @app.command()
61
+ def login():
62
+ """Store password for the current account."""
63
+ password = typer.prompt("Password", hide_input=True)
64
+ Config.save(password=password)
65
+ typer.echo("Password saved successfully.")
66
+
67
+
68
+ @app.command()
69
+ def token(
70
+ revoke: bool = typer.Option(False, "--revoke", "-r", help="Revoke current token and create a new one"),
71
+ ):
72
+ """Get API token. Creates one automatically if none exists. Use --revoke to force refresh."""
73
+ try:
74
+ crawler = AccountCrawler()
75
+ crawler.login()
76
+ typer.echo(f"[account: {crawler.username}]")
77
+
78
+ if revoke:
79
+ new_token = crawler.create_token()
80
+ Config.save(token=new_token)
81
+ typer.echo(f"Token revoked. New token: {new_token}")
82
+ else:
83
+ try:
84
+ existing = crawler.get_token()
85
+ Config.save(token=existing)
86
+ typer.echo(f"API token: {existing}")
87
+ except NotFoundError:
88
+ new_token = crawler.create_token()
89
+ Config.save(token=new_token)
90
+ typer.echo(f"Token created: {new_token}")
91
+ except AuthError as e:
92
+ typer.echo(f"Auth error: {e}", err=True)
93
+ raise typer.Exit(code=1)
94
+ except NetworkError as e:
95
+ typer.echo(f"Network error: {e}", err=True)
96
+ raise typer.Exit(code=1)
97
+ except NotFoundError as e:
98
+ typer.echo(f"Not found: {e}", err=True)
99
+ raise typer.Exit(code=1)
100
+
101
+
102
+ @app.command()
103
+ def extend():
104
+ """Extend account expiry by logging in via crawler."""
105
+ try:
106
+ crawler = AccountCrawler()
107
+ crawler.login()
108
+ typer.echo(f"[account: {crawler.username}]")
109
+
110
+ current_expiry = crawler.get_expiry_date()
111
+ if current_expiry:
112
+ typer.echo(f"Current expiry: {current_expiry}")
113
+
114
+ if crawler.extend_expiry():
115
+ typer.echo("Account expiry extended successfully.")
116
+ new_expiry = crawler.get_expiry_date()
117
+ if new_expiry:
118
+ typer.echo(f"New expiry: {new_expiry}")
119
+ else:
120
+ typer.echo("Failed to extend account expiry.", err=True)
121
+ raise typer.Exit(code=1)
122
+ except AuthError as e:
123
+ typer.echo(f"Auth error: {e}", err=True)
124
+ raise typer.Exit(code=1)
125
+ except NetworkError as e:
126
+ typer.echo(f"Network error: {e}", err=True)
127
+ raise typer.Exit(code=1)
128
+ except NotFoundError as e:
129
+ typer.echo(f"Not found: {e}", err=True)
130
+ raise typer.Exit(code=1)
131
+
@@ -0,0 +1,82 @@
1
+ import typer
2
+
3
+ from pa_cli.api.always_on import AlwaysOnClient
4
+ from pa_cli.cli.utils import get_client
5
+ from pa_cli.exceptions import AuthError, NetworkError, NotFoundError, APIError
6
+
7
+ app = typer.Typer(help="Manage always-on tasks on PythonAnywhere.")
8
+
9
+
10
+ @app.command("list")
11
+ def list_tasks():
12
+ """List all always-on tasks."""
13
+ try:
14
+ account, client = get_client(AlwaysOnClient)
15
+ tasks = client.list(account["username"])
16
+ if not tasks:
17
+ typer.echo("No always-on tasks found.")
18
+ return
19
+ for task in tasks:
20
+ status = "enabled" if task.get("enabled") else "disabled"
21
+ typer.echo(f"ID: {task['id']}, Command: {task['command']}, Status: {status}")
22
+ except AuthError as e:
23
+ typer.echo(f"Auth error: {e}", err=True)
24
+ raise typer.Exit(code=1)
25
+ except NetworkError as e:
26
+ typer.echo(f"Network error: {e}", err=True)
27
+ raise typer.Exit(code=1)
28
+ except APIError as e:
29
+ typer.echo(f"API error: {e}", err=True)
30
+ raise typer.Exit(code=1)
31
+
32
+
33
+ @app.command()
34
+ def create(
35
+ command: str = typer.Argument(..., help="Command to run"),
36
+ ):
37
+ """Create a new always-on task."""
38
+ try:
39
+ account, client = get_client(AlwaysOnClient)
40
+ task = client.create(account["username"], command=command)
41
+ typer.echo(f"Always-on task created: ID={task['id']}, Command={task['command']}")
42
+ except APIError as e:
43
+ if "limit" in str(e).lower():
44
+ typer.echo("Error: Always-on task limit reached. Upgrade your plan to add more.", err=True)
45
+ else:
46
+ typer.echo(f"API error: {e}", err=True)
47
+ raise typer.Exit(code=1)
48
+ except AuthError as e:
49
+ typer.echo(f"Auth error: {e}", err=True)
50
+ raise typer.Exit(code=1)
51
+ except NetworkError as e:
52
+ typer.echo(f"Network error: {e}", err=True)
53
+ raise typer.Exit(code=1)
54
+
55
+
56
+ @app.command()
57
+ def delete(
58
+ task_id: int = typer.Argument(..., help="Task ID to delete"),
59
+ force: bool = typer.Option(False, "-f", "--force", help="Skip confirmation"),
60
+ ):
61
+ """Delete an always-on task."""
62
+ try:
63
+ account, client = get_client(AlwaysOnClient)
64
+ if not force:
65
+ confirm = typer.confirm(f"Are you sure you want to delete always-on task {task_id}?")
66
+ if not confirm:
67
+ typer.echo("Cancelled.")
68
+ raise typer.Exit()
69
+ client.delete(account["username"], task_id)
70
+ typer.echo(f"Always-on task {task_id} deleted.")
71
+ except AuthError as e:
72
+ typer.echo(f"Auth error: {e}", err=True)
73
+ raise typer.Exit(code=1)
74
+ except NetworkError as e:
75
+ typer.echo(f"Network error: {e}", err=True)
76
+ raise typer.Exit(code=1)
77
+ except NotFoundError as e:
78
+ typer.echo(f"Task not found: {e}", err=True)
79
+ raise typer.Exit(code=1)
80
+ except APIError as e:
81
+ typer.echo(f"API error: {e}", err=True)
82
+ raise typer.Exit(code=1)