Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/py_moodle/cli/categories.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Category management commands for ``py-moodle``."""

from typing import Optional

import typer
from rich.table import Table

Expand All @@ -9,7 +11,7 @@
delete_category,
list_categories,
)
from py_moodle.cli.output import OutputFormat, emit, get_console
from py_moodle.cli.output import OutputFormat, emit, get_console, parse_fields
from py_moodle.session import MoodleSession

app = typer.Typer(help="Manage course categories: list, create, delete.")
Expand Down Expand Up @@ -41,6 +43,15 @@ def list_all_categories(
"--output",
help="Output format: table, json, yaml, or csv.",
),
fields: Optional[str] = typer.Option(
None,
"--fields",
help=(
"Comma-separated top-level fields to keep in machine-readable "
"output (json/yaml/csv), in the given order. Ignored for table "
"output; an unknown field is an error."
),
),
):
"""
Lists all available course categories.
Expand Down Expand Up @@ -68,7 +79,13 @@ def _render_table(data):
)
get_console(ctx).print(table)

emit(categories, output, table_fn=_render_table, csv_fields=_LIST_CSV_FIELDS)
emit(
categories,
output,
table_fn=_render_table,
csv_fields=_LIST_CSV_FIELDS,
fields=parse_fields(fields),
)
except MoodleCategoryError as e:
typer.echo(f"Error listing categories: {e}", err=True)
raise typer.Exit(1)
Expand Down
19 changes: 18 additions & 1 deletion src/py_moodle/cli/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from dataclasses import asdict
from functools import partial
from typing import Optional

import typer
from rich.table import Table
Expand All @@ -10,6 +11,7 @@
OutputFormat,
emit,
get_console,
parse_fields,
render_dry_run_plan,
)
from py_moodle.course import (
Expand Down Expand Up @@ -54,6 +56,15 @@ def list_all_courses(
"--output",
help="Output format: table, json, yaml, or csv.",
),
fields: Optional[str] = typer.Option(
None,
"--fields",
help=(
"Comma-separated top-level fields to keep in machine-readable "
"output (json/yaml/csv), in the given order. Ignored for table "
"output; an unknown field is an error."
),
),
):
"""
Lists all available courses.
Expand All @@ -75,7 +86,13 @@ def _render_table(data):
)
get_console(ctx).print(table)

emit(courses, output, table_fn=_render_table, csv_fields=_LIST_CSV_FIELDS)
emit(
courses,
output,
table_fn=_render_table,
csv_fields=_LIST_CSV_FIELDS,
fields=parse_fields(fields),
)


def _print_course_summary_table(ctx: typer.Context, course_data: dict):
Expand Down
115 changes: 115 additions & 0 deletions src/py_moodle/cli/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,94 @@
LOGGER_NAME = "py_moodle"


class UnknownFieldError(ValueError):
"""Raised when ``--fields`` names a field absent from every row.

Attributes:
unknown: The requested field names that are not present in any row.
available: The field names actually present across the rows, in
first-seen order, offered to help the user recover.
"""

def __init__(self, unknown: Sequence[str], available: Sequence[str]) -> None:
"""Build a clear message naming the offending and available fields.

Args:
unknown: The unrecognized field names.
available: The field names present across the rows.
"""
self.unknown = list(unknown)
self.available = list(available)
unknown_str = ", ".join(repr(field) for field in self.unknown)
available_str = ", ".join(self.available) or "(none)"
super().__init__(
f"Unknown field(s): {unknown_str}. Available fields: {available_str}"
)


def parse_fields(value: Optional[str]) -> Optional[list]:
"""Parse a comma-separated ``--fields`` value into a list of field names.

An empty or whitespace-only value (e.g. ``--fields ""``) is treated as
"no filtering" and returns ``None``, exactly as if the flag were omitted.

Args:
value: The raw ``--fields`` option value, or ``None`` when the flag
was not provided.

Returns:
A list of non-empty, stripped field names preserving the
user-provided order, or ``None`` when no filtering should be applied.
"""
if value is None:
return None
fields = [part.strip() for part in value.split(",")]
fields = [part for part in fields if part]
return fields or None


def select_fields(rows: Sequence[Any], fields: Sequence[str]) -> list:
"""Project each row to the requested fields, preserving field order.

Only flat, top-level keys are supported (no nested/dotted selectors).
Each returned row is a new dict whose keys are exactly ``fields`` in the
requested order; a field present in some rows but missing from a given
row is filled with ``None`` so that all rows share a consistent shape.

Args:
rows: A list of dict-like rows to project. An empty list is returned
unchanged (validation is skipped, since there are no keys to
validate against).
fields: The field names to keep, in the desired output order.

Returns:
A new list of dicts containing only ``fields``, in order.

Raises:
UnknownFieldError: If any requested field is not present in any row.
"""
if not rows:
return []

available: list = []
seen: set = set()
for row in rows:
if isinstance(row, dict):
for key in row.keys():
if key not in seen:
seen.add(key)
available.append(key)

unknown = [field for field in fields if field not in seen]
if unknown:
raise UnknownFieldError(unknown, available)

return [
{field: (row.get(field) if isinstance(row, dict) else None) for field in fields}
for row in rows
]


class OutputFormat(str, enum.Enum):
"""Supported CLI output formats.

Expand Down Expand Up @@ -103,6 +191,7 @@ def emit(
output_format: OutputFormat,
table_fn: Optional[Callable[[Any], None]] = None,
csv_fields: Optional[Sequence[CsvField]] = None,
fields: Optional[Sequence[str]] = None,
) -> None:
"""Emit ``data`` in the requested output format.

Expand All @@ -117,11 +206,34 @@ def emit(
``(header, accessor)`` pair, where ``accessor`` is either a
dict key or a callable extracting the value from a row. When
omitted, the union of keys present in ``data`` is used.
fields: Optional flat, top-level keys to keep in the machine-readable
output (JSON/YAML/CSV), in the given order. ``None`` (the default)
applies no filtering, preserving the existing behavior. For CSV,
the selected keys become the column headers (``csv_fields`` is
ignored) so that all formats share the same projected shape. For
``TABLE`` output, ``fields`` is accepted but ignored (best-effort).

Raises:
ValueError: If ``output_format`` is ``TABLE`` and no ``table_fn``
is provided.
typer.Exit: If ``fields`` names a field absent from every row (the
offending field is reported on stderr with a non-zero exit code).
"""
if fields is not None and output_format != OutputFormat.TABLE:
rows = data if isinstance(data, list) else [data]
try:
projected = select_fields(rows, fields)
except UnknownFieldError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(code=1)
if isinstance(data, list):
data = projected
else:
data = projected[0] if projected else {}
# The selected keys are the projected shape shared by all formats, so
# discard the pretty CSV column definitions in favor of those keys.
csv_fields = None

if output_format == OutputFormat.JSON:
typer.echo(json.dumps(data, indent=2, ensure_ascii=False))
elif output_format == OutputFormat.YAML:
Expand Down Expand Up @@ -225,7 +337,10 @@ def _render_table(data: dict) -> None:

__all__ = [
"OutputFormat",
"UnknownFieldError",
"emit",
"select_fields",
"parse_fields",
"get_console",
"configure_logging",
"render_dry_run_plan",
Expand Down
21 changes: 19 additions & 2 deletions src/py_moodle/cli/sections.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""Section management commands for ``py-moodle``."""

from typing import Optional

import typer
from rich.box import SQUARE
from rich.table import Table

# Import the new centralized function and corresponding error
from py_moodle.cli.output import OutputFormat, emit, get_console
from py_moodle.cli.output import OutputFormat, emit, get_console, parse_fields
from py_moodle.course import MoodleCourseError, get_course_with_sections_and_modules

# Keep the action functions (create/delete) that are still valid
Expand Down Expand Up @@ -46,6 +48,15 @@ def list_course_sections(
"--output",
help="Output format: table, json, yaml, or csv.",
),
fields: Optional[str] = typer.Option(
None,
"--fields",
help=(
"Comma-separated top-level fields to keep in machine-readable "
"output (json/yaml/csv), in the given order. Ignored for table "
"output; an unknown field is an error."
),
),
):
"""
Lists a summary of all sections in a specific course.
Expand Down Expand Up @@ -85,7 +96,13 @@ def _render_table(data):
)
get_console(ctx).print(table)

emit(sections, output, table_fn=_render_table, csv_fields=_LIST_CSV_FIELDS)
emit(
sections,
output,
table_fn=_render_table,
csv_fields=_LIST_CSV_FIELDS,
fields=parse_fields(fields),
)

except MoodleCourseError as e:
typer.echo(f"Error listing sections: {e}", err=True)
Expand Down
21 changes: 19 additions & 2 deletions src/py_moodle/cli/users.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""User management commands for ``py-moodle``."""

from typing import Optional

import typer
from rich.table import Table

from py_moodle.cli.output import OutputFormat, emit, get_console
from py_moodle.cli.output import OutputFormat, emit, get_console, parse_fields
from py_moodle.session import MoodleSession
from py_moodle.user import MoodleUserError, create_user, delete_user, list_course_users

Expand Down Expand Up @@ -35,6 +37,15 @@ def list_users_in_course(
"--output",
help="Output format: table, json, yaml, or csv.",
),
fields: Optional[str] = typer.Option(
None,
"--fields",
help=(
"Comma-separated top-level fields to keep in machine-readable "
"output (json/yaml/csv), in the given order. Ignored for table "
"output; an unknown field is an error."
),
),
):
"""Lists users enrolled in a specific course."""
ms = MoodleSession.get(ctx.obj["env"])
Expand All @@ -57,7 +68,13 @@ def _render_table(data):
)
get_console(ctx).print(table)

emit(users, output, table_fn=_render_table, csv_fields=_LIST_CSV_FIELDS)
emit(
users,
output,
table_fn=_render_table,
csv_fields=_LIST_CSV_FIELDS,
fields=parse_fields(fields),
)
except MoodleUserError as e:
typer.echo(f"Error listing users: {e}", err=True)
raise typer.Exit(1)
Expand Down
Loading