moodle-cli 0.1.0b0__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.
moodle_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Terminal-first CLI for Moodle LMS."""
2
+
3
+ __version__ = "0.1.0b0"
moodle_cli/auth.py ADDED
@@ -0,0 +1,74 @@
1
+ """Authentication: extract MoodleSession cookie from env or browser."""
2
+
3
+ import os
4
+ import logging
5
+ from urllib.parse import urlparse
6
+
7
+ from moodle_cli.constants import ENV_MOODLE_SESSION
8
+ from moodle_cli.exceptions import AuthError
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def load_from_env() -> str | None:
14
+ """Check MOODLE_SESSION environment variable."""
15
+ value = os.environ.get(ENV_MOODLE_SESSION)
16
+ if value:
17
+ log.debug("Using MoodleSession from environment variable")
18
+ return value
19
+
20
+
21
+ def load_from_browser(domain: str) -> str | None:
22
+ """Extract MoodleSession cookie from browser cookie stores.
23
+
24
+ Tries Arc, Chrome, Brave, Edge, Firefox in order.
25
+ """
26
+ try:
27
+ import browser_cookie3 # noqa: F811
28
+ except ImportError:
29
+ log.warning("browser-cookie3 not installed; skipping browser cookie extraction")
30
+ return None
31
+
32
+ # browser-cookie3 loaders to try (in priority order)
33
+ loaders = [
34
+ ("Chrome", browser_cookie3.chrome),
35
+ ("Firefox", browser_cookie3.firefox),
36
+ ("Brave", browser_cookie3.brave),
37
+ ("Edge", browser_cookie3.edge),
38
+ ]
39
+
40
+ for name, loader in loaders:
41
+ try:
42
+ cj = loader(domain_name=domain)
43
+ for cookie in cj:
44
+ if cookie.name == "MoodleSession" and domain in (cookie.domain or ""):
45
+ log.debug("Found MoodleSession in %s", name)
46
+ return cookie.value
47
+ except Exception as exc:
48
+ log.debug("Could not read cookies from %s: %s", name, exc)
49
+
50
+ return None
51
+
52
+
53
+ def get_session(base_url: str) -> str:
54
+ """Get a valid MoodleSession cookie value.
55
+
56
+ Priority: env var → browser cookies.
57
+ Raises AuthError if no session is found.
58
+ """
59
+ # 1. Environment variable
60
+ session = load_from_env()
61
+ if session:
62
+ return session
63
+
64
+ # 2. Browser cookies
65
+ domain = urlparse(base_url).hostname or ""
66
+ session = load_from_browser(domain)
67
+ if session:
68
+ return session
69
+
70
+ raise AuthError(
71
+ "No MoodleSession found. Either:\n"
72
+ f" 1. Log in to {base_url} in your browser, or\n"
73
+ f" 2. Set the {ENV_MOODLE_SESSION} environment variable"
74
+ )
moodle_cli/cli.py ADDED
@@ -0,0 +1,142 @@
1
+ """CLI entry point using Click."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from moodle_cli import __version__
10
+ from moodle_cli.auth import get_session
11
+ from moodle_cli.client import MoodleClient
12
+ from moodle_cli.config import load_config
13
+ from moodle_cli.exceptions import AuthError, MoodleAPIError, MoodleCLIError
14
+ from moodle_cli.formatter import print_courses, print_course_contents, print_user_info
15
+ from moodle_cli.output import output_json, output_yaml
16
+
17
+ console = Console(stderr=True)
18
+
19
+
20
+ @click.group()
21
+ @click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
22
+ @click.version_option(version=__version__)
23
+ @click.pass_context
24
+ def cli(ctx: click.Context, verbose: bool) -> None:
25
+ """Terminal-first CLI for Moodle LMS."""
26
+ logging.basicConfig(
27
+ level=logging.DEBUG if verbose else logging.WARNING,
28
+ format="%(name)s: %(message)s",
29
+ )
30
+
31
+ config = load_config()
32
+ ctx.ensure_object(dict)
33
+ ctx.obj["config"] = config
34
+
35
+ # Lazy client creation — only authenticate when a command needs it
36
+ ctx.obj["_client"] = None
37
+
38
+ def get_client() -> MoodleClient:
39
+ if ctx.obj["_client"] is None:
40
+ session_cookie = get_session(config["base_url"])
41
+ ctx.obj["_client"] = MoodleClient(config["base_url"], session_cookie)
42
+ return ctx.obj["_client"]
43
+
44
+ ctx.obj["get_client"] = get_client
45
+
46
+
47
+ @cli.command(name="user")
48
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
49
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
50
+ @click.pass_context
51
+ def user(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
52
+ """Show authenticated user info."""
53
+ client = ctx.obj["get_client"]()
54
+ info = client.get_site_info()
55
+
56
+ if as_json:
57
+ output_json(info.to_dict())
58
+ elif as_yaml:
59
+ output_yaml(info.to_dict())
60
+ else:
61
+ print_user_info(info)
62
+
63
+
64
+ @cli.command()
65
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
66
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
67
+ @click.pass_context
68
+ def courses(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
69
+ """List enrolled courses."""
70
+ client = ctx.obj["get_client"]()
71
+ course_list = client.get_courses()
72
+
73
+ if as_json:
74
+ output_json([c.to_dict() for c in course_list])
75
+ elif as_yaml:
76
+ output_yaml([c.to_dict() for c in course_list])
77
+ else:
78
+ print_courses(course_list)
79
+
80
+
81
+ @cli.command()
82
+ @click.argument("course_id", type=int)
83
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
84
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
85
+ @click.pass_context
86
+ def activities(ctx: click.Context, course_id: int, as_json: bool, as_yaml: bool) -> None:
87
+ """List activities in a course (sections and modules)."""
88
+ client = ctx.obj["get_client"]()
89
+ sections = client.get_course_contents(course_id)
90
+
91
+ if as_json:
92
+ output_json([s.to_dict() for s in sections])
93
+ elif as_yaml:
94
+ output_yaml([s.to_dict() for s in sections])
95
+ else:
96
+ print_course_contents(sections, course_label=f"Course {course_id}")
97
+
98
+
99
+ @cli.command()
100
+ @click.argument("course_id", type=int)
101
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
102
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
103
+ @click.pass_context
104
+ def course(ctx: click.Context, course_id: int, as_json: bool, as_yaml: bool) -> None:
105
+ """Show course detail with sections."""
106
+ client = ctx.obj["get_client"]()
107
+ sections = client.get_course_contents(course_id)
108
+
109
+ if as_json:
110
+ output_json([s.to_dict() for s in sections])
111
+ elif as_yaml:
112
+ output_yaml([s.to_dict() for s in sections])
113
+ else:
114
+ print_course_contents(sections, course_label=f"Course {course_id}")
115
+
116
+
117
+ def main() -> None:
118
+ """Entry point with error handling."""
119
+ try:
120
+ cli(standalone_mode=False)
121
+ except click.exceptions.Abort:
122
+ sys.exit(130)
123
+ except click.exceptions.Exit as e:
124
+ sys.exit(e.exit_code)
125
+ except click.ClickException as e:
126
+ e.show()
127
+ sys.exit(e.exit_code)
128
+ except AuthError as e:
129
+ console.print(f"[bold red]Auth error:[/] {e}")
130
+ sys.exit(1)
131
+ except MoodleAPIError as e:
132
+ console.print(f"[bold red]API error:[/] {e}")
133
+ if e.error_code:
134
+ console.print(f" Error code: {e.error_code}")
135
+ sys.exit(1)
136
+ except MoodleCLIError as e:
137
+ console.print(f"[bold red]Error:[/] {e}")
138
+ sys.exit(1)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
moodle_cli/client.py ADDED
@@ -0,0 +1,206 @@
1
+ """Moodle client using authenticated page scraping and AJAX fallbacks."""
2
+
3
+ import logging
4
+
5
+ import requests
6
+
7
+ from moodle_cli.constants import (
8
+ AJAX_SERVICE_PATH,
9
+ COURSE_PATH,
10
+ DASHBOARD_PATH,
11
+ FUNC_GET_COURSES,
12
+ FUNC_GET_COURSES_BY_TIMELINE,
13
+ FUNC_GET_COURSE_CONTENTS,
14
+ FUNC_GET_SITE_INFO,
15
+ )
16
+ from moodle_cli.exceptions import AuthError, MoodleAPIError
17
+ from moodle_cli.models import Course, Section, UserInfo
18
+ from moodle_cli.parser import parse_courses, parse_user_info
19
+ from moodle_cli.scraper import parse_course_contents_html, parse_course_section_numbers, parse_page_context
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ class MoodleClient:
25
+ """Client for Moodle's authenticated pages and internal AJAX API."""
26
+
27
+ def __init__(self, base_url: str, moodle_session: str):
28
+ self.base_url = base_url
29
+ self.session = requests.Session()
30
+ self.session.cookies.set("MoodleSession", moodle_session)
31
+
32
+ self._sesskey: str | None = None
33
+ self._userid: int | None = None
34
+ self._user_info: UserInfo | None = None
35
+
36
+ def _ajax_url(self, function_name: str) -> str:
37
+ """Build the AJAX service URL."""
38
+ sesskey = self._sesskey or ""
39
+ return f"{self.base_url}{AJAX_SERVICE_PATH}?sesskey={sesskey}&info={function_name}"
40
+
41
+ def _get(self, path: str, params: dict | None = None) -> requests.Response:
42
+ """Fetch an authenticated Moodle page."""
43
+ resp = self.session.get(f"{self.base_url}{path}", params=params)
44
+ resp.raise_for_status()
45
+ return resp
46
+
47
+ def _call(self, function_name: str, args: dict | None = None) -> dict | list:
48
+ """Make a single AJAX service call.
49
+
50
+ Returns the 'data' field from the first response item.
51
+ """
52
+ payload = [
53
+ {
54
+ "index": 0,
55
+ "methodname": function_name,
56
+ "args": args or {},
57
+ }
58
+ ]
59
+
60
+ url = self._ajax_url(function_name)
61
+ log.debug("POST %s", url)
62
+
63
+ resp = self.session.post(url, json=payload)
64
+ resp.raise_for_status()
65
+
66
+ result = resp.json()
67
+
68
+ # Moodle returns a JSON array
69
+ if isinstance(result, list) and len(result) > 0:
70
+ item = result[0]
71
+ if item.get("error"):
72
+ exc = item.get("exception", {})
73
+ raise MoodleAPIError(
74
+ exc.get("message", "Unknown API error"),
75
+ error_code=exc.get("errorcode"),
76
+ )
77
+ return item.get("data", item)
78
+
79
+ # Some endpoints return a dict with error info
80
+ if isinstance(result, dict) and result.get("error"):
81
+ raise MoodleAPIError(
82
+ result.get("message", "Unknown error"),
83
+ error_code=result.get("errorcode"),
84
+ )
85
+
86
+ return result
87
+
88
+ def _ensure_session(self) -> None:
89
+ """Ensure we have an authenticated Moodle context."""
90
+ if self._sesskey and self._userid:
91
+ return
92
+ response = self._get(DASHBOARD_PATH)
93
+ context = parse_page_context(response.text, self.base_url)
94
+ self._sesskey = context.sesskey
95
+ self._userid = context.user_info.userid
96
+ self._user_info = context.user_info
97
+
98
+ def get_site_info(self) -> UserInfo:
99
+ """Load authenticated user info, falling back to page scraping when needed."""
100
+ self._ensure_session()
101
+
102
+ try:
103
+ data = self._call(FUNC_GET_SITE_INFO)
104
+ except MoodleAPIError as exc:
105
+ if exc.error_code != "servicenotavailable":
106
+ raise
107
+ log.debug("Falling back to scraped user info because %s is unavailable", FUNC_GET_SITE_INFO)
108
+ else:
109
+ if not isinstance(data, dict) or "userid" not in data:
110
+ raise AuthError("Session appears invalid — could not retrieve user info")
111
+
112
+ self._sesskey = data.get("sesskey") or self._sesskey
113
+ self._userid = data["userid"]
114
+ self._user_info = parse_user_info(data)
115
+
116
+ if self._user_info is None or self._userid is None:
117
+ raise AuthError("Session appears invalid — could not retrieve user info")
118
+
119
+ log.debug("Authenticated as %s (uid=%d)", self._user_info.fullname, self._userid)
120
+ return self._user_info
121
+
122
+ def get_courses(self) -> list[Course]:
123
+ """Get all enrolled courses for the authenticated user."""
124
+ self._ensure_session()
125
+
126
+ try:
127
+ return self._get_courses_timeline()
128
+ except MoodleAPIError as exc:
129
+ if exc.error_code != "servicenotavailable":
130
+ raise
131
+ log.debug("Falling back to %s because timeline API is unavailable", FUNC_GET_COURSES)
132
+
133
+ data = self._call(FUNC_GET_COURSES, {"userid": self._userid})
134
+ if not isinstance(data, list):
135
+ return []
136
+ return parse_courses(data)
137
+
138
+ def get_course_contents(self, course_id: int) -> list[Section]:
139
+ """Get sections and activities for a course."""
140
+ self._ensure_session()
141
+
142
+ try:
143
+ data = self._call(FUNC_GET_COURSE_CONTENTS, {"courseid": course_id})
144
+ except MoodleAPIError as exc:
145
+ if exc.error_code != "servicenotavailable":
146
+ raise
147
+ log.debug("Falling back to scraping %s because %s is unavailable", COURSE_PATH, FUNC_GET_COURSE_CONTENTS)
148
+ else:
149
+ if isinstance(data, list):
150
+ from moodle_cli.parser import parse_course_contents
151
+
152
+ return parse_course_contents(data)
153
+ return []
154
+
155
+ response = self._get(COURSE_PATH, {"id": course_id})
156
+ sections = self._scrape_course_contents(course_id, response.text)
157
+ return sections
158
+
159
+ def _get_courses_timeline(self) -> list[Course]:
160
+ """Get enrolled courses from the dashboard timeline API."""
161
+ courses: list[dict] = []
162
+ offset = 0
163
+
164
+ while True:
165
+ data = self._call(
166
+ FUNC_GET_COURSES_BY_TIMELINE,
167
+ {"classification": "all", "limit": 100, "offset": offset},
168
+ )
169
+ if not isinstance(data, dict):
170
+ break
171
+
172
+ batch = data.get("courses", [])
173
+ if not isinstance(batch, list) or not batch:
174
+ break
175
+
176
+ courses.extend(batch)
177
+
178
+ next_offset = data.get("nextoffset", offset)
179
+ if not isinstance(next_offset, int) or next_offset <= offset:
180
+ break
181
+ offset = next_offset
182
+
183
+ return parse_courses(courses)
184
+
185
+ def _scrape_course_contents(self, course_id: int, root_html: str) -> list[Section]:
186
+ """Aggregate sections from section-specific course pages."""
187
+ section_numbers = parse_course_section_numbers(root_html, course_id)
188
+ html_pages = [root_html]
189
+
190
+ for section_num in section_numbers:
191
+ if section_num == 0:
192
+ continue
193
+ response = self._get(COURSE_PATH, {"id": course_id, "section": section_num})
194
+ html_pages.append(response.text)
195
+
196
+ sections: list[Section] = []
197
+ seen_sections: set[int] = set()
198
+ for html in html_pages:
199
+ for section in parse_course_contents_html(html, self.base_url):
200
+ section_key = section.section or section.id
201
+ if section_key in seen_sections:
202
+ continue
203
+ seen_sections.add(section_key)
204
+ sections.append(section)
205
+
206
+ return sections
moodle_cli/config.py ADDED
@@ -0,0 +1,140 @@
1
+ """Config loading and first-run base URL setup."""
2
+
3
+ from pathlib import Path
4
+ import os
5
+ from urllib.parse import urlparse
6
+
7
+ import click
8
+ import requests
9
+ import yaml
10
+
11
+ from moodle_cli.constants import CONFIG_DIR, CONFIG_FILENAME, ENV_MOODLE_BASE_URL
12
+ from moodle_cli.exceptions import MoodleCLIError
13
+
14
+ CONFIG_PROMPT = "Enter your Moodle base URL"
15
+
16
+
17
+ def _config_candidates() -> list[Path]:
18
+ return [
19
+ Path.cwd() / CONFIG_FILENAME,
20
+ Path(CONFIG_DIR).expanduser() / CONFIG_FILENAME,
21
+ ]
22
+
23
+
24
+ def _find_config_file() -> Path | None:
25
+ """Search for config.yaml in CWD, then ~/.config/moodle-cli/."""
26
+ for path in _config_candidates():
27
+ if path.is_file():
28
+ return path
29
+ return None
30
+
31
+
32
+ def _default_config_path() -> Path:
33
+ cwd_config = Path.cwd() / CONFIG_FILENAME
34
+ if cwd_config.exists():
35
+ return cwd_config
36
+ return Path(CONFIG_DIR).expanduser() / CONFIG_FILENAME
37
+
38
+
39
+ def _read_config_file(path: Path) -> dict:
40
+ with open(path, encoding="utf-8") as f:
41
+ return yaml.safe_load(f) or {}
42
+
43
+
44
+ def _save_config(path: Path, config: dict) -> None:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ with open(path, "w", encoding="utf-8") as f:
47
+ yaml.safe_dump(config, f, sort_keys=True)
48
+
49
+
50
+ def _validate_base_url(value: str) -> str:
51
+ raw = value.strip()
52
+ if not raw:
53
+ raise MoodleCLIError("Base URL cannot be empty.")
54
+ if "://" not in raw:
55
+ raise MoodleCLIError("Base URL must include the scheme, for example https://school.example.edu")
56
+
57
+ parsed = urlparse(raw)
58
+ if parsed.scheme not in {"http", "https"}:
59
+ raise MoodleCLIError("Base URL must start with http:// or https://")
60
+ if not parsed.hostname:
61
+ raise MoodleCLIError("Base URL must include a hostname")
62
+ if parsed.query or parsed.fragment:
63
+ raise MoodleCLIError("Base URL must not include query parameters or fragments")
64
+ if parsed.path not in {"", "/"}:
65
+ raise MoodleCLIError("Base URL must be the site root, for example https://school.example.edu")
66
+
67
+ return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
68
+
69
+
70
+ def _probe_base_url(base_url: str) -> tuple[bool, str]:
71
+ """Check whether the configured URL looks reachable and likely Moodle."""
72
+ try:
73
+ response = requests.get(f"{base_url}/login/index.php", timeout=10, allow_redirects=True)
74
+ except requests.RequestException as exc:
75
+ return False, f"Could not reach {base_url}: {exc}"
76
+
77
+ content_type = response.headers.get("content-type", "").lower()
78
+ body = response.text[:5000].lower()
79
+ looks_html = "text/html" in content_type or "<html" in body
80
+ looks_moodle = "moodle" in body or "log in" in body or "/login/index.php" in body
81
+
82
+ if response.status_code >= 400:
83
+ return False, f"{base_url} returned HTTP {response.status_code}"
84
+ if not looks_html:
85
+ return False, f"{base_url} did not return an HTML page"
86
+ if not looks_moodle:
87
+ return False, f"{base_url} does not look like a Moodle site"
88
+
89
+ return True, ""
90
+
91
+
92
+ def _prompt_for_base_url() -> str:
93
+ click.echo("No Moodle base URL configured.")
94
+ click.echo("Example: https://school.example.edu")
95
+
96
+ while True:
97
+ value = click.prompt(CONFIG_PROMPT, type=str)
98
+ try:
99
+ base_url = _validate_base_url(value)
100
+ except MoodleCLIError as exc:
101
+ click.echo(f"Invalid URL: {exc}")
102
+ continue
103
+
104
+ looks_valid, message = _probe_base_url(base_url)
105
+ if not looks_valid:
106
+ click.echo(f"Warning: {message}")
107
+ if not click.confirm("Save this URL anyway?", default=False):
108
+ continue
109
+
110
+ if click.confirm(f"Use {base_url}?", default=True):
111
+ return base_url
112
+
113
+
114
+ def load_config() -> dict:
115
+ """Load configuration, prompting once for base_url when needed."""
116
+ config: dict = {}
117
+ config_file = _find_config_file()
118
+ if config_file:
119
+ config = _read_config_file(config_file)
120
+
121
+ if env_url := os.environ.get(ENV_MOODLE_BASE_URL):
122
+ config["base_url"] = _validate_base_url(env_url)
123
+ return config
124
+
125
+ if base_url := config.get("base_url"):
126
+ config["base_url"] = _validate_base_url(str(base_url))
127
+ return config
128
+
129
+ if not click.get_text_stream("stdin").isatty():
130
+ raise MoodleCLIError(
131
+ "No base_url configured. Set MOODLE_BASE_URL or add base_url to config.yaml before running non-interactively."
132
+ )
133
+
134
+ base_url = _prompt_for_base_url()
135
+ config["base_url"] = base_url
136
+
137
+ target_path = config_file or _default_config_path()
138
+ _save_config(target_path, config)
139
+ click.echo(f"Saved base_url to {target_path}")
140
+ return config
@@ -0,0 +1,19 @@
1
+ """Default values and API paths."""
2
+
3
+ AJAX_SERVICE_PATH = "/lib/ajax/service.php"
4
+ DASHBOARD_PATH = "/my/"
5
+ COURSE_PATH = "/course/view.php"
6
+
7
+ # Moodle AJAX function names
8
+ FUNC_GET_SITE_INFO = "core_webservice_get_site_info"
9
+ FUNC_GET_COURSES = "core_enrol_get_users_courses"
10
+ FUNC_GET_COURSES_BY_TIMELINE = "core_course_get_enrolled_courses_by_timeline_classification"
11
+ FUNC_GET_COURSE_CONTENTS = "core_course_get_contents"
12
+
13
+ # Config file locations (checked in order)
14
+ CONFIG_FILENAME = "config.yaml"
15
+ CONFIG_DIR = "~/.config/moodle-cli"
16
+
17
+ # Environment variable names
18
+ ENV_MOODLE_SESSION = "MOODLE_SESSION"
19
+ ENV_MOODLE_BASE_URL = "MOODLE_BASE_URL"
@@ -0,0 +1,17 @@
1
+ """Custom exceptions for moodle-cli."""
2
+
3
+
4
+ class MoodleCLIError(Exception):
5
+ """Base exception for moodle-cli."""
6
+
7
+
8
+ class AuthError(MoodleCLIError):
9
+ """Authentication failed — no valid session found."""
10
+
11
+
12
+ class MoodleAPIError(MoodleCLIError):
13
+ """Moodle API returned an error response."""
14
+
15
+ def __init__(self, message: str, error_code: str | None = None):
16
+ super().__init__(message)
17
+ self.error_code = error_code
@@ -0,0 +1,90 @@
1
+ """Rich terminal output: tables and trees for Moodle data."""
2
+
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from rich.tree import Tree
6
+ from rich.text import Text
7
+
8
+ from moodle_cli.models import Course, Section, UserInfo
9
+
10
+ console = Console()
11
+
12
+
13
+ def print_user_info(user: UserInfo) -> None:
14
+ """Display authenticated user info."""
15
+ table = Table(show_header=False, box=None, padding=(0, 2))
16
+ table.add_column(style="bold cyan")
17
+ table.add_column()
18
+
19
+ table.add_row("User", user.fullname)
20
+ if user.username:
21
+ table.add_row("Username", user.username)
22
+ table.add_row("User ID", str(user.userid))
23
+ table.add_row("Site", user.sitename)
24
+ table.add_row("URL", user.siteurl)
25
+ if user.lang:
26
+ table.add_row("Language", user.lang)
27
+
28
+ console.print(table)
29
+
30
+
31
+ def print_courses(courses: list[Course]) -> None:
32
+ """Display enrolled courses as a table."""
33
+ table = Table(title="Enrolled Courses")
34
+ table.add_column("ID", style="dim", justify="right")
35
+ table.add_column("Short Name", style="bold")
36
+ table.add_column("Full Name")
37
+ table.add_column("Visible", justify="center")
38
+
39
+ for c in courses:
40
+ visible = "[green]Yes[/]" if c.visible else "[red]No[/]"
41
+ table.add_row(str(c.id), c.shortname, c.fullname, visible)
42
+
43
+ console.print(table)
44
+
45
+
46
+ def print_course_contents(sections: list[Section], course_label: str = "Course") -> None:
47
+ """Display course sections and activities as a tree."""
48
+ tree = Tree(f"[bold]{course_label}[/bold]")
49
+
50
+ for section in sections:
51
+ label = section.name or f"Section {section.section}"
52
+ if not section.visible:
53
+ label += " [dim](hidden)[/dim]"
54
+ branch = tree.add(f"[bold yellow]{label}[/bold yellow]")
55
+
56
+ if not section.activities:
57
+ branch.add("[dim]No activities[/dim]")
58
+ continue
59
+
60
+ for activity in section.activities:
61
+ icon = _activity_icon(activity.modname)
62
+ name = activity.name
63
+ if not activity.visible:
64
+ name += " [dim](hidden)[/dim]"
65
+ branch.add(f"{icon} {name} [dim]({activity.modname})[/dim]")
66
+
67
+ console.print(tree)
68
+
69
+
70
+ def _activity_icon(modname: str) -> str:
71
+ """Map Moodle module types to terminal-friendly icons."""
72
+ icons = {
73
+ "assign": "[red]A[/]",
74
+ "quiz": "[magenta]Q[/]",
75
+ "forum": "[green]F[/]",
76
+ "resource": "[blue]R[/]",
77
+ "url": "[cyan]U[/]",
78
+ "page": "[white]P[/]",
79
+ "folder": "[yellow]D[/]",
80
+ "label": "[dim]L[/]",
81
+ "choice": "[magenta]C[/]",
82
+ "feedback": "[green]B[/]",
83
+ "workshop": "[red]W[/]",
84
+ "glossary": "[cyan]G[/]",
85
+ "wiki": "[white]K[/]",
86
+ "book": "[blue]B[/]",
87
+ "h5pactivity": "[magenta]H[/]",
88
+ "lti": "[yellow]E[/]",
89
+ }
90
+ return icons.get(modname, "[dim]·[/]")
moodle_cli/models.py ADDED
@@ -0,0 +1,85 @@
1
+ """Data models for Moodle entities."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class UserInfo:
8
+ userid: int
9
+ username: str
10
+ fullname: str
11
+ sitename: str
12
+ siteurl: str
13
+ lang: str = ""
14
+
15
+ def to_dict(self) -> dict:
16
+ return {
17
+ "userid": self.userid,
18
+ "username": self.username,
19
+ "fullname": self.fullname,
20
+ "sitename": self.sitename,
21
+ "siteurl": self.siteurl,
22
+ "lang": self.lang,
23
+ }
24
+
25
+
26
+ @dataclass
27
+ class Course:
28
+ id: int
29
+ shortname: str
30
+ fullname: str
31
+ category: int = 0
32
+ visible: bool = True
33
+ startdate: int = 0
34
+ enddate: int = 0
35
+
36
+ def to_dict(self) -> dict:
37
+ return {
38
+ "id": self.id,
39
+ "shortname": self.shortname,
40
+ "fullname": self.fullname,
41
+ "category": self.category,
42
+ "visible": self.visible,
43
+ "startdate": self.startdate,
44
+ "enddate": self.enddate,
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class Activity:
50
+ id: int
51
+ name: str
52
+ modname: str # e.g. "assign", "forum", "resource", "url"
53
+ url: str = ""
54
+ visible: bool = True
55
+ description: str = ""
56
+
57
+ def to_dict(self) -> dict:
58
+ return {
59
+ "id": self.id,
60
+ "name": self.name,
61
+ "modname": self.modname,
62
+ "url": self.url,
63
+ "visible": self.visible,
64
+ "description": self.description,
65
+ }
66
+
67
+
68
+ @dataclass
69
+ class Section:
70
+ id: int
71
+ name: str
72
+ section: int # position/order number
73
+ visible: bool = True
74
+ summary: str = ""
75
+ activities: list[Activity] = field(default_factory=list)
76
+
77
+ def to_dict(self) -> dict:
78
+ return {
79
+ "id": self.id,
80
+ "name": self.name,
81
+ "section": self.section,
82
+ "visible": self.visible,
83
+ "summary": self.summary,
84
+ "activities": [a.to_dict() for a in self.activities],
85
+ }
moodle_cli/output.py ADDED
@@ -0,0 +1,17 @@
1
+ """Structured output: --json and --yaml support."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ import yaml
7
+
8
+
9
+ def output_json(data: dict | list) -> None:
10
+ """Print data as formatted JSON to stdout."""
11
+ json.dump(data, sys.stdout, indent=2, ensure_ascii=False)
12
+ sys.stdout.write("\n")
13
+
14
+
15
+ def output_yaml(data: dict | list) -> None:
16
+ """Print data as YAML to stdout."""
17
+ yaml.dump(data, sys.stdout, default_flow_style=False, allow_unicode=True)
moodle_cli/parser.py ADDED
@@ -0,0 +1,57 @@
1
+ """Parse raw Moodle JSON responses into typed models."""
2
+
3
+ from moodle_cli.models import Activity, Course, Section, UserInfo
4
+
5
+
6
+ def parse_user_info(data: dict) -> UserInfo:
7
+ return UserInfo(
8
+ userid=data["userid"],
9
+ username=data["username"],
10
+ fullname=data["fullname"],
11
+ sitename=data["sitename"],
12
+ siteurl=data["siteurl"],
13
+ lang=data.get("lang", ""),
14
+ )
15
+
16
+
17
+ def parse_course(data: dict) -> Course:
18
+ return Course(
19
+ id=data["id"],
20
+ shortname=data.get("shortname", ""),
21
+ fullname=data.get("fullname", ""),
22
+ category=data.get("category", 0),
23
+ visible=bool(data.get("visible", True)),
24
+ startdate=data.get("startdate", 0),
25
+ enddate=data.get("enddate", 0),
26
+ )
27
+
28
+
29
+ def parse_courses(data: list[dict]) -> list[Course]:
30
+ return [parse_course(c) for c in data]
31
+
32
+
33
+ def parse_activity(data: dict) -> Activity:
34
+ return Activity(
35
+ id=data["id"],
36
+ name=data.get("name", ""),
37
+ modname=data.get("modname", ""),
38
+ url=data.get("url", ""),
39
+ visible=bool(data.get("visible", True)),
40
+ description=data.get("description", ""),
41
+ )
42
+
43
+
44
+ def parse_section(data: dict) -> Section:
45
+ modules = data.get("modules", [])
46
+ return Section(
47
+ id=data["id"],
48
+ name=data.get("name", ""),
49
+ section=data.get("section", 0),
50
+ visible=bool(data.get("visible", True)),
51
+ summary=data.get("summary", ""),
52
+ activities=[parse_activity(m) for m in modules],
53
+ )
54
+
55
+
56
+ def parse_course_contents(data: list[dict]) -> list[Section]:
57
+ return [parse_section(s) for s in data]
moodle_cli/scraper.py ADDED
@@ -0,0 +1,186 @@
1
+ """HTML scraping helpers for authenticated Moodle pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from html import unescape
9
+ from urllib.parse import urljoin
10
+
11
+ from bs4 import BeautifulSoup
12
+
13
+ from moodle_cli.exceptions import AuthError
14
+ from moodle_cli.models import Activity, Section, UserInfo
15
+
16
+
17
+ @dataclass
18
+ class PageContext:
19
+ """Minimal authenticated context extracted from a Moodle page."""
20
+
21
+ sesskey: str
22
+ user_info: UserInfo
23
+
24
+
25
+ def parse_page_context(html: str, base_url: str) -> PageContext:
26
+ """Extract sesskey and user identity from an authenticated page."""
27
+ config = _parse_moodle_config(html)
28
+ soup = BeautifulSoup(html, "html.parser")
29
+
30
+ sesskey = str(config.get("sesskey") or "").strip()
31
+ user_id = int(config.get("userId") or _search_int(html, r'data-user-id="(\d+)"'))
32
+ fullname = _clean_text_from_node(soup.select_one(".userfullname"))
33
+ sitename = _extract_sitename(soup)
34
+ fallback_lang = soup.html.get("lang", "") if soup.html else ""
35
+ lang = str(config.get("language") or fallback_lang)
36
+
37
+ if not sesskey or not user_id or not fullname:
38
+ raise AuthError("Session appears invalid — could not load authenticated Moodle context")
39
+
40
+ return PageContext(
41
+ sesskey=sesskey,
42
+ user_info=UserInfo(
43
+ userid=user_id,
44
+ username="",
45
+ fullname=fullname,
46
+ sitename=sitename,
47
+ siteurl=base_url,
48
+ lang=lang,
49
+ ),
50
+ )
51
+
52
+
53
+ def parse_course_contents_html(html: str, base_url: str) -> list[Section]:
54
+ """Parse rendered Moodle course HTML into sections and activities."""
55
+ soup = BeautifulSoup(html, "html.parser")
56
+ sections: list[Section] = []
57
+
58
+ for section_el in soup.select('li[data-for="section"]'):
59
+ section_id = _safe_int(section_el.get("data-id"))
60
+ section_num = _safe_int(section_el.get("data-number") or section_el.get("data-sectionnum"))
61
+ position_name = _clean_text_from_node(section_el.select_one(".course-section-position-name"))
62
+ main_name = _clean_text_from_node(
63
+ section_el.select_one("h1.sectionname, h2.sectionname, h3.sectionname")
64
+ or section_el.select_one('[data-for="section_title"] a')
65
+ or section_el.select_one('[data-for="section_title"]')
66
+ )
67
+ if position_name and main_name and position_name != main_name:
68
+ section_name = f"{position_name} - {main_name}"
69
+ else:
70
+ section_name = main_name or position_name or f"Section {section_num}"
71
+
72
+ summary_el = section_el.select_one(".summarytext, [data-for='sectioninfo']")
73
+ summary = _clean_text_from_node(summary_el)
74
+ section_visible = "hidden" not in section_el.get("class", [])
75
+
76
+ activities: list[Activity] = []
77
+ seen_activity_ids: set[int] = set()
78
+ for activity_el in section_el.select('li[data-for="cmitem"]'):
79
+ activity_id = _safe_int(activity_el.get("data-id"))
80
+ if activity_id and activity_id in seen_activity_ids:
81
+ continue
82
+ if activity_id:
83
+ seen_activity_ids.add(activity_id)
84
+
85
+ classes = activity_el.get("class", [])
86
+ modname = next((cls[8:] for cls in classes if cls.startswith("modtype_")), "")
87
+ card = activity_el.select_one('[data-region="activity-card"]') or activity_el.select_one(".activity-item")
88
+
89
+ activity_name = _clean_text_from_node(
90
+ activity_el.select_one(".activityname .instancename")
91
+ or activity_el.select_one(".activityname")
92
+ or activity_el.select_one("a.aalink")
93
+ )
94
+ if not activity_name and card is not None:
95
+ activity_name = (card.get("data-activityname") or "").strip()
96
+ if not activity_name:
97
+ continue
98
+
99
+ link = activity_el.select_one(".activityname a, a.aalink, a[href]")
100
+ href = link.get("href") if link else ""
101
+ description = _clean_text_from_node(
102
+ activity_el.select_one("[data-region='activity-description'], .contentafterlink, .description")
103
+ )
104
+ activity_visible = not any(flag in classes for flag in ("hidden", "stealth", "dimmed"))
105
+
106
+ activities.append(
107
+ Activity(
108
+ id=activity_id,
109
+ name=activity_name,
110
+ modname=modname,
111
+ url=urljoin(base_url, href) if href else "",
112
+ visible=activity_visible,
113
+ description=description,
114
+ )
115
+ )
116
+
117
+ sections.append(
118
+ Section(
119
+ id=section_id,
120
+ name=section_name,
121
+ section=section_num,
122
+ visible=section_visible,
123
+ summary=summary,
124
+ activities=activities,
125
+ )
126
+ )
127
+
128
+ return sections
129
+
130
+
131
+ def parse_course_section_numbers(html: str, course_id: int) -> list[int]:
132
+ """Extract the ordered section numbers exposed by the course navigation."""
133
+ soup = BeautifulSoup(html, "html.parser")
134
+ section_numbers: list[int] = []
135
+
136
+ for link in soup.select(f'a[href*="/course/view.php?id={course_id}&section="]'):
137
+ href = link.get("href") or ""
138
+ match = re.search(r"[?&]section=(\d+)", href)
139
+ if not match:
140
+ continue
141
+
142
+ section_num = int(match.group(1))
143
+ if section_num not in section_numbers:
144
+ section_numbers.append(section_num)
145
+
146
+ return section_numbers
147
+
148
+
149
+ def _parse_moodle_config(html: str) -> dict:
150
+ match = re.search(r"M\.cfg\s*=\s*({.*?});", html, re.S)
151
+ if not match:
152
+ return {}
153
+
154
+ try:
155
+ return json.loads(match.group(1))
156
+ except json.JSONDecodeError:
157
+ return {}
158
+
159
+
160
+ def _extract_sitename(soup: BeautifulSoup) -> str:
161
+ title = _clean_text_from_node(soup.title)
162
+ if "|" in title:
163
+ return title.rsplit("|", 1)[1].strip()
164
+ return title
165
+
166
+
167
+ def _search_int(html: str, pattern: str) -> int:
168
+ match = re.search(pattern, html)
169
+ return int(match.group(1)) if match else 0
170
+
171
+
172
+ def _safe_int(value: str | None) -> int:
173
+ try:
174
+ return int(value or 0)
175
+ except ValueError:
176
+ return 0
177
+
178
+
179
+ def _clean_text_from_node(node) -> str:
180
+ if node is None:
181
+ return ""
182
+ return _clean_text(node.get_text(" ", strip=True))
183
+
184
+
185
+ def _clean_text(value: str) -> str:
186
+ return " ".join(unescape(value or "").split())
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: moodle-cli
3
+ Version: 0.1.0b0
4
+ Summary: Terminal-first CLI for Moodle LMS
5
+ Project-URL: Homepage, https://github.com/bunizao/moodle-cli
6
+ Project-URL: Repository, https://github.com/bunizao/moodle-cli
7
+ Project-URL: Issues, https://github.com/bunizao/moodle-cli/issues
8
+ Author: bunizao
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 bunizao
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Requires-Python: >=3.10
32
+ Requires-Dist: beautifulsoup4>=4.12
33
+ Requires-Dist: browser-cookie3>=0.19
34
+ Requires-Dist: click>=8.0
35
+ Requires-Dist: pyyaml>=6.0
36
+ Requires-Dist: requests>=2.28
37
+ Requires-Dist: rich>=13.0
38
+ Description-Content-Type: text/markdown
39
+
40
+ # moodle-cli
41
+
42
+ Terminal-first CLI for Moodle LMS that reuses an authenticated browser session.
43
+
44
+ Repository: <https://github.com/bunizao/moodle-cli>
45
+
46
+ ## Features
47
+
48
+ - No API token setup required
49
+ - Reads `MoodleSession` from your browser or `MOODLE_SESSION`
50
+ - Works with Moodle AJAX APIs and falls back to authenticated page scraping when needed
51
+ - Terminal output plus `--json` and `--yaml`
52
+
53
+ ## Requirements
54
+
55
+ - Python 3.10+
56
+ - `uv`
57
+ - An active Moodle browser session, or a `MOODLE_SESSION` environment variable
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ uv sync
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ```bash
68
+ uv run moodle --help
69
+ uv run moodle user
70
+ uv run moodle courses
71
+ uv run moodle activities 34637
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ On first run, the CLI will ask for your Moodle base URL and save it to `config.yaml`.
77
+
78
+ Safety checks during setup:
79
+
80
+ - Requires a full root URL such as `https://school.example.edu`
81
+ - Rejects paths, query strings, and fragments
82
+ - Probes the site before saving
83
+ - Requires explicit confirmation if the target does not look like Moodle
84
+
85
+ If you prefer to configure it manually, create `config.yaml` in the project directory or in `~/.config/moodle-cli/`:
86
+
87
+ ```yaml
88
+ base_url: https://school.example.edu
89
+ ```
90
+
91
+ You can copy from `config.example.yaml`.
92
+
93
+ Environment overrides:
94
+
95
+ - `MOODLE_BASE_URL`
96
+ - `MOODLE_SESSION`
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ uv run python -m compileall moodle_cli
102
+ uv build
103
+ ```
104
+
105
+ ## CI
106
+
107
+ GitHub Actions runs the following checks on pushes and pull requests:
108
+
109
+ - Dependency lock sync with `uv`
110
+ - Bytecode compilation
111
+ - CLI smoke check
112
+ - Package build
@@ -0,0 +1,17 @@
1
+ moodle_cli/__init__.py,sha256=m04x4InJgeyxvzMnKndLw-ef5zKtcF45SIfjo0eCyAQ,66
2
+ moodle_cli/auth.py,sha256=DBiMu_9niev-AiunRPwn3OJu9Xqg9YJ30VV-VOLyHV4,2184
3
+ moodle_cli/cli.py,sha256=okjfQqox8oSOyrIjNwCORk2V3OMDR-p6HuwBZRaEoDU,4581
4
+ moodle_cli/client.py,sha256=q-gmgoABAw0qvMg3ejnW9l4ZLGG6y5usIB_3AiPwkGM,7457
5
+ moodle_cli/config.py,sha256=dBHhumvsO7XQlFYh5wTtHD9zEOZJ2TA6lnHpP1EkeyA,4649
6
+ moodle_cli/constants.py,sha256=M_Oul6Wg19mbHn2x-RQtHIYMu2mNSdMDK6BoA4oFxdg,638
7
+ moodle_cli/exceptions.py,sha256=n4zyh1T2ET3z8CSYSkbMoP9t4fseRz0cOKW02rXeBuc,441
8
+ moodle_cli/formatter.py,sha256=jLtkCDNE-cq6LseWjwPhM8D2A_vUYs8mKUBhZ15PNUg,2860
9
+ moodle_cli/models.py,sha256=N2ovDmxIBUnOSHLlaYJtpAu6AqohcUR8PVo5rePdwg8,1931
10
+ moodle_cli/output.py,sha256=Eauf9jq6_ibQUr2omqtVpqSnRu77su-GdeL34fz-ahA,438
11
+ moodle_cli/parser.py,sha256=EUcoYhOg03_70uHoX_ZE7QKvSs7eC3rROVvycAbyKMM,1617
12
+ moodle_cli/scraper.py,sha256=LMtis0JBOO6KgYpNW7aqszmKSrX50F91wa0b5Q8YGeQ,6469
13
+ moodle_cli-0.1.0b0.dist-info/METADATA,sha256=Gww1emJzsS9WWWycOzMrGuF8bSa7iU7Ibe-Y-uNxzeE,3331
14
+ moodle_cli-0.1.0b0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ moodle_cli-0.1.0b0.dist-info/entry_points.txt,sha256=fuUGPE0CmD0KfQaGx4pmMnL2I6lTZxrVIb-LebQLNkw,47
16
+ moodle_cli-0.1.0b0.dist-info/licenses/LICENSE,sha256=mNchh_7Qge5KyljHGnGDu3I6eiVijuU22NxtZp5Dgh0,1064
17
+ moodle_cli-0.1.0b0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ moodle = moodle_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bunizao
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.