Skip to main content

moodlectl

A Python CLI and reusable API to inspect Moodle courses, manage learning resources, and read and write forum discussions (posts, replies, attachments), including Moodle instances whose users authenticate using a university SSO provider (SAML / Shibboleth / OIDC). Designed for Moodle 4.x and 5.x forms.

This is an independent utility, not an official University of Bologna/Moodle integration.

Why two backends?

  • Browser automation (default) uses Playwright (WebKit, Firefox, Chromium, Chrome or Edge). You authenticate using the institution's real browser SSO/MFA flow; no passwords or SSO bypasses. This is the practical route for creating standard URL resources when REST tokens are not enabled or when a create-activity web service is not provisioned.
  • Moodle REST (optional) can call external functions authorized for an existing Moodle web-service token. Moodle web services are configurable per installation: the client cannot enable them for you. moodlepy is a relevant library for this interface, but it neither solves SSO nor supplies an unrestricted create-activity endpoint. This project uses a tiny generic REST wrapper instead.

Setup (macOS, Linux, Python >=3.10)

Install uv, then run:

uv sync --all-groups
uv run playwright install chromium   # optional if Chrome or Edge is installed
source .venv/bin/activate

moodlectl config set-site https://virtuale.unibo.it
moodlectl login

uv sync --all-groups creates the virtual environment and installs the project, test tools, and development tools from pyproject.toml.

login opens a visible, dedicated browser window. Complete the normal Unibo SSO process there, including MFA when requested. When Moodle is visible, return to the terminal and press Enter. The persistent browser profile is reused by subsequent commands (session cookies are kept in the profile directory too, since the browser would otherwise drop them on exit). In this mode the tool never sees your password. Use the same --profile for login and later operations.

Without a display (e.g. over SSH), use moodlectl --headless login: no window opens, and each SSO page is shown in the terminal as text, followed by prompts for its fields (passwords via a hidden prompt, never stored) and a numbered list of buttons/links to click. Press Enter on a page with no fields to wait, e.g. while approving an MFA push. If MOODLE_USERNAME / MOODLE_PASSWORD are set, username and password fields are filled from them instead of prompting (set the password with read -s rather than typing it on the command line).

All other commands run headless by default, since they only reuse the saved session; pass --headed (or MOODLE_HEADLESS=0) to watch the browser, e.g. when debugging a form. MOODLE_HEADLESS=1 makes login headless too.

--browser (or MOODLE_BROWSER), placed before the subcommand, picks the browser:

Value Browser Install
webkit Playwright's WebKit (Safari's engine) playwright install webkit
firefox Playwright's Firefox build playwright install firefox
chromium Playwright's Chromium playwright install chromium
chrome, msedge the installed Google Chrome / Microsoft Edge none

The default, auto, tries Edge on Windows and Firefox on Linux, then chromium, then chrome; on macOS it tries chrome, then chromium. WebKit is not used by default: it rejects the Shibboleth session cookie, so Unibo SSO login loops. Playwright cannot drive the installed Safari or Firefox apps, only its own builds. Each engine keeps its own login session, so log in again after switching between engines (Chromium, Chrome and Edge share one).

CLI essentials

Commands print their results as YAML (multi-line texts, such as forum messages, as | blocks), so they are readable and can be piped to tools like yq.

# Read courses from your My Courses view (this view may be paginated)
moodlectl courses list

# Search the site's visible course catalogue
moodlectl courses list --search "Agenti Intelligenti"

# Set the default course for the current profile
moodlectl courses use 80509

moodlectl sections list
moodlectl resources list --section "Module 2"

# Create an URL pointing to a PDF, with visible Markdown description
moodlectl resources add-url "L2 - My lecture" \
  "https://example.org/slides.pdf" \
  --section "Module 2" \
  --description '[Online slides](https://example.org/slides/)' \
  --format markdown \
  --show-description

# Update an existing URL module by course-module ID (cmid)
moodlectl resources update-url 12345 --name "Updated title" \
  --url https://example.org/new.pdf --description '[Slides](https://example.org/)'

# Create a Page resource (editor support varies per site, see below)
moodlectl resources add-page "Course info" --content "<p>Hello</p>" --section "Module 2"

# Inspect the real URL field, visibility, description etc.
moodlectl resources show 12345

# Forums: list them, their discussions, and a discussion's posts (as plain text, or --html)
moodlectl forums list
moodlectl discussions list 2263032            # forum cmid
moodlectl discussions show 230424             # discussion id
moodlectl discussions list 'https://virtuale.unibo.it/mod/forum/view.php?id=2263032'  # URLs too
# Write: start a discussion, reply (optionally privately), edit, delete, set states
moodlectl discussions add 2263032 "Office hours" -m "Moved to **Friday**"   # --format markdown
moodlectl posts reply 315396 -m "Thanks!" --private                      # post id
moodlectl posts reply 315396 -m "Slides attached" -a l1.pdf -a l2.pdf   # --attach, repeatable
echo "Longer text" | moodlectl posts edit 315849 -m - --subject "New subject"
moodlectl posts delete 315849                 # asks after showing Moodle's confirmation; -y skips
moodlectl discussions delete 230424           # deletes the whole discussion (same prompt)
moodlectl discussions set 230424 --lock --pin --favourite --unsubscribe

# Course, section and forum listings are cached per URL for 1h (in the temp dir)
moodlectl --cache-ttl 600 sections list   # or MOODLE_CACHE_TTL=600
moodlectl --no-cache sections list        # or MOODLE_NO_CACHE=1; refreshes the cache
moodlectl cache clear

# See all form control selectors and select options for customization
moodlectl resources inspect-form --section "Module 2" --kind url

# Generate an actual YAML manifest from the current public APICe slide table
moodlectl manifest from-table \
  'https://apice.unibo.it/bin/view/Course/Iag2627M2/?mode=data&data=course&display=Slides' \
  --course 80509 --section "Module 2" --output module2-2026.yaml

# Batch from a YAML file: preview first, then apply
moodlectl resources sync module2-2026.yaml
moodlectl resources sync module2-2026.yaml --apply

# Provisioned native Moodle REST functions (token supplied via environment)
export MOODLE_WS_TOKEN='YOUR_PROVISIONED_TOKEN'
moodlectl api courses
moodlectl api contents --course 80509
moodlectl api call mod_url_get_urls_by_courses \
  --params '{"courseids":[80509]}'

Use --profile NAME and --site URL (or MOODLE_SITE_URL) before the subcommand to select an independent account or Moodle installation:

moodlectl --profile teaching --site https://virtuale.unibo.it login
moodlectl --profile teaching courses use 80509
moodlectl --profile teaching resources list --section "Module 2"

The saved default site/course are in ~/.config/moodlectl/PROFILE.json (or $XDG_CONFIG_HOME), and the browser profile is under ~/.local/share/moodlectl/profiles/PROFILE (or $XDG_DATA_HOME); moodlectl config show prints both. Treat the browser profile as an authenticated session.

YAML batch files

course: 80509
section: "Module 2"
resources:
  - kind: url
    name: "L1 - Intro"
    url: "https://example.org/l1.pdf"
    description: "[Rolling version](https://example.org/l1/)"
    description_format: markdown
    show_description: true
    visible: false
    display: "Open" # Must match a dropdown label in the site's UI; optional
    fields:
      "#id_visible": "0" # Arbitrary form fields; optional

resources sync is a preview unless --apply is provided. The batch stops on the first form error and prints the successful operations completed so far. Names are checked for duplicates within the selected section (kind + name): existing resources are skipped by default, or --if-exists error can be used. It does not delete or overwrite anything. --fields on CLI accepts the same custom field mapping in JSON. A field key is a CSS selector; choose values such as a string, a boolean for a checkbox, or {label: ...} for dropdowns.

The browser supports url activities as the stable, screenshot-grounded workflow. get_url_details and inspect_form expose read-only inspection of URL settings and arbitrary Moodle form controls; their CLI counterparts are resources show and resources inspect-form. The generic resource creator also accepts named module kinds (e.g. forum or assign) with site-specific required fields provided in fields, but those kinds are experimental. An adapter for Moodle page resources is also included; it recognizes plain textarea, Atto contenteditable and TinyMCE iframe configurations. page remains dependent on the specific site's editor configuration; test on an expendable course first. File uploads, labels, activity deletion, complex access restrictions and section creation are not implemented. Custom form fields accommodate additional activity settings where Moodle exposes them.

Forums

Forums are course resources of kind forum. Commands follow Moodle's hierarchy: forums (find them), discussions (take a forum cmid, list/read/start/manage its discussions), posts (take a post id: reply, edit, delete). Forums and discussions can also be given as pasted view.php?id= / discuss.php?d= URLs; ids are shown by the listing commands.

Reading uses Moodle's own AJAX service (mod_forum_get_discussion_posts) through the browser session; starting, replying to, editing and deleting posts submit the same mod/forum/post.php forms as the web UI, and lock/pin/favourite/subscribe call the UI's AJAX toggles. Discussion lists and posts are cached like other listings (--no-cache for fresh ones).

discussions add skips (or --if-exists error) when a discussion with the same subject exists. posts reply warns, and asks before posting (-y skips), when you already posted a very similar message (>= 80% alike) in the discussion. --attach on discussions add, posts reply and posts edit uploads files like the form's file picker (edit adds to the existing ones; same-named files are refused); discussions show lists attachment URLs. Deleting a discussion's first post deletes the whole discussion. Not implemented: removing attachments, splitting/moving discussions, ratings, export, read tracking, group selection.

Programmatic Python API

from moodlectl import BrowserMoodle, ResourceSpec
from moodlectl.config import Settings

with BrowserMoodle(Settings.load()) as moodle:
    for section in moodle.course_sections(80509):
        print(section.number, section.name)
        for resource in section.resources:
            print(resource.kind, resource.cmid, resource.name, resource.url)

    status = moodle.add_resource(
        80509,
        "Module 2",
        ResourceSpec(
            kind="url",
            name="L2 - Agents",
            url="https://example.org/l2.pdf",
            description="[Online](https://example.org/l2/)",
            description_format="markdown",
            show_description=True,
            fields={"#id_display": {"label": "Open"}},
        ),
    )
    print(status)  # 'created' or 'skipped'

    # Forums -> discussions -> posts (messages as plain text; html=True for Moodle's HTML)
    for forum in moodle.list_forums(80509):
        for discussion in moodle.list_discussions(forum.cmid):
            for post in moodle.list_posts(discussion.id):
                print(post.author, post.created, post.subject, post.attachments)
                print(post.message)

    new_post = moodle.reply(315396, "Slides **attached**", attachments=["l1.pdf"])
    moodle.edit_post(new_post, message="Slides attached (updated)")
    moodle.set_discussion(230424, pinned=True)

Write methods mirror the CLI: add_discussion, reply (its confirm_similar callback decides on near-duplicate replies; by default it warns and posts), edit_post, delete_post (its confirm callback sees Moodle's confirmation text), set_discussion.

REST usage:

from moodlectl import MoodleREST

with MoodleREST("https://virtuale.unibo.it") as ws:
    print(ws.site_info())
    print(ws.course_contents(80509))
    print(ws.call("mod_url_get_urls_by_courses", courseids=[80509]))

Limitations and operational notes

  • Validated live on Virtuale (Moodle 4.5): login, course/section/resource listings and all forum commands. Resource writes (add-url, add-page, update-url, sync) match screenshots of the forms but are not yet validated live. Site plugins, roles, Moodle versions and updates can change selectors or permissions.
  • Moodle core REST exposes course reading and module management functions, but resource creation is not universally exposed as a generic external function. A REST token must be provisioned with adequate functions/capabilities by the site operator; SSO cookies are not a REST wstoken.
  • Browser course discovery is limited to course links rendered in the My Courses or course-search page. Explicit --course works even if the list is incomplete.
  • The browser profile stores authenticating cookies; protect it as you would a web browser user profile. Passwords are never stored; only --headless login reads one, and passes it straight to the SSO page.
  • Moodle rich-text editors and course themes vary: unrecognized controls raise a clear MoodleFormError instead of silently submitting incomplete data.
  • A stable independent test Moodle instance or a dedicated test course is recommended for live integration validation before making bulk course edits.

Tests

uv run poe test
MOODLE_LIVE_TEST=1 uv run poe test-live   # real SSO login per browser, see tests/test_live.py
uv run poe static-checks
uv run poe format-check

Unit tests cover manifest validation, HTML table import, configuration, REST parameter serialization, browser selection, session cookies and page cache, CLI headless defaults, forum message rendering, near-duplicate detection and forum URL parsing. Integration against live Virtuale requires running moodlectl login locally with an authorized account; no stored credentials or Moodle tokens are included.

Contributor/agent notes (code layout, conventions, implementation status) are in AGENTS.md.

Releases and dependency updates

Releases use semantic-release and Conventional Commit messages. Pushes to main or master can publish the package to PyPI; other branches and the initial commit run in dry-run mode. Configure PYPI_TOKEN and RELEASE_TOKEN as repository secrets before enabling releases. TEST_PYPI_TOKEN is used when the repository is a template or its name contains template.

Renovate is configured in renovate.json to open dependency update pull requests and enable automerge after checks pass. Install the Renovate GitHub App and enable pull request automerge in repository settings to activate it.

References

Metadata

Release files for moodlectl 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for moodlectl 1.0.0
File Size Uploaded
moodlectl-1.0.0.tar.gz 44.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for moodlectl 1.0.0
File Interpreter ABI Platform
moodlectl-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 82.1 kB

Release files / moodlectl-1.0.0.tar.gz

Download URL moodlectl-1.0.0.tar.gz
Size 44.8 kB
Tags Source
SHA-256 checksum
How to use checksums
1e0cdc4548562857c4d7bcf7ccc35eac521dc820df7e1f1e1c214a1a9c2721ed
BLAKE2b-256 checksum
How to use checksums
4907746f36d2e078085ea7f4d59167dbd666cba167a8b0a6ae72dc3bdaafa304
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / moodlectl-1.0.0-py3-none-any.whl

Download URL moodlectl-1.0.0-py3-none-any.whl
Size 37.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
01e9bb703e9efaa5549d628630523d0131adf2c855695a2191c3081660b67c9f
BLAKE2b-256 checksum
How to use checksums
f4fc2897f78a48a0da11e701f1ce8627c02319d8078286ce085ae842a5398b94
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

1.2.0

2 release files

1.1.1

2 release files

1.1.0

2 release files

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page