diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51a7771..3f6b2ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ on: - main jobs: - test: + lint: runs-on: ubuntu-latest steps: @@ -23,40 +23,98 @@ jobs: uses: actions/setup-python@v6 with: python-version: '3.13' - cache: 'pip' # caching pip dependencies + cache: 'pip' - name: Install dependencies - run: | - pip install -r requirements.txt - pip install -e . + run: python -m pip install -e ".[dev]" - name: Lint run: make lint + unit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: python -m pip install -e ".[dev]" + + - name: Run smoke tests + run: make test-unit + + integration: + runs-on: ubuntu-latest + needs: + - lint + - unit + strategy: + fail-fast: false + matrix: + include: + - python-version: '3.8' + moodle-image: erseco/alpine-moodle:v4.5.5 + moodle-label: Moodle 4.5.5 + moodle-cache-key: moodle-4-5-5 + run-examples: false + - python-version: '3.13' + moodle-image: erseco/alpine-moodle:v5.0.1 + moodle-label: Moodle 5.0.1 + moodle-cache-key: moodle-5-0-1 + run-examples: true + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: python -m pip install -e ".[dev]" + - name: Cache Docker images uses: actions/cache@v5 with: - path: /tmp/docker-cache - key: ${{ runner.os }}-docker-${{ hashFiles('docker-compose.yml') }} + path: /tmp/docker-cache/${{ matrix.moodle-cache-key }} + key: ${{ runner.os }}-docker-${{ matrix.moodle-cache-key }}-${{ hashFiles('docker-compose.yml') }} - name: Load cached Docker images run: | - if [ -f /tmp/docker-cache/postgres.tar ]; then docker load -i /tmp/docker-cache/postgres.tar; fi - if [ -f /tmp/docker-cache/moodle.tar ]; then docker load -i /tmp/docker-cache/moodle.tar; fi + if [ -f /tmp/docker-cache/${{ matrix.moodle-cache-key }}/postgres.tar ]; then + docker load -i /tmp/docker-cache/${{ matrix.moodle-cache-key }}/postgres.tar + fi + if [ -f /tmp/docker-cache/${{ matrix.moodle-cache-key }}/moodle.tar ]; then + docker load -i /tmp/docker-cache/${{ matrix.moodle-cache-key }}/moodle.tar + fi - - name: Start Moodle with Docker Compose (in backgroud) - run: make upd + - name: Start ${{ matrix.moodle-label }} with Docker Compose + run: MOODLE_DOCKER_IMAGE="${{ matrix.moodle-image }}" make upd - name: Run tests - run: make test + run: make test-local - name: Run example_script.py + if: matrix.run-examples run: python example_script.py - name: Save Docker images to cache if: always() run: | - mkdir -p /tmp/docker-cache - docker save postgres:alpine -o /tmp/docker-cache/postgres.tar - docker save erseco/alpine-moodle:v4.5.5 -o /tmp/docker-cache/moodle.tar - + mkdir -p /tmp/docker-cache/${{ matrix.moodle-cache-key }} + docker save postgres:16-alpine -o /tmp/docker-cache/${{ matrix.moodle-cache-key }}/postgres.tar + docker save "${{ matrix.moodle-image }}" -o /tmp/docker-cache/${{ matrix.moodle-cache-key }}/moodle.tar diff --git a/Makefile b/Makefile index 9935044..4191867 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ ENV_FILE=.env -.PHONY: ensure-env check-docker up format lint docs docs-generate test test-local test-staging +.PHONY: ensure-env check-docker up format lint docs docs-generate test test-unit test-local test-staging ensure-env: @if [ ! -f $(ENV_FILE) ]; then cp .env.example $(ENV_FILE); fi @@ -15,13 +15,19 @@ up: check-docker ensure-env upd: check-docker ensure-env docker compose up -d @echo "Waiting for Moodle to be ready..." - @for i in $$(seq 1 60); do \ - if curl -s --head http://localhost | grep "200 OK" > /dev/null; then \ + @ready=0; \ + for i in $$(seq 1 60); do \ + if curl -fsSL http://localhost/login/index.php > /dev/null; then \ echo "Moodle is up!"; \ + ready=1; \ break; \ fi; \ sleep 5; \ - done + done; \ + if [ $$ready -ne 1 ]; then \ + echo "Moodle did not become ready in time."; \ + exit 1; \ + fi format: black . @@ -36,11 +42,14 @@ docs: python -m typer py_moodle.cli.app utils docs --output docs/cli.md --name py-moodle mkdocs build --strict +test-unit: + pytest tests/unit + test-local: ensure-env - pytest --moodle-env local -n auto + pytest --integration --moodle-env local -m integration -n auto test-staging: ensure-env - pytest --moodle-env staging -n auto + pytest --integration --moodle-env staging -m integration -n auto test: upd test-local @@ -63,6 +72,7 @@ help: @echo " docs - Build documentation with mkdocs" @echo "" @echo "Testing:" + @echo " test-unit - Run fast smoke tests that do not require Moodle" @echo " test-local - Run local tests (in parallel) using pytest with moodle-env=local" @echo " test-staging - Run tests (in parallel) using moodle-env=staging" @echo " test - Start containers (detached) and run local tests" diff --git a/README.md b/README.md index d11cb7d..40b6482 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![MIT License](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/erseco/python-moodle/blob/main/LICENSE) [![Python Version](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) +[![CI](https://github.com/erseco/python-moodle/actions/workflows/ci.yml/badge.svg)](https://github.com/erseco/python-moodle/actions/workflows/ci.yml) [![PyPI downloads](https://img.shields.io/pypi/dm/python-moodle)](https://pypi.org/project/python-moodle/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![GitHub repository](https://img.shields.io/badge/github-repository-blue)](https://github.com/erseco/python-moodle) @@ -191,7 +192,13 @@ This script is the best starting point for understanding how to use the library' The project uses `pytest` and provides a `Makefile` with convenient targets. -Run the default test suite against the local environment: +Run the fast smoke test suite (no Moodle service required): + +```bash +make test-unit +``` + +Run the Docker-backed integration suite against the local environment: ```bash make test-local @@ -209,6 +216,14 @@ Run all configured environments: make test ``` +GitHub Actions automatically runs: + +- linting on Python 3.13 +- smoke tests on Python 3.8 through 3.13 +- Docker-backed integration tests on representative Python/Moodle combinations: + - Python 3.8 with Moodle 4.5.5 + - Python 3.13 with Moodle 5.0.1 + ## Development Use the Makefile to format code, run linters, or build the documentation: diff --git a/docker-compose.yml b/docker-compose.yml index 4b2988b..6e6ba01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: postgres: - image: postgres:alpine + image: postgres:16-alpine restart: unless-stopped environment: - POSTGRES_PASSWORD=moodle @@ -12,7 +12,7 @@ services: - postgres:/var/lib/postgresql/data moodle: - image: erseco/alpine-moodle:v4.5.5 + image: ${MOODLE_DOCKER_IMAGE:-erseco/alpine-moodle:v4.5.5} restart: unless-stopped environment: DEBUG: true diff --git a/pytest.ini b/pytest.ini index 76d39e3..7d46ac1 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,3 +4,5 @@ addopts = -ra -q testpaths = tests python_files = test_*.py pythonpath = src +markers = + integration: tests that require a live Moodle instance diff --git a/src/py_moodle/course.py b/src/py_moodle/course.py index 0ef30b2..ede54c7 100644 --- a/src/py_moodle/course.py +++ b/src/py_moodle/course.py @@ -1,4 +1,3 @@ -# src/moodle/course.py """ Course management module for Moodle. @@ -6,6 +5,8 @@ and enumerate course sections using AJAX endpoints. """ +from __future__ import annotations + import json import time import urllib.parse diff --git a/src/py_moodle/folder.py b/src/py_moodle/folder.py index 9b79d71..5e917dc 100644 --- a/src/py_moodle/folder.py +++ b/src/py_moodle/folder.py @@ -1,4 +1,3 @@ -# src/moodle/folder.py """ Folder module management for Moodle CLI. @@ -9,6 +8,8 @@ All code and comments are in English. """ +from __future__ import annotations + import json import re import time diff --git a/src/py_moodle/module.py b/src/py_moodle/module.py index 8665ebc..06f58c7 100644 --- a/src/py_moodle/module.py +++ b/src/py_moodle/module.py @@ -1,9 +1,10 @@ -# src/moodle/module.py """ Generic Moodle module management helpers. All code and comments are in English. """ +from __future__ import annotations + import json import re import time diff --git a/src/py_moodle/session.py b/src/py_moodle/session.py index 04ea624..683cc92 100644 --- a/src/py_moodle/session.py +++ b/src/py_moodle/session.py @@ -1,9 +1,10 @@ -# src/moodle/session.py """Reusable, thread-safe Moodle session. Lazy login on first access and cache sessions per environment. """ +from __future__ import annotations + import threading from typing import TYPE_CHECKING diff --git a/tests/conftest.py b/tests/conftest.py index a2ba92b..87a0ba9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,17 @@ import os import random from dataclasses import dataclass +from pathlib import Path import pytest import requests from dotenv import load_dotenv -from py_moodle.auth import LoginError, login -from py_moodle.course import ( - get_course_with_sections_and_modules, -) - # Load environment variables from .env file at the start load_dotenv() +UNIT_TESTS_DIR = (Path(__file__).parent / "unit").resolve() + @dataclass(frozen=True) class Target: @@ -33,6 +31,12 @@ def pytest_addoption(parser): default="local", help="Moodle environment to target: local | staging | prod", ) + parser.addoption( + "--integration", + action="store_true", + default=False, + help="Run tests that require a live Moodle instance.", + ) def _env(name: str) -> str: @@ -43,15 +47,10 @@ def _env(name: str) -> str: return val -def pytest_configure(config): - """ - Configures the test target and checks for both configuration validity and - host availability before tests run. - """ - env = config.getoption("--moodle-env") +def _build_target(env: str) -> Target: + """Builds the Moodle target configuration for the requested environment.""" prefix = f"MOODLE_{env.upper()}" - # --- 1. Validate that the environment is fully configured in .env --- required_suffixes = ("URL", "USERNAME", "PASSWORD") missing_vars = [] for suffix in required_suffixes: @@ -65,24 +64,22 @@ def pytest_configure(config): "Please ensure the following environment variables are set in your .env file:\n\n" f" {', '.join(missing_vars)}\n" ) - # Use pytest.exit() to stop the session cleanly from a hook pytest.exit(message) - # --- If configuration is valid, create the Target object --- - target = Target( + return Target( name=env, url=_env(f"{prefix}_URL"), username=_env(f"{prefix}_USERNAME"), password=_env(f"{prefix}_PASSWORD"), ) - # Store the target globally so all tests can access it via request.config - config.moodle_target = target - # --- 2. Check if the host is available before running tests --- + +def _ensure_target_available(target: Target): + """Checks that the configured Moodle host is reachable before tests run.""" try: requests.get(target.url, timeout=5).raise_for_status() except requests.RequestException: - if env == "local": + if target.name == "local": message = ( f"Host '{target.url}' for environment 'local' is not available.\n" "You may need to start the local Moodle instance. Try running:\n\n" @@ -90,16 +87,51 @@ def pytest_configure(config): ) else: message = ( - f"Host '{target.url}' for environment '{env}' is not available. " + f"Host '{target.url}' for environment '{target.name}' is not available. " "Please check the host and your network connection." ) - # Use pytest.exit() here as well for a clean stop pytest.exit(message) +def pytest_configure(config): + """ + Configures markers and, when requested, the Moodle target for integration + tests. + """ + config.addinivalue_line( + "markers", "integration: tests that require a live Moodle instance" + ) + + if not config.getoption("--integration"): + return + + env = config.getoption("--moodle-env") + target = _build_target(env) + config.moodle_target = target + _ensure_target_available(target) + + +def pytest_collection_modifyitems(config, items): + """Marks Moodle-backed tests as integration tests and skips them by default.""" + run_integration = config.getoption("--integration") + skip_integration = pytest.mark.skip( + reason="Use --integration to run tests that require a live Moodle instance." + ) + + for item in items: + item_path = Path(item.path).resolve() + if item_path == UNIT_TESTS_DIR or UNIT_TESTS_DIR in item_path.parents: + continue + item.add_marker(pytest.mark.integration) + if not run_integration: + item.add_marker(skip_integration) + + @pytest.fixture(scope="function") def moodle(request): """Provides an authenticated Moodle session for the target environment.""" + from py_moodle.auth import LoginError, login + target = request.config.moodle_target try: session = login( @@ -149,6 +181,8 @@ def temporary_course_for_labels(request): @pytest.fixture def first_section_id(moodle, request, temporary_course_for_labels) -> int: """Gets the ID of the first thematic section (position 1) of the temporary course.""" + from py_moodle.course import get_course_with_sections_and_modules + base_url = request.config.moodle_target.url course_id = temporary_course_for_labels["id"] token = getattr(moodle, "webservice_token", None) diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py new file mode 100644 index 0000000..653b983 --- /dev/null +++ b/tests/unit/test_smoke.py @@ -0,0 +1,22 @@ +"""Smoke tests that do not require a live Moodle instance.""" + +from typer.testing import CliRunner + +from py_moodle import __version__ +from py_moodle.cli.app import app + + +def test_package_version_is_exposed(): + """The package should expose a string version for tooling and docs.""" + assert isinstance(__version__, str) + assert __version__ + + +def test_cli_help_runs_without_environment(): + """The CLI help should render without contacting a Moodle instance.""" + runner = CliRunner() + + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + assert "A CLI to manage Moodle via AJAX sessions and web services." in result.output