Functional Weave
Code in Rust

todo.import-csv@1.1.0

impl/python/validate_todo_csv.py

7,472 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Set, Tuple, Union

from .todo_import_csv_types import CsvRowError, CsvValidation
from .todo_item import Recurrence, Todo, validate_todo  ← from todo.item ^1.0.0 · built alongside by fune
from .todo_normalise_tags import normalise_tags  ← from todo.normalise-tags ^1.0.0 · built alongside by fune

_COLUMNS = (
    "id", "title", "notes", "done", "priority", "due", "tags",
    "recurrenceFrequency", "recurrenceInterval", "recurrenceAnchor", "createdAt", "completedAt", "order",
)
_HEADER_RULE = "the header must have the columns " + ", ".join(_COLUMNS)
_WHOLE = re.compile(r"-?[0-9]{1,15}")


@dataclass(frozen=True)
class TodoCsvProblem:
    """One problem, and the message import_csv raises when it is the first."""

    error: CsvRowError
    thrown: str


@dataclass(frozen=True)
class TodoCsvReading:
    """What reading a whole file found: the good rows' todos, and every problem."""

    todos: List[Todo]
    rows: int
    problems: List[TodoCsvProblem]


def _record_at(text: str, pos: int) -> Union[Tuple[List[str], int], str]:
    """One RFC 4180 record from pos: its fields and where the next starts, or why it cannot be read."""
    fields: List[str] = []
    size = len(text)
    while True:
        if pos < size and text[pos] == '"':
            pos += 1
            parts: List[str] = []
            while True:
                quote = text.find('"', pos)
                if quote < 0:
                    return "a quoted field is not closed"
                parts.append(text[pos:quote])
                if quote + 1 < size and text[quote + 1] == '"':
                    parts.append('"')
                    pos = quote + 2
                else:
                    pos = quote + 1
                    break
            if pos < size and text[pos] not in ",\r\n":
                return "text after the closing quote of a field"
            value = "".join(parts)
        else:
            end = pos
            while end < size and text[end] not in ",\r\n":
                if text[end] == '"':
                    return "a field with a double quote in it must be quoted"
                end += 1
            value = text[pos:end]
            pos = end
        fields.append(value)
        if pos >= size:
            return fields, pos
        if text[pos] == ",":
            pos += 1
        elif text[pos] == "\n":
            return fields, pos + 1
        elif pos + 1 < size and text[pos + 1] == "\n":
            return fields, pos + 2
        else:
            return "a CR outside quotes must be followed by LF"


def _columns_at(names: List[str]) -> Union[List[int], str]:
    at: Dict[str, int] = {}
    for i, name in enumerate(names):
        if name not in _COLUMNS:
            return f'{_HEADER_RULE}: unknown column "{name}"'
        if name in at:
            return f'{_HEADER_RULE}: column "{name}" appears twice'
        at[name] = i
    for name in _COLUMNS:
        if name not in at:
            return f'{_HEADER_RULE}: missing column "{name}"'
    return [at[name] for name in _COLUMNS]


def _whole_number(text: str) -> Optional[int]:
    return int(text) if _WHOLE.fullmatch(text) else None


def read_todo_csv(csv: str) -> TodoCsvReading:
    """Reads the whole file, collecting every problem; the first is the one import_csv 1.0.0 raised."""
    text = csv[1:] if csv.startswith("") else csv
    todos: List[Todo] = []
    problems: List[TodoCsvProblem] = []
    ids: Set[str] = set()
    at: Optional[List[int]] = None
    row = 0
    rows = 0
    pos = 0
    size = len(text)

    def add(field: Optional[str], message: str, thrown: str) -> None:
        problems.append(TodoCsvProblem(error=CsvRowError(row=row, field=field, message=message), thrown=thrown))

    def plain(field: str, message: str) -> None:
        add(field, message, f"row {row}: {message}")

    while pos < size:
        if text[pos] == "\n":
            pos += 1
            continue
        if text.startswith("\r\n", pos):
            pos += 2
            continue
        row += 1
        record = _record_at(text, pos)
        if isinstance(record, str):
            add(None, record, f"row {row}: {record}")
            break
        fields, pos = record
        if at is None:
            header = _columns_at(fields)
            if isinstance(header, str):
                add(None, header, header)
                break
            at = header
            continue
        rows += 1
        if len(fields) != len(_COLUMNS):
            message = f"expected {len(_COLUMNS)} fields, found {len(fields)}"
            add(None, message, f"row {row}: {message}")
            continue
        before = len(problems)
        (id_, title, notes, done, priority, due, tags, frequency, interval, anchor, created_at, completed_at,
         order) = [fields[i] for i in at]

        done_read = done in ("true", "false")
        if not done_read:
            plain("done", f'done must be true or false, found "{done}"')
        filled = sum(1 for t in (frequency, interval, anchor) if t != "")
        recurrence: Optional[Recurrence] = None
        recurrence_read = True
        if filled == 3:
            every = _whole_number(interval)
            if every is None:
                plain("recurrenceInterval", f'recurrenceInterval must be a whole number, found "{interval}"')
                recurrence_read = False
            else:
                recurrence = Recurrence(frequency=frequency, interval=every, anchor=anchor)
        elif filled != 0:
            plain("recurrence", "fill in all three recurrence columns or leave them all empty")
            recurrence_read = False
        position = _whole_number(order)
        if position is None:
            plain("order", f'order must be a whole number, found "{order}"')

        todo = Todo(
            id=id_,
            title=title,
            notes=None if notes == "" else notes,
            done=done == "true",
            priority=priority,
            due=None if due == "" else due,
            tags=normalise_tags(tags.split(" ")),
            recurrence=recurrence,
            created_at=created_at,
            completed_at=None if completed_at == "" else completed_at,
            order=0 if position is None else position,
        )
        # A column that could not be read has already been reported; the
        # stand-in value must not raise a second, misleading message.
        errors = validate_todo(todo).errors
        for field, message in errors.items():
            if field == "completedAt" and not done_read:
                continue
            if field == "recurrence" and not recurrence_read:
                continue
            if field == "order" and position is None:
                continue
            add(field, message, f"row {row}: {field}: {message}")
        if "id" not in errors:
            if id_ in ids:
                plain("id", f'duplicate id "{id_}"')
            ids.add(id_)
        if len(problems) == before:
            todos.append(todo)
    if at is None and not problems:
        row = 1
        add(None, "the CSV has no header row", "the CSV has no header row")
    return TodoCsvReading(todos=todos, rows=rows, problems=problems)


def validate_todo_csv(csv: str) -> CsvValidation:
    """Every problem in a todo CSV file, row by row: the row (header = 1), the field and the message."""
    reading = read_todo_csv(csv)
    return CsvValidation(valid=not reading.problems, rows=reading.rows, errors=[p.error for p in reading.problems])