diff --git a/pyproject.toml b/pyproject.toml index 75df9eb..af5e297 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "python-dotenv", "typer[all]", "rich", + "pyyaml", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 27c5a9f..cf084a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ lxml python-dotenv typer[all] rich +pyyaml # dev dependencies pytest diff --git a/src/py_moodle/cli/categories.py b/src/py_moodle/cli/categories.py index 9c59b1f..3fbe29a 100644 --- a/src/py_moodle/cli/categories.py +++ b/src/py_moodle/cli/categories.py @@ -1,7 +1,5 @@ """Category management commands for ``py-moodle``.""" -import json - import typer from rich.console import Console from rich.table import Table @@ -12,6 +10,7 @@ delete_category, list_categories, ) +from py_moodle.cli.output import OutputFormat, emit from py_moodle.session import MoodleSession app = typer.Typer(help="Manage course categories: list, create, delete.") @@ -30,7 +29,9 @@ def main(ctx: typer.Context): @app.command("list") def list_all_categories( ctx: typer.Context, - json_flag: bool = typer.Option(False, "--json", help="Output in JSON format."), + output: OutputFormat = typer.Option( + OutputFormat.TABLE, "--output", help="Output format: table, json, or yaml." + ), ): """ Lists all available course categories. @@ -46,11 +47,10 @@ def list_all_categories( try: categories = list_categories(ms.session, ms.settings.url, ms.token) - if json_flag: - typer.echo(json.dumps(categories, indent=2, ensure_ascii=False)) - else: + + def _render_table(data): table = Table("ID", "Name", "Parent ID", "Course Count") - for category in categories: + for category in data: table.add_row( str(category.get("id", "")), category.get("name", ""), @@ -58,6 +58,8 @@ def list_all_categories( str(category.get("coursecount", "")), ) Console().print(table) + + emit(categories, output, table_fn=_render_table) except MoodleCategoryError as e: typer.echo(f"Error listing categories: {e}", err=True) raise typer.Exit(1) diff --git a/src/py_moodle/cli/courses.py b/src/py_moodle/cli/courses.py index 87aa6e4..555c4f0 100644 --- a/src/py_moodle/cli/courses.py +++ b/src/py_moodle/cli/courses.py @@ -1,11 +1,10 @@ """Course-related commands for ``py-moodle``.""" -import json - import typer from rich.console import Console from rich.table import Table +from py_moodle.cli.output import OutputFormat, emit from py_moodle.course import ( MoodleCourseError, create_course, @@ -32,8 +31,8 @@ def main(ctx: typer.Context): @app.command("list") def list_all_courses( ctx: typer.Context, - json_flag: bool = typer.Option( - False, "--json", help="Display output in JSON format." + output: OutputFormat = typer.Option( + OutputFormat.TABLE, "--output", help="Output format: table, json, or yaml." ), ): """ @@ -44,11 +43,9 @@ def list_all_courses( ms.session, ms.settings.url, token=ms.token, sesskey=ms.sesskey ) - if json_flag: - typer.echo(json.dumps(courses, indent=2, ensure_ascii=False)) - else: + def _render_table(data): table = Table("ID", "Shortname", "Fullname", "Category", "Visible") - for course in courses: + for course in data: table.add_row( str(course.get("id", "")), course.get("shortname", ""), @@ -58,6 +55,8 @@ def list_all_courses( ) Console().print(table) + emit(courses, output, table_fn=_render_table) + def _print_course_summary_table(course_data: dict): """Prints a rich summary table of the course contents.""" @@ -99,7 +98,9 @@ def _print_course_summary_table(course_data: dict): def show_course( ctx: typer.Context, course_id: int = typer.Argument(..., help="ID of the course to show."), - json_flag: bool = typer.Option(False, "--json", help="Output in JSON format."), + output: OutputFormat = typer.Option( + OutputFormat.TABLE, "--output", help="Output format: table, json, or yaml." + ), ): """ Shows a detailed summary of a specific course, including its sections and modules. @@ -111,10 +112,7 @@ def show_course( ms.session, ms.settings.url, ms.sesskey, course_id, token=ms.token ) - if json_flag: - typer.echo(json.dumps(course_data, indent=2, ensure_ascii=False)) - else: - _print_course_summary_table(course_data) + emit(course_data, output, table_fn=_print_course_summary_table) except MoodleCourseError as e: typer.echo(f"Error getting course details: {e}", err=True) diff --git a/src/py_moodle/cli/modules.py b/src/py_moodle/cli/modules.py index 7919bc7..5a3192c 100644 --- a/src/py_moodle/cli/modules.py +++ b/src/py_moodle/cli/modules.py @@ -1,11 +1,11 @@ """Module-related commands for ``py-moodle``.""" -import json from typing import Optional import typer from py_moodle.assign import MoodleAssignError, add_assign +from py_moodle.cli.output import OutputFormat, emit from py_moodle.label import MoodleLabelError, add_label, update_label # Import functions from the library directly @@ -64,7 +64,9 @@ def delete_a_module( def show_a_module( ctx: typer.Context, cmid: int = typer.Argument(..., help="ID of the module (cmid) to show."), - json_flag: bool = typer.Option(False, "--json", help="Output in JSON format."), + output: OutputFormat = typer.Option( + OutputFormat.TABLE, "--output", help="Output format: table, json, or yaml." + ), ): """ Shows detailed information for a specific module. @@ -73,11 +75,11 @@ def show_a_module( try: # This function is also generic and doesn't need changes. module_info = get_module_info(ms.session, ms.settings.url, ms.sesskey, cmid) - if json_flag: - typer.echo(json.dumps(module_info, indent=2, ensure_ascii=False)) - else: - table_str = format_module_table(module_info) - typer.echo(table_str) + + def _render_table(data): + typer.echo(format_module_table(data)) + + emit(module_info, output, table_fn=_render_table) except MoodleModuleError as e: typer.echo(f"Error getting module info: {e}", err=True) @@ -256,7 +258,9 @@ def list_available_module_types( "--course-id", help="Course ID to check available modules for. Defaults to 1.", ), - json_flag: bool = typer.Option(False, "--json", help="Output in JSON format."), + output: OutputFormat = typer.Option( + OutputFormat.TABLE, "--output", help="Output format: table, json, or yaml." + ), ): """ Lists all available module types (activities/resources) that can be added to a course. @@ -273,9 +277,7 @@ def list_available_module_types( ms.session, ms.settings.url, ms.sesskey, course_id ) - if json_flag: - typer.echo(json.dumps(module_types, indent=2, ensure_ascii=False)) - else: + def _render_table(data): table = Table( title=f"Available Module Types in Course ID {course_id}", show_header=True, @@ -285,7 +287,7 @@ def list_available_module_types( table.add_column("Name (modname)", width=20) table.add_column("Title (Translated)", justify="left") - for module in module_types: + for module in data: table.add_row( str(module.get("id")), f"[bold green]{module.get('name')}[/bold green]", @@ -294,6 +296,8 @@ def list_available_module_types( Console().print(table) + emit(module_types, output, table_fn=_render_table) + except MoodleModuleError as e: typer.echo(f"Error listing module types: {e}", err=True) raise typer.Exit(1) diff --git a/src/py_moodle/cli/output.py b/src/py_moodle/cli/output.py new file mode 100644 index 0000000..9fdf3ff --- /dev/null +++ b/src/py_moodle/cli/output.py @@ -0,0 +1,59 @@ +"""Shared output format utilities for the ``py-moodle`` CLI.""" + +from __future__ import annotations + +import enum +import json +from typing import Any, Callable, Optional + +import typer +import yaml + + +class OutputFormat(str, enum.Enum): + """Supported CLI output formats. + + Attributes: + TABLE: Human-readable rich table (default). + JSON: Machine-readable JSON. + YAML: Machine-readable YAML. + """ + + TABLE = "table" + JSON = "json" + YAML = "yaml" + + +def emit( + data: Any, + output_format: OutputFormat, + table_fn: Optional[Callable[[Any], None]] = None, +) -> None: + """Emit ``data`` in the requested output format. + + Args: + data: The data to emit. For JSON/YAML this must be serializable. + output_format: The desired output format. + table_fn: A callable that renders ``data`` as a rich table. + Required when ``output_format`` is ``OutputFormat.TABLE``. + + Raises: + ValueError: If ``output_format`` is ``TABLE`` and no ``table_fn`` + is provided. + """ + if output_format == OutputFormat.JSON: + typer.echo(json.dumps(data, indent=2, ensure_ascii=False)) + elif output_format == OutputFormat.YAML: + typer.echo( + yaml.dump(data, allow_unicode=True, default_flow_style=False), + nl=False, + ) + else: + if table_fn is None: + raise ValueError( + "table_fn is required when output_format is OutputFormat.TABLE" + ) + table_fn(data) + + +__all__ = ["OutputFormat", "emit"] diff --git a/src/py_moodle/cli/sections.py b/src/py_moodle/cli/sections.py index ccbac16..ab8f59a 100644 --- a/src/py_moodle/cli/sections.py +++ b/src/py_moodle/cli/sections.py @@ -1,13 +1,12 @@ """Section management commands for ``py-moodle``.""" -import json - import typer from rich.box import SQUARE from rich.console import Console from rich.table import Table # Import the new centralized function and corresponding error +from py_moodle.cli.output import OutputFormat, emit from py_moodle.course import MoodleCourseError, get_course_with_sections_and_modules # Keep the action functions (create/delete) that are still valid @@ -34,8 +33,8 @@ def list_course_sections( course_id: int = typer.Option( ..., "--course-id", help="ID of the course to list sections from." ), - json_flag: bool = typer.Option( - False, "--json", help="Display output in JSON format." + output: OutputFormat = typer.Option( + OutputFormat.TABLE, "--output", help="Output format: table, json, or yaml." ), ): """ @@ -49,9 +48,7 @@ def list_course_sections( ) sections = course_data.get("sections", []) - if json_flag: - typer.echo(json.dumps(sections, indent=2, ensure_ascii=False)) - else: + def _render_table(data): table = Table( title=f"Sections in Course: '{course_data.get('fullname')}'", show_header=True, @@ -63,8 +60,7 @@ def list_course_sections( table.add_column("Modules (Count)", justify="center") table.add_column("Visible", justify="center") - for section in sections: - # Visibility is found in the main section object + for section in data: visible_text = ( "[green]Yes[/green]" if section.get("visible", True) @@ -79,6 +75,8 @@ def list_course_sections( ) Console().print(table) + emit(sections, output, table_fn=_render_table) + except MoodleCourseError as e: typer.echo(f"Error listing sections: {e}", err=True) raise typer.Exit(1) @@ -91,7 +89,9 @@ def show_section_details( course_id: int = typer.Option( ..., "--course-id", help="ID of the course the section belongs to." ), - json_flag: bool = typer.Option(False, "--json", help="Output in JSON format."), + output: OutputFormat = typer.Option( + OutputFormat.TABLE, "--output", help="Output format: table, json, or yaml." + ), ): """ Shows detailed information of a specific section, including its modules. @@ -120,13 +120,9 @@ def show_section_details( ) raise typer.Exit(1) - if json_flag: - typer.echo(json.dumps(target_section, indent=2, ensure_ascii=False)) - else: + def _render_table(data): console = Console() - section_name = ( - target_section.get("name") or f"Section {target_section.get('section')}" - ) + section_name = data.get("name") or f"Section {data.get('section')}" console.print( f"\n[bold cyan]Details for Section: '{section_name}'[/bold cyan]" ) @@ -135,25 +131,24 @@ def show_section_details( details_table = Table(box=SQUARE, show_header=False) details_table.add_column("Field", style="dim") details_table.add_column("Value") - details_table.add_row("ID", str(target_section.get("id"))) - details_table.add_row("Position", str(target_section.get("section"))) + details_table.add_row("ID", str(data.get("id"))) + details_table.add_row("Position", str(data.get("section"))) details_table.add_row( "Visible", ( "[green]Yes[/green]" - if target_section.get("visible", True) + if data.get("visible", True) else "[red]No[/red]" ), ) - # The summary may contain HTML, so we show it as is. - summary = target_section.get("summary", "[dim]No summary[/dim]") + summary = data.get("summary", "[dim]No summary[/dim]") details_table.add_row( "Summary", summary if summary.strip() else "[dim]No summary[/dim]" ) console.print(details_table) # Module table within the section - modules = target_section.get("modules", []) + modules = data.get("modules", []) modules_table = Table( title="Modules in this Section", header_style="bold magenta" ) @@ -172,6 +167,8 @@ def show_section_details( ) console.print(modules_table) + emit(target_section, output, table_fn=_render_table) + except MoodleCourseError as e: typer.echo(f"Error showing section details: {e}", err=True) raise typer.Exit(1) diff --git a/src/py_moodle/cli/users.py b/src/py_moodle/cli/users.py index 78ee96c..aff42ef 100644 --- a/src/py_moodle/cli/users.py +++ b/src/py_moodle/cli/users.py @@ -1,11 +1,10 @@ """User management commands for ``py-moodle``.""" -import json - import typer from rich.console import Console from rich.table import Table +from py_moodle.cli.output import OutputFormat, emit from py_moodle.session import MoodleSession from py_moodle.user import MoodleUserError, create_user, delete_user, list_course_users @@ -25,8 +24,8 @@ def list_users_in_course( course_id: int = typer.Option( ..., "--course-id", help="ID of the course to list users from." ), - json_flag: bool = typer.Option( - False, "--json", help="Display output in JSON format." + output: OutputFormat = typer.Option( + OutputFormat.TABLE, "--output", help="Output format: table, json, or yaml." ), ): """Lists users enrolled in a specific course.""" @@ -39,17 +38,18 @@ def list_users_in_course( try: users = list_course_users(ms.session, ms.settings.url, ms.token, course_id) - if json_flag: - typer.echo(json.dumps(users, indent=2, ensure_ascii=False)) - else: + + def _render_table(data): table = Table("ID", "Full Name", "Email") - for user in users: + for user in data: table.add_row( str(user.get("id", "")), user.get("fullname", ""), user.get("email", ""), ) Console().print(table) + + emit(users, output, table_fn=_render_table) except MoodleUserError as e: typer.echo(f"Error listing users: {e}", err=True) raise typer.Exit(1) diff --git a/tests/unit/test_output_format.py b/tests/unit/test_output_format.py new file mode 100644 index 0000000..5078e60 --- /dev/null +++ b/tests/unit/test_output_format.py @@ -0,0 +1,238 @@ +"""Unit tests for the CLI output format utility.""" + +from __future__ import annotations + +import json +import re + +import pytest +import yaml +from typer.testing import CliRunner + +from py_moodle.cli.output import OutputFormat, emit + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape sequences from a string.""" + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +# --------------------------------------------------------------------------- +# emit() unit tests +# --------------------------------------------------------------------------- + + +def test_emit_json_serializes_list(capsys): + """JSON format should produce valid, indented JSON output.""" + data = [{"id": 1, "name": "Course A"}, {"id": 2, "name": "Course B"}] + + emit(data, OutputFormat.JSON) + + captured = capsys.readouterr() + parsed = json.loads(captured.out) + assert parsed == data + + +def test_emit_json_serializes_dict(capsys): + """JSON format should handle a plain dict.""" + data = {"id": 42, "fullname": "Test Course"} + + emit(data, OutputFormat.JSON) + + captured = capsys.readouterr() + parsed = json.loads(captured.out) + assert parsed == data + + +def test_emit_json_preserves_unicode(capsys): + """JSON format should not escape non-ASCII characters.""" + data = [{"name": "Ñoño"}] + + emit(data, OutputFormat.JSON) + + captured = capsys.readouterr() + assert "Ñoño" in captured.out + + +def test_emit_yaml_serializes_list(capsys): + """YAML format should produce valid YAML output for a list.""" + data = [{"id": 1, "name": "Course A"}, {"id": 2, "name": "Course B"}] + + emit(data, OutputFormat.YAML) + + captured = capsys.readouterr() + parsed = yaml.safe_load(captured.out) + assert parsed == data + + +def test_emit_yaml_serializes_dict(capsys): + """YAML format should handle a plain dict.""" + data = {"id": 42, "fullname": "Test Course"} + + emit(data, OutputFormat.YAML) + + captured = capsys.readouterr() + parsed = yaml.safe_load(captured.out) + assert parsed == data + + +def test_emit_yaml_preserves_unicode(capsys): + """YAML format should not escape non-ASCII characters.""" + data = [{"name": "Ñoño"}] + + emit(data, OutputFormat.YAML) + + captured = capsys.readouterr() + assert "Ñoño" in captured.out + + +def test_emit_table_calls_table_fn(): + """Table format should invoke the provided table_fn with the data.""" + data = [{"id": 1}] + calls = [] + + def fake_table_fn(d): + calls.append(d) + + emit(data, OutputFormat.TABLE, table_fn=fake_table_fn) + + assert calls == [data] + + +def test_emit_table_raises_without_table_fn(): + """Table format without a table_fn should raise ValueError.""" + with pytest.raises(ValueError, match="table_fn is required"): + emit([{"id": 1}], OutputFormat.TABLE) + + +# --------------------------------------------------------------------------- +# OutputFormat enum tests +# --------------------------------------------------------------------------- + + +def test_output_format_values(): + """The ``OutputFormat`` enum exposes the expected string values.""" + assert OutputFormat.TABLE == "table" + assert OutputFormat.JSON == "json" + assert OutputFormat.YAML == "yaml" + + +def test_output_format_is_str_enum(): + """``OutputFormat`` members are usable as plain strings.""" + fmt = OutputFormat.JSON + assert isinstance(fmt, str) + assert fmt == "json" + + +# --------------------------------------------------------------------------- +# CLI integration tests (no live Moodle required) +# --------------------------------------------------------------------------- + + +def test_courses_list_help_shows_output_option(): + """The courses list command should advertise --output in its help text.""" + from py_moodle.cli.app import app + + runner = CliRunner() + result = runner.invoke(app, ["courses", "list", "--help"]) + + assert result.exit_code == 0 + assert "--output" in _strip_ansi(result.output) + + +def test_categories_list_help_shows_output_option(): + """The categories list command should advertise --output in its help text.""" + from py_moodle.cli.app import app + + runner = CliRunner() + result = runner.invoke(app, ["categories", "list", "--help"]) + + assert result.exit_code == 0 + assert "--output" in _strip_ansi(result.output) + + +def test_sections_list_help_shows_output_option(): + """The sections list command should advertise --output in its help text.""" + from py_moodle.cli.app import app + + runner = CliRunner() + result = runner.invoke(app, ["sections", "list", "--help"]) + + assert result.exit_code == 0 + assert "--output" in _strip_ansi(result.output) + + +def test_users_list_help_shows_output_option(): + """The users list command should advertise --output in its help text.""" + from py_moodle.cli.app import app + + runner = CliRunner() + result = runner.invoke(app, ["users", "list", "--help"]) + + assert result.exit_code == 0 + assert "--output" in _strip_ansi(result.output) + + +def test_modules_show_help_shows_output_option(): + """The modules show command should advertise --output in its help text.""" + from py_moodle.cli.app import app + + runner = CliRunner() + result = runner.invoke(app, ["modules", "show", "--help"]) + + assert result.exit_code == 0 + assert "--output" in _strip_ansi(result.output) + + +def test_modules_list_types_help_shows_output_option(): + """The modules list-types command should advertise --output in its help text.""" + from py_moodle.cli.app import app + + runner = CliRunner() + result = runner.invoke(app, ["modules", "list-types", "--help"]) + + assert result.exit_code == 0 + assert "--output" in _strip_ansi(result.output) + + +def test_courses_show_help_shows_output_option(): + """The courses show command should advertise --output in its help text.""" + from py_moodle.cli.app import app + + runner = CliRunner() + result = runner.invoke(app, ["courses", "show", "--help"]) + + assert result.exit_code == 0 + assert "--output" in _strip_ansi(result.output) + + +def test_sections_show_help_shows_output_option(): + """The sections show command should advertise --output in its help text.""" + from py_moodle.cli.app import app + + runner = CliRunner() + result = runner.invoke(app, ["sections", "show", "--help"]) + + assert result.exit_code == 0 + assert "--output" in _strip_ansi(result.output) + + +def test_no_command_has_json_flag(): + """None of the updated commands should still expose a bare --json flag.""" + from py_moodle.cli.app import app + + runner = CliRunner() + for subcmd in [ + ["courses", "list", "--help"], + ["courses", "show", "--help"], + ["categories", "list", "--help"], + ["sections", "list", "--help"], + ["sections", "show", "--help"], + ["users", "list", "--help"], + ["modules", "show", "--help"], + ["modules", "list-types", "--help"], + ]: + result = runner.invoke(app, subcmd) + assert result.exit_code == 0, f"Non-zero exit for {subcmd}: {result.output}" + clean = _strip_ansi(result.output) + assert "--json" not in clean, f"--json flag still present in {' '.join(subcmd)}"