moodle-cli 0.1.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.
moodle_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Terminal-first CLI for Moodle LMS."""
2
+
3
+ __version__ = "0.1.0"
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,198 @@
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
+ from moodle_cli.update_check import check_for_updates
17
+
18
+ stdout_console = Console()
19
+ stderr_console = Console(stderr=True)
20
+
21
+
22
+ def _require_course_id(ctx: click.Context, course_id: int | None) -> int:
23
+ """Validate a required course ID and show a helpful next step."""
24
+ if course_id is not None:
25
+ return course_id
26
+
27
+ command_name = ctx.info_name or "course"
28
+ raise click.UsageError(
29
+ "Missing argument 'COURSE_ID'. Run 'moodle courses' to list available course IDs, "
30
+ f"then retry with 'moodle {command_name} COURSE_ID'.",
31
+ ctx=ctx,
32
+ )
33
+
34
+
35
+ def _print_loading(message: str) -> None:
36
+ """Print a short loading hint to stderr for slow network calls."""
37
+ stderr_console.print(f"[dim]{message}[/]")
38
+
39
+
40
+ @click.group()
41
+ @click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
42
+ @click.version_option(version=__version__)
43
+ @click.pass_context
44
+ def cli(ctx: click.Context, verbose: bool) -> None:
45
+ """Terminal-first CLI for Moodle LMS."""
46
+ logging.basicConfig(
47
+ level=logging.DEBUG if verbose else logging.WARNING,
48
+ format="%(name)s: %(message)s",
49
+ )
50
+
51
+ ctx.ensure_object(dict)
52
+ ctx.obj["_config"] = None
53
+
54
+ def get_config() -> dict:
55
+ if ctx.obj["_config"] is None:
56
+ ctx.obj["_config"] = load_config()
57
+ return ctx.obj["_config"]
58
+
59
+ ctx.obj["get_config"] = get_config
60
+
61
+ # Lazy client creation; only authenticate when a command needs it.
62
+ ctx.obj["_client"] = None
63
+
64
+ def get_client() -> MoodleClient:
65
+ if ctx.obj["_client"] is None:
66
+ config = get_config()
67
+ session_cookie = get_session(config["base_url"])
68
+ ctx.obj["_client"] = MoodleClient(config["base_url"], session_cookie)
69
+ return ctx.obj["_client"]
70
+
71
+ ctx.obj["get_client"] = get_client
72
+
73
+
74
+ @cli.command(name="user")
75
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
76
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
77
+ @click.pass_context
78
+ def user(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
79
+ """Show authenticated user info."""
80
+ client = ctx.obj["get_client"]()
81
+ info = client.get_site_info()
82
+
83
+ if as_json:
84
+ output_json(info.to_dict())
85
+ elif as_yaml:
86
+ output_yaml(info.to_dict())
87
+ else:
88
+ print_user_info(info)
89
+
90
+
91
+ @cli.command()
92
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
93
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
94
+ @click.pass_context
95
+ def courses(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
96
+ """List enrolled courses."""
97
+ _print_loading("Loading courses...")
98
+ client = ctx.obj["get_client"]()
99
+ course_list = client.get_courses()
100
+
101
+ if as_json:
102
+ output_json([c.to_dict() for c in course_list])
103
+ elif as_yaml:
104
+ output_yaml([c.to_dict() for c in course_list])
105
+ else:
106
+ print_courses(course_list)
107
+
108
+
109
+ @cli.command(name="update")
110
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
111
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
112
+ def update(as_json: bool, as_yaml: bool) -> None:
113
+ """Check whether a newer moodle-cli version is available."""
114
+ _print_loading("Checking for updates...")
115
+ info = check_for_updates()
116
+
117
+ if as_json:
118
+ output_json(info.to_dict())
119
+ elif as_yaml:
120
+ output_yaml(info.to_dict())
121
+ elif info.update_available:
122
+ stdout_console.print(
123
+ f"[yellow]Update available:[/] {info.latest_version} "
124
+ f"(installed: {info.current_version})"
125
+ )
126
+ stdout_console.print("Upgrade with:")
127
+ for command in info.upgrade_commands:
128
+ stdout_console.print(f" {command}")
129
+ else:
130
+ stdout_console.print(f"[green]{info.package_name} is up to date[/] ({info.current_version})")
131
+
132
+
133
+ @cli.command()
134
+ @click.argument("course_id", type=int, required=False)
135
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
136
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
137
+ @click.pass_context
138
+ def activities(ctx: click.Context, course_id: int | None, as_json: bool, as_yaml: bool) -> None:
139
+ """List activities in a course (sections and modules)."""
140
+ course_id = _require_course_id(ctx, course_id)
141
+ _print_loading(f"Loading activities for course {course_id}...")
142
+ client = ctx.obj["get_client"]()
143
+ sections = client.get_course_contents(course_id)
144
+
145
+ if as_json:
146
+ output_json([s.to_dict() for s in sections])
147
+ elif as_yaml:
148
+ output_yaml([s.to_dict() for s in sections])
149
+ else:
150
+ print_course_contents(sections, course_label=f"Course {course_id}")
151
+
152
+
153
+ @cli.command()
154
+ @click.argument("course_id", type=int, required=False)
155
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
156
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
157
+ @click.pass_context
158
+ def course(ctx: click.Context, course_id: int | None, as_json: bool, as_yaml: bool) -> None:
159
+ """Show course detail with sections."""
160
+ course_id = _require_course_id(ctx, course_id)
161
+ _print_loading(f"Loading course {course_id}...")
162
+ client = ctx.obj["get_client"]()
163
+ sections = client.get_course_contents(course_id)
164
+
165
+ if as_json:
166
+ output_json([s.to_dict() for s in sections])
167
+ elif as_yaml:
168
+ output_yaml([s.to_dict() for s in sections])
169
+ else:
170
+ print_course_contents(sections, course_label=f"Course {course_id}")
171
+
172
+
173
+ def main() -> None:
174
+ """Entry point with error handling."""
175
+ try:
176
+ cli(standalone_mode=False)
177
+ except click.exceptions.Abort:
178
+ sys.exit(130)
179
+ except click.exceptions.Exit as e:
180
+ sys.exit(e.exit_code)
181
+ except click.ClickException as e:
182
+ e.show()
183
+ sys.exit(e.exit_code)
184
+ except AuthError as e:
185
+ stderr_console.print(f"[bold red]Auth error:[/] {e}")
186
+ sys.exit(1)
187
+ except MoodleAPIError as e:
188
+ stderr_console.print(f"[bold red]API error:[/] {e}")
189
+ if e.error_code:
190
+ stderr_console.print(f" Error code: {e.error_code}")
191
+ sys.exit(1)
192
+ except MoodleCLIError as e:
193
+ stderr_console.print(f"[bold red]Error:[/] {e}")
194
+ sys.exit(1)
195
+
196
+
197
+ if __name__ == "__main__":
198
+ 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,161 @@
1
+ """Config loading and validation."""
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
+
15
+ def _config_candidates() -> list[Path]:
16
+ return [
17
+ Path.cwd() / CONFIG_FILENAME,
18
+ Path(CONFIG_DIR).expanduser() / CONFIG_FILENAME,
19
+ ]
20
+
21
+
22
+ def _find_config_file() -> Path | None:
23
+ """Search for config.yaml in CWD, then ~/.config/moodle-cli/."""
24
+ for path in _config_candidates():
25
+ if path.is_file():
26
+ return path
27
+ return None
28
+
29
+
30
+ def _default_config_path() -> Path:
31
+ cwd_config = Path.cwd() / CONFIG_FILENAME
32
+ if cwd_config.exists():
33
+ return cwd_config
34
+ return Path(CONFIG_DIR).expanduser() / CONFIG_FILENAME
35
+
36
+
37
+ def _read_config_file(path: Path) -> dict:
38
+ with open(path, encoding="utf-8") as f:
39
+ return yaml.safe_load(f) or {}
40
+
41
+
42
+ def _save_config(path: Path, config: dict) -> None:
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ with open(path, "w", encoding="utf-8") as f:
45
+ yaml.safe_dump(config, f, sort_keys=True)
46
+
47
+
48
+ def _load_existing_config() -> tuple[dict, Path | None]:
49
+ config_file = _find_config_file()
50
+ config = _read_config_file(config_file) if config_file else {}
51
+ return config, config_file
52
+
53
+
54
+ def _validate_base_url(value: str) -> str:
55
+ raw = value.strip()
56
+ if not raw:
57
+ raise MoodleCLIError("Base URL cannot be empty.")
58
+ if "://" not in raw:
59
+ raise MoodleCLIError("Base URL must include the scheme, for example https://school.example.edu")
60
+
61
+ parsed = urlparse(raw)
62
+ if parsed.scheme not in {"http", "https"}:
63
+ raise MoodleCLIError("Base URL must start with http:// or https://")
64
+ if not parsed.hostname:
65
+ raise MoodleCLIError("Base URL must include a hostname")
66
+ if parsed.query or parsed.fragment:
67
+ raise MoodleCLIError("Base URL must not include query parameters or fragments")
68
+ if parsed.path not in {"", "/"}:
69
+ raise MoodleCLIError("Base URL must be the site root, for example https://school.example.edu")
70
+
71
+ return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
72
+
73
+
74
+ def _missing_base_url_message(config_file: Path | None) -> str:
75
+ target_path = config_file or _default_config_path()
76
+ return "\n".join(
77
+ [
78
+ "No base_url configured.",
79
+ f"Add base_url to {target_path} or set MOODLE_BASE_URL.",
80
+ "Required format:",
81
+ " base_url: https://school.example.edu",
82
+ "Use the site root only. Do not include paths like /login/index.php or /my/.",
83
+ ]
84
+ )
85
+
86
+
87
+ def _probe_base_url(base_url: str) -> tuple[bool, str]:
88
+ """Check whether the configured URL exposes a Moodle-specific endpoint."""
89
+ try:
90
+ response = requests.get(f"{base_url}/login/token.php", timeout=10, allow_redirects=True)
91
+ except requests.RequestException as exc:
92
+ return False, f"Could not reach {base_url}: {exc}"
93
+
94
+ content_type = response.headers.get("content-type", "").lower()
95
+ body = response.text[:5000].lower()
96
+ looks_json = "application/json" in content_type or body.startswith("{")
97
+ looks_moodle_token_error = any(
98
+ marker in body
99
+ for marker in [
100
+ '"errorcode":"missingparam"',
101
+ '"errorcode":"invalidparameter"',
102
+ '"errorcode":"invalidlogin"',
103
+ "a required parameter (username) was missing",
104
+ ]
105
+ )
106
+
107
+ if response.status_code >= 400:
108
+ return False, f"{base_url} returned HTTP {response.status_code}"
109
+ if looks_json and looks_moodle_token_error:
110
+ return True, ""
111
+
112
+ return False, f"{base_url} does not expose the expected Moodle token endpoint"
113
+
114
+
115
+ def _prompt_for_base_url() -> str:
116
+ click.secho("\nConfiguration required", fg="yellow", bold=True)
117
+ click.secho("Moodle base URL is not configured yet.", fg="yellow")
118
+ click.secho("Required format: https://school.example.edu", fg="cyan", bold=True)
119
+ click.echo("Use the site root only. Do not include paths like /login/index.php or /my/.")
120
+ click.echo()
121
+
122
+ while True:
123
+ try:
124
+ base_url = _validate_base_url(
125
+ click.prompt(
126
+ click.style("Moodle base URL", fg="green", bold=True),
127
+ prompt_suffix=click.style(" > ", fg="green", bold=True),
128
+ type=str,
129
+ )
130
+ )
131
+ except MoodleCLIError as exc:
132
+ click.secho(f"Invalid URL: {exc}", fg="red")
133
+ continue
134
+
135
+ looks_valid, message = _probe_base_url(base_url)
136
+ if looks_valid:
137
+ return base_url
138
+
139
+ click.secho(f"Validation failed: {message}", fg="red")
140
+
141
+
142
+ def load_config() -> dict:
143
+ """Load configuration and require an explicit base_url."""
144
+ config, config_file = _load_existing_config()
145
+
146
+ if env_url := os.environ.get(ENV_MOODLE_BASE_URL):
147
+ config["base_url"] = _validate_base_url(env_url)
148
+ return config
149
+
150
+ if base_url := config.get("base_url"):
151
+ config["base_url"] = _validate_base_url(str(base_url))
152
+ return config
153
+
154
+ if click.get_text_stream("stdin").isatty():
155
+ config["base_url"] = _prompt_for_base_url()
156
+ target_path = config_file or _default_config_path()
157
+ _save_config(target_path, config)
158
+ click.echo(f"Saved base_url to {target_path}")
159
+ return config
160
+
161
+ raise MoodleCLIError(_missing_base_url_message(config_file))
@@ -0,0 +1,22 @@
1
+ """Default values and API paths."""
2
+
3
+ PACKAGE_NAME = "moodle-cli"
4
+ PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
5
+
6
+ AJAX_SERVICE_PATH = "/lib/ajax/service.php"
7
+ DASHBOARD_PATH = "/my/"
8
+ COURSE_PATH = "/course/view.php"
9
+
10
+ # Moodle AJAX function names
11
+ FUNC_GET_SITE_INFO = "core_webservice_get_site_info"
12
+ FUNC_GET_COURSES = "core_enrol_get_users_courses"
13
+ FUNC_GET_COURSES_BY_TIMELINE = "core_course_get_enrolled_courses_by_timeline_classification"
14
+ FUNC_GET_COURSE_CONTENTS = "core_course_get_contents"
15
+
16
+ # Config file locations (checked in order)
17
+ CONFIG_FILENAME = "config.yaml"
18
+ CONFIG_DIR = "~/.config/moodle-cli"
19
+
20
+ # Environment variable names
21
+ ENV_MOODLE_SESSION = "MOODLE_SESSION"
22
+ 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