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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ pip install plain-parser

## Usage

Validate a module and its `requires` chain from the command line (`<file>: OK` and exit `0` when valid; `Error: …` on stderr and exit `1` otherwise):

```bash
plain-parser check my_spec.plain [--template-dir DIR] [--config-name NAME]
```

Modules and `{% include %}` templates are looked up in the `.plain` file's directory, then in `--template-dir`.
When `--template-dir` is not given, it is read from the `template-dir` key of `config.yaml` (or the file named by
`--config-name`), found next to the `.plain` file or in the working directory. Finding it in both is a usage error
(exit `2`). The config file is read even when `--template-dir` is given, so a malformed one fails the run either way.
Other config keys are ignored.

Or from Python:

```python
from plain_parser import plain_file_parser

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies = [
"mistletoe>=1.6.0,<2",
"python-frontmatter>=1.3.0,<2",
"networkx>=3.6.1,<4",
"pyyaml>=6,<7",
]

[project.optional-dependencies]
Expand All @@ -32,6 +33,9 @@ dev = [
"mypy==2.3.0",
]

[project.scripts]
plain-parser = "plain_parser.cli:main"

[tool.hatch.version]
source = "vcs"

Expand Down
2 changes: 2 additions & 0 deletions src/plain_parser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
get_filename_from_module_name,
get_module_name_from_filename,
marshall_plain_source,
parse_module_chain,
parse_plain_file,
parse_plain_source,
plain_file_parser,
Expand All @@ -19,6 +20,7 @@
"get_module_name_from_filename",
"loaders",
"marshall_plain_source",
"parse_module_chain",
"parse_plain_file",
"parse_plain_source",
"plain_file",
Expand Down
117 changes: 117 additions & 0 deletions src/plain_parser/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Command-line entry point: ``plain-parser check <file.plain> [--template-dir DIR] [--config-name NAME]``."""

import argparse
import os
import sys

import yaml

from plain_parser import loaders, plain_file, plain_spec

DEFAULT_CONFIG_NAME = "config.yaml"
TEMPLATE_DIR_CONFIG_KEYS = ("template_dir", "template-dir")


class AmbiguousConfigFileError(Exception):
pass


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="plain-parser", description="Tools for ***plain specification files.")
subparsers = parser.add_subparsers(dest="command", required=True)

check_parser = subparsers.add_parser("check", help="Validate a .plain module and its requires chain.")
check_parser.add_argument("plain_file", help="Path to the .plain module.")
check_parser.add_argument(
"--template-dir",
help="Directory searched for modules and templates after the .plain file's own directory. "
"Overrides the template-dir value of the config file.",
)
check_parser.add_argument(
"--config-name",
default=DEFAULT_CONFIG_NAME,
help="Name of the config file to read template-dir from. Looked up in the .plain file's directory "
"and the current working directory. Defaults to %(default)s.",
)
return parser


def resolve_config_file(config_name: str, plain_file_path: str) -> str | None:
"""The config file next to the .plain file, else the one in the working directory, else None."""
plain_file_dir = os.path.dirname(os.path.abspath(plain_file_path))
plain_dir_config = os.path.normpath(os.path.join(plain_file_dir, config_name))
cwd_config = os.path.normpath(os.path.join(os.getcwd(), config_name))

in_plain_dir = os.path.exists(plain_dir_config)
in_cwd = os.path.exists(cwd_config)
if in_plain_dir and in_cwd and plain_dir_config != cwd_config:
raise AmbiguousConfigFileError(
f"Config file '{config_name}' was found in two locations:\n"
f" - Plain file directory: {plain_file_dir}\n"
f" - Current working directory: {os.getcwd()}\n"
f"Remove the config file from one of these locations to resolve the ambiguity."
)
if in_plain_dir:
return plain_dir_config
if in_cwd:
return cwd_config
return None


def template_dir_from_config(config_file: str) -> str | None:
"""The template-dir value of the config file, resolved against the file's directory. Other keys are ignored."""
with open(config_file) as f:
config = yaml.safe_load(f) or {}

for key in TEMPLATE_DIR_CONFIG_KEYS:
value = config.get(key)
if value:
value = os.path.expanduser(str(value))
return value if os.path.isabs(value) else os.path.join(os.path.dirname(config_file), value)
return None


def check(plain_file_path: str, template_dir: str | None) -> None:
"""Raise if the module, its requires chain, or its linked resources are invalid."""
template_dirs = [os.path.dirname(os.path.abspath(plain_file_path))]
if template_dir:
template_dirs.append(template_dir)

chain = plain_file.parse_module_chain(os.path.basename(plain_file_path), template_dirs)

for module_name, plain_source_tree in chain:
resources_list: list[dict] = []
plain_spec.collect_linked_resources(plain_source_tree, resources_list, None, True)
loaders.load_linked_resources(template_dirs, resources_list, module_name)

_, top_plain_source_tree = chain[-1]
for frid in plain_spec.get_frids(top_plain_source_tree):
plain_spec.get_specifications_for_frid(top_plain_source_tree, frid)


def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)

try:
# The config file is resolved even when --template-dir is given, so an ambiguous config is always an error.
config_template_dir = _template_dir_from_config(parser, args)
check(args.plain_file, args.template_dir or config_template_dir)
except KeyboardInterrupt:
return 130
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1

print(f"{args.plain_file}: OK")
return 0


def _template_dir_from_config(parser: argparse.ArgumentParser, args: argparse.Namespace) -> str | None:
try:
config_file = resolve_config_file(args.config_name, args.plain_file)
return template_dir_from_config(config_file) if config_file is not None else None
except AmbiguousConfigFileError as e:
parser.error(str(e))
except Exception as e:
parser.error(f"Error reading config file: {e}")
125 changes: 84 additions & 41 deletions src/plain_parser/plain_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,11 +701,16 @@ def parse_plain_file(

def process_required_modules(
required_modules: list[str],
code_variables: dict,
template_dirs: list[str],
all_required_modules: list[str],
modules_trace: list[str],
chain: list[tuple[str, dict]],
) -> list[mistletoe.block_token.token]:
"""Parse and fully validate every required module, deepest first.

Appends ``(module name, marshalled plain source tree)`` to ``chain`` for each module not already in it
and returns the exported definitions of the directly required modules.
"""
exported_definitions = list[mistletoe.block_token.token]()
for module_name in required_modules:
if module_name in modules_trace:
Expand All @@ -714,10 +719,12 @@ def process_required_modules(
if len(all_required_modules) > 0 and module_name == all_required_modules[-1]:
continue

code_variables: dict = {}
plain_file_parse_result = parse_plain_file(
module_name, code_variables, template_dirs, imported_modules=[], modules_trace=[]
)

ancestor_exported_definitions: list[mistletoe.block_token.token] = []
if len(plain_file_parse_result.required_modules) == 0:
if len(all_required_modules) > 0:
# For now we require that there is fixed order how required modules are dependent.
Expand All @@ -727,12 +734,12 @@ def process_required_modules(
f"Plain syntax error: There must be a fixed order how required modules are dependent ({module_name})."
)
else:
process_required_modules(
ancestor_exported_definitions = process_required_modules(
plain_file_parse_result.required_modules,
code_variables,
template_dirs,
all_required_modules,
modules_trace + [module_name],
chain,
)

if EXPORTED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata:
Expand All @@ -748,6 +755,12 @@ def process_required_modules(
)
)

marshalled_plain_source = validate_and_marshall_module(
plain_file_parse_result, module_name, ancestor_exported_definitions, code_variables
)
if module_name not in (chain_module_name for chain_module_name, _ in chain):
chain.append((module_name, marshalled_plain_source))

all_required_modules.append(module_name)

return exported_definitions
Expand Down Expand Up @@ -775,35 +788,13 @@ def process_exported_definitions(plain_source: dict, exported_definitions: list[
plain_source[plain_spec.DEFINITIONS].children.append(exported_definition)


def plain_file_parser( # noqa: C901
plain_source_file_name: str,
template_dirs: list[str],
) -> tuple[str, dict, list[str]]:
# code_variables are used to pass code variables to the plain source
# they need to be passed as an argument to the function because they populated when liquid templating is applied
# and we need to pass them to the marshalled_plain_source_tree after it's rendered
plain_source_file_path = Path(plain_source_file_name)
if plain_source_file_path.suffix != PLAIN_SOURCE_FILE_EXTENSION:
raise PlainSyntaxError(
f"Plain syntax error: Invalid plain file extension: {plain_source_file_path.suffix}. Expected: {PLAIN_SOURCE_FILE_EXTENSION}."
)

module_name = (
plain_source_file_path.stem
if plain_source_file_path.is_absolute()
else plain_source_file_path.with_suffix("").as_posix()
)

code_variables = {}

plain_file_parse_result = parse_plain_file(
module_name,
code_variables,
template_dirs,
imported_modules=[],
modules_trace=[],
)

def validate_and_marshall_module(
plain_file_parse_result: PlainFileParseResult,
module_name: str,
exported_definitions: list[mistletoe.block_token.token],
code_variables: dict,
) -> dict:
"""Every check a module must pass after parsing, and its marshalled plain source tree."""
if len(plain_file_parse_result.required_concepts) > 0:
missing_required_concepts_msg = "Missing required concepts: "
missing_required_concepts_msg += ", ".join(plain_file_parse_result.required_concepts)
Expand Down Expand Up @@ -831,14 +822,6 @@ def plain_file_parser( # noqa: C901
has_requires=bool(plain_file_parse_result.required_modules),
)

exported_definitions = process_required_modules(
plain_file_parse_result.required_modules,
code_variables={},
template_dirs=template_dirs,
all_required_modules=[],
modules_trace=[],
)

process_exported_definitions(plain_file_parse_result.plain_source, exported_definitions)

process_acceptance_tests(plain_file_parse_result.plain_source)
Expand All @@ -858,4 +841,64 @@ def plain_file_parser( # noqa: C901
if plain_spec.DEFINITIONS in marshalled_plain_source:
concept_utils.sort_definitions(marshalled_plain_source[plain_spec.DEFINITIONS])

return module_name, marshalled_plain_source, plain_file_parse_result.required_modules
return marshalled_plain_source


def _parse_module(
plain_source_file_name: str, template_dirs: list[str]
) -> tuple[str, dict, list[str], list[tuple[str, dict]]]:
plain_source_file_path = Path(plain_source_file_name)
if plain_source_file_path.suffix != PLAIN_SOURCE_FILE_EXTENSION:
raise PlainSyntaxError(
f"Plain syntax error: Invalid plain file extension: {plain_source_file_path.suffix}. Expected: {PLAIN_SOURCE_FILE_EXTENSION}."
)

module_name = (
plain_source_file_path.stem
if plain_source_file_path.is_absolute()
else plain_source_file_path.with_suffix("").as_posix()
)

# code_variables are populated when liquid templating is applied and consumed by process_code_variables
# once the plain source is marshalled.
code_variables: dict = {}

plain_file_parse_result = parse_plain_file(
module_name,
code_variables,
template_dirs,
imported_modules=[],
modules_trace=[],
)

chain: list[tuple[str, dict]] = []
exported_definitions = process_required_modules(
plain_file_parse_result.required_modules,
template_dirs=template_dirs,
all_required_modules=[],
modules_trace=[],
chain=chain,
)

marshalled_plain_source = validate_and_marshall_module(
plain_file_parse_result, module_name, exported_definitions, code_variables
)

return module_name, marshalled_plain_source, plain_file_parse_result.required_modules, chain


def plain_file_parser(plain_source_file_name: str, template_dirs: list[str]) -> tuple[str, dict, list[str]]:
"""Parse a module, validating it and every module in its ``requires`` chain."""
module_name, marshalled_plain_source, required_modules, _ = _parse_module(plain_source_file_name, template_dirs)
return module_name, marshalled_plain_source, required_modules


def parse_module_chain(plain_file_name: str, template_dirs: list[str]) -> list[tuple[str, dict]]:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for the two functions that call the same _parse_module call, with different return signatures:

  • parse_module_chain returns every module's tree and the CLI (top + required).
    • CLI needs all the trees to load resources and plain_file_parser only returns the top module's tree
  • plain_file_parser keeps the 3-tuple return so the contract with codeplain holds until the upcoming refactor

The load steps in cli.py line 84, 85, remain and they're just walks over a list now - no module is actually re-read or re-parsed.

"""Parse a module and every module in its ``requires`` chain.

Returns ``(module name, marshalled plain source tree)`` pairs: every required module
first, deepest ancestors first, then the module itself. A module reached through more
than one ``requires`` path appears once, at its first (deepest) position.
"""
module_name, marshalled_plain_source, _, chain = _parse_module(plain_file_name, template_dirs)
return chain + [(module_name, marshalled_plain_source)]
10 changes: 10 additions & 0 deletions tests/data/cli/acceptance_test_binary_resource.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
***implementation reqs***

- An implementation requirement.

***functional specs***

- A functionality.

***acceptance tests***
- Behaves as described in [the binary](binary.bin).
10 changes: 10 additions & 0 deletions tests/data/cli/acceptance_test_resource.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
***implementation reqs***

- An implementation requirement.

***functional specs***

- A functionality.

***acceptance tests***
- Behaves as described in [the notes](notes.md).
7 changes: 7 additions & 0 deletions tests/data/cli/base64_resource.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
***implementation reqs***

- Follow [the sample](../sample_base64_image.txt).

***functional specs***

- A functionality.
Binary file added tests/data/cli/binary.bin
Binary file not shown.
7 changes: 7 additions & 0 deletions tests/data/cli/binary_resource.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
***implementation reqs***

- Follow [the binary](binary.bin).

***functional specs***

- A functionality.
Loading
Loading