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
44 changes: 43 additions & 1 deletion docs/source/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@ ignore: Header level=1
layout: PartsSepSol
```

`question` and `layout` have to be there; `part`, `solution`, `strip` and `ignore` need not be.
`question` and `layout` have to be there; `part`, `solution`, `strip`, `ignore` and
`predicates` need not be.

- **`question`, `part`, `solution`** select the blocks that are each of those things.
- **`ignore`** selects the blocks that are none of them - a running header, a page of
instructions - and marks them as `in2lambda draft mark ignore` would, so they are not reported
as left out.
- **`strip`** is a list of patterns taken off the front of every value: the `(a) ` or `1. ` that
labels a part in the document, but not in the question.
- **`predicates`** names a Python file beside the spec, for the selectors that cannot say what
they mean in constraints alone. See below.
- **`layout`** is one of the [filters](filters/index), and says which solution answers which
question or part. See below.

Expand Down Expand Up @@ -77,6 +80,45 @@ the lines it came from. That is worth knowing in two places: a part written `(a)
is a `ListItem`, because pandoc reads `(a)` as a list marker, and `strip` still has to take the
`(a) ` off the front of the value, because the line it was copied from still has it.

## Predicates

Some documents cannot be told apart by their text. If the questions are the paragraphs written
in bold, and a paragraph about marking starts with the word `Question` as surely as they do,
then no `text~` constraint will do it. For those, a spec names a Python file beside it and calls
functions from it:

```yaml
predicates: predicates.py
question: Para bold_lead()
solution: Para italic_lead()
layout: PartsOneSol
```

A `name()` anywhere in a selector is a call, and goes with a type, with constraints and with
`after` - `after Header text=Solutions, is_solution()` - all of which have to hold as well. A
predicate is an ordinary function of one argument, the [panflute](https://scorreia.com/software/panflute/)
element the block is, that says whether the block is one of those:

```python
import panflute as pf


def bold_lead(element: pf.Element) -> bool:
"""Whether a block begins in bold."""
first = element.content[0] if element.content else None
while isinstance(first, pf.Span) and first.content: # Past the sourcepos spans.
first = first.content[0]
return isinstance(first, pf.Strong)
```

The frozen source is parsed with pandoc's `sourcepos`, so that each block knows which lines it
came from, and that leaves every inline wrapped in a `Span` carrying where it is. A predicate
looking at the markup has to see through them, as the one above does.

The file is named in the draft's log with its hash, exactly as the spec is, and it is run from
the bytes that hash was taken of. So a predicate edited after a run is refused the same way an
edited spec is, by `in2lambda draft replay` and by running the spec again.

## Layouts

The layout is the one thing that differs between problem sheets that are otherwise alike: where
Expand Down
130 changes: 107 additions & 23 deletions in2lambda/draft/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ class ReplayDiffers(SourceError):


class SpecChanged(SourceError):
"""The spec file a log names is not the one that ran: it has changed, or gone."""
"""A file a log names - a spec, or its predicates - is not the one that ran.

Either it has changed since, or it has gone.
"""


def command(name: str) -> Callable[[Handler], Handler]:
Expand Down Expand Up @@ -610,60 +613,141 @@ def _split_block(
return f"{block}a and {block}b"


def _spec_as_run(directory: str, name: str, digest: str) -> bytes:
"""A spec the log says has run, given it is still there and still says the same.
def _file_as_run(directory: str, name: str, digest: str) -> bytes:
"""A file the log says a spec run used, given it still says what it said then.

Args:
directory: Where the draft is, and so what the file is beside.
name: What the log calls the file: the spec, or the predicates it names.
digest: What the log says the file hashed to when it ran.

Returns:
The contents of the file, for whoever is about to run it.

Raises:
SpecChanged: there is no such file beside the draft, or it is not the one the
log records running. Either way the fields it wrote are fields nothing on
disk would write again, so neither a replay nor another run can check them.
log records running. Either way the fields the spec wrote are fields nothing
on disk would write again, so neither a replay nor another run can check
them.
"""
try:
raw = (Path(directory) / name).read_bytes()
except FileNotFoundError:
raise SpecChanged(
f"There is no {name} beside {DRAFT}, and the log says the draft was filled "
"in from one. Put it back, or start the draft again with in2lambda source "
"in with it. Put it back, or start the draft again with in2lambda source "
"add --start-over."
) from None
if _digest(raw) != digest:
raise SpecChanged(
f"{name} has changed since it was run against {DRAFT}, so the fields it "
"wrote are not the ones it would write now. Put it back, or start the "
f"{name} has changed since it was run against {DRAFT}, so the fields the "
"spec wrote are not the ones it would write now. Put it back, or start the "
"draft again with in2lambda source add --start-over."
)
return raw


def _files(args: dict[str, Any]) -> list[tuple[str, str]]:
"""What a `spec run` entry says it ran, as ``(name, hash)`` for each file.

The spec, and then the Python file of predicates it named, where it named one.

Raises:
MalformedCommand: the entry names a file without hashing it, or the other way
about.
"""
files = [
(
_argument(args, "spec", "spec run"),
_argument(args, "hash", "spec run"),
)
]
if "predicates" in args or "predicates_hash" in args:
files.append(
(
_argument(args, "predicates", "spec run"),
_argument(args, "predicates_hash", "spec run"),
)
)
return files


def spec_command(name: str, by: str, directory: str = ".") -> Command:
"""The `spec run` entry for a spec, with everything it depends on hashed into it.

The hashes go in the log beside the names, so that a replay can tell whether it is
running the files that wrote the fields it is checking.

Args:
name: The spec to run, as it is to be named in the log: beside the draft.
by: Who is running it, as a name or a model.
directory: Where the draft is, and so what the spec is beside.

Returns:
The command, for :func:`execute` to run.

Raises:
SourceError: pandoc, panflute or pyyaml is missing; the spec cannot be read; or
it names a file of predicates that is not beside it.
"""
_require_conversion_tools()
try:
raw = (Path(directory) / name).read_bytes()
except OSError:
raise in2lambda.spec.BadSpec(
f"There is no {name} to read a spec from. A spec is the file of selectors "
"the draft's fields are filled in from."
) from None
args: dict[str, Any] = {"spec": name, "hash": _digest(raw)}
spec = in2lambda.spec.load(raw)
if spec.predicates is not None:
# Beside the spec, which is what a spec naming a file next to it means and all
# that load lets one name, and recorded from the draft's directory, which is
# what the log names things from.
beside = (Path(name).parent / spec.predicates).as_posix()
try:
code = (Path(directory) / beside).read_bytes()
except OSError:
raise in2lambda.spec.BadSpec(
f"There is no {beside} to read the spec's predicates from. The "
"functions a spec calls are in a Python file beside it."
) from None
args |= {"predicates": beside, "predicates_hash": _digest(code)}
return {"command": "spec run", "args": args, "by": by}


@command("spec run")
def _spec_run(
draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str, directory: str
) -> str:
"""Fills in a draft's fields from a spec of selectors over the frozen source."""
_require_conversion_tools()
name = _argument(args, "spec", "spec run")
digest = _argument(args, "hash", "spec run")
# Every spec the log says has run, rather than one named the same way as this one: a
# spec that has been edited since leaves fields the log can no longer reproduce
# whatever it is spelled as now, so what has run is what to check. On a replay this
# re-reads specs whose own entries checked them, which is a file read each.
# Every file every spec the log says has run was run with, rather than one named the
# same way as this one: a spec that has been edited since leaves fields the log can
# no longer reproduce whatever it is spelled as now, so what has run is what to
# check. On a replay this re-reads files whose own entries checked them, which is a
# file read each.
for entry in map(_checked, draft["log"]):
if entry["command"] == "spec run":
_spec_as_run(
directory,
_argument(entry["args"], "spec", "spec run"),
_argument(entry["args"], "hash", "spec run"),
)
raw = _spec_as_run(directory, name, digest)

spec = in2lambda.spec.load(raw)
for file, digest in _files(entry["args"]):
_file_as_run(directory, file, digest)
raw = [_file_as_run(directory, file, digest) for file, digest in _files(args)]

spec = in2lambda.spec.load(raw[0])
functions = None
if spec.predicates is not None:
# Named by the entry rather than taken from the spec, so that what is run is the
# file the hash beside it in the log was checked against.
functions = in2lambda.spec.predicates(
spec, raw[-1], _argument(args, "predicates", "spec run")
)
# The blocks the selectors run over are the ones the parser makes of the source, and
# a `split block` since has left the draft holding halves the parser never made. So
# an ignored block is named and ranged from here rather than from the draft: the
# field then spans the whole of what was ignored, and `uncovered`, which goes by the
# lines a field was taken from, counts each half of a split block as covered by it.
elements = _elements(markdown)
fields, ignored = in2lambda.spec.fields(spec, elements, markdown)
fields, ignored = in2lambda.spec.fields(spec, elements, markdown, functions)
for found in fields:
record(draft, found.key, found.value, layer=1, ranges=found.ranges, by=by)
lines = {block.id: [block.start, block.end] for block, _ in elements}
Expand Down
12 changes: 1 addition & 11 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
Iterator,
)
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Optional

import rich_click as click
Expand All @@ -32,7 +31,6 @@
from in2lambda.source import ( # noqa: F401 # Re-exported, so not unused.
ConversionToolsMissing,
SourceError,
_digest,
_pandoc,
_require_conversion_tools,
file_type,
Expand Down Expand Up @@ -415,15 +413,7 @@ def spec_group() -> None:
def spec_run(spec: str, by: str) -> None:
"""Fills the draft's fields in from SPEC, and says which blocks it left out."""
with _message_not_traceback():
# The hash goes in the log beside the file's name, so that a replay can tell
# whether it is running the spec that wrote the fields it is checking.
report = in2lambda.draft.execute(
{
"command": "spec run",
"args": {"spec": spec, "hash": _digest(Path(spec).read_bytes())},
"by": by,
}
)
report = in2lambda.draft.execute(in2lambda.draft.spec_command(spec, by))
click.echo(report)


Expand Down
Loading
Loading