Skip to content

Commit

Permalink
docs: fill components pages with content
Browse files Browse the repository at this point in the history
  • Loading branch information
the-dipsy committed Mar 13, 2024
1 parent fa46d28 commit b5ba032
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 0 deletions.
71 changes: 71 additions & 0 deletions docs/components-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,75 @@ nav_order: 30

# Python Section
{% raw %}
The *Python* code block allows you to write arbitrary python code, including
import statements, function and class definitions, etc. Any imports, variables,
functions, and classes that you define will also be available in the
*YAML*/*Jinja* section. You may therefore use the *Python* section to prepare
the environment for *Jinja*.

If the *Python* section isn't closed with a line containing exactly three
dashes, it must end in an expression that will determine the component's
result. This can be a variable name, function call, or any arbitrarily complex
python expression.

The following component takes two numbers and simply returns their sum.

```python
(a: float, b: float)
---
a + b
```

## Execution Context
The context *Python* is executed in comes with *Any* from the *typing* module,
*Path* for the *PosixPath* class of the *pathlib*, and the special *URL* class
pre-imported, just like in the signature.

It also contains the *butane* and *expand* functions for rendering
configurations. This is what the *merge* component of the standard library uses
to render sub-configurations into *Ignition* format and assemble the contents
for the `ignition.config.merge` field.

The *butane* function simply takes a configuration as a dict, transforms it
into *Ignition* and returns the result as a string.

The *expand* function recursively performs expansion of composite keys in dicts
and lists and leaves all other values as they are. Lists and dicts nested inside
other data structures will not be modified. You can optionally pass a second
and third parameter to control further modifications to the input. If the
second parameter is `True`, keys starting with an underscore will recursively
be filtered out of the result. If the third parameter is *True*, the *variant*
and *version* fields required by *Butane* will be added if the input is a dict
with these fields missing.

Lastly, the *GLOBAL* variable is a dict, shared by all components throughout
the compilation of the configuration. Using global state is discouraged. Pass
state around using component arguments and return values instead whenever
possible.

## Referencing Components and Local Files
You can reference directories and components by their name and execute them
as if they were python functions. You can traverse the component tree using
dot notation. To execute the component *foo/bar/baz.pyro*, simply write
`foo.bar.baz()`. If a directory contains a component named *main.pyro*, you may
call it by referencing the directory itself, as long as no component with the
same name exists: `foo.bar.main()` and `foo.bar()` are equivalent as long as
there is no component *foo/bar.pyro*.

The components from the [standard library][stdlib] are in scope by default.

Use the *_* (underscore) from the global context to reference the directory the
current component lies in, or use it as a field name on a component/directory
to reference its ancestor: The *foo/bar/baz.pyro* component can execute
*foo/bar/qux.pyro* by calling `_.qux()` and *foo/quux.pyro* by calling
`_._.quux()`. Referencing `_._` at the root of the component tree will raise an
error. It is recommended to use relative references whenever possible to
simplify refactoring and to make component directories truly self-contained.

Components/directories may also be used for referencing other files by using
the slash syntax: Read a file *data.txt* from the component's directory by
writing `load(_/"file.txt")`, or from its parent by writing
`load(_._/"file.txt")`.

[stdlib]: components-stdlib.html
{% endraw %}
65 changes: 65 additions & 0 deletions docs/components-signature.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,69 @@ nav_order: 20

# Signature Section
{% raw %}
*Pyromaniac* component signatures specify the arguments accepted by your
components with their types and default values. They use the same syntax as
Python function signatures including variadic arguments, union types, etc. If
no signature is specified, it defaults to `(*args, **kwargs)` to accept any
number of positional and keyword arguments.

*Pyromaniac* will perform type checking and coercion at runtime. Calling a
component with arguments that don't match its signature will result in an
error.

In your signature specification, you have access to *Any* from the *typing*
module, *Path* for the *PosixPath* class from the *pathlib* module, and a
special *URL* class in addition to all of Python's built-in identifiers. The
*URL* class wraps a string in its *url* field and supports the slash operator
for concatenation with strings as in `URL("https://example.com") / "path.html"`
similar to the *Path* class. You may also use union types and a limited
selection of generics as described below.

The following is an example of a valid signature with some documentation.

```python
(
title: str, # the document title
content: Path | URL, # a path or URL the content should be loaded from
sources: list[str | dict[str, str]] = [], # an optional list of sources
**meta: str, # any number of meta tags
)
```

## Supported Types
You may use any built-in types and classes in your signatures, and *Pyromaniac*
will check if the passed arguments are instances of the specified types. If you
want to allow any type, use *Any* or omit the type annotation altogether.

To allow multiple types, you may use the pipe syntax as in `str | int`.
*Pyromaniac* will check the types from left to right and stop at the first
match. This becomes relevant when type coercion comes into play: A string can,
e.g., be coerced into a *Path* or *URL* but not the other way around. If you
annotate an argument with `str | Path`, it will work as expected. If you switch
the two around, you'll always end up with a Path.

The supported generics are `list[T]` to make sure all elements of the list are
of type `T`, `dict[TK, TV]` to make sure all dict keys are of type `TK` and
all values of type `TV`, and `tuple[TA, TB, TC, ...]` to make sure the tuple
has the specified amount and types of elements in the specified order. Other
generics are not supported and will raise errors.

*NoneType* and *EllipsisType* are not available in signatures, but you may use
`None` and `...` in your type annotations directly. You may also specify them
as default values, and *Pyromaniac* will add them to the supported types, so
`(name: str = None)` will turn into `(name: str | None = None)`. This is not
the case for other types.

## Type Coercion
In general, arguments must be instances of the type specified in the signature.
Some types support coercion, though.

Integers and floating-point numbers may be used interchangeably as long as they
are compatible: You may pass `3.0` as an integer argument but not `3.5`.

Strings will be coerced into *Path*s and *URL*s but not the other way around.

Tuples and lists may be passed interchangeably.

Other coercions will not take place.
{% endraw %}
17 changes: 17 additions & 0 deletions docs/components-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,21 @@ nav_order: 40

# YAML Section
{% raw %}
Unless the component has an unclosed *Python* section, it ends with a *YAML*
section that determines the component's result. It may evaluate to any valid
*YAML* value, including *None*, by leaving it empty.

*Jinja* directives may be used to dynamically generate the *YAML* content.
*Jinja* will have access to *Any*, *Path*, *URL*, all imports, variables,
functions, and classes from the *Python* section, as well as your component
tree and the *_* (underscore).

## Serialization of Jinja Expressions
Data from *Jinja* expressions will generally be *JSON* serialized before being
inserted into the *YAML* document, making use of the fact that *YAML* is a
superset of *JSON*. You can therefore safely inject complex data structures
produced by the *Python* section or by other components into your *YAML* code.

Use the `raw` filter to insert raw strings into your document as in `name: {{
"Alice" | raw }}`.
{% endraw %}
105 changes: 105 additions & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,109 @@ has_toc: false

# Components
{% raw %}
*Pyromaniac* configurations are written as one or more potentially nested
components in a custom format that combines *YAML*, *Jinja*, and *Python*.
Except for the special meaning of *Jinja* control structures, any valid
*Butane* configuration is also an equivalent valid *Pyromaniac* component.
*Pyromaniac* can therefore be considered to be a superset of *Butane* in
practice.

A collection of built-in components are available to you by default. You can
read their documentation on the [Standard Library][stdlib] page or take a look
at [their source code][source] as inspiration for your own components.

Since *Pyromaniac* is implemented in and relies on *Python* as part of its
configuration format, *Python* terminology will be used to describe data
structures in this document: Indexed arrays will be called lists, associative
arrays will be called dicts, and the *null* value will be called `None`.

[stdlib]: components-stdlib.html
[source]: https://github.com/salatfreak/pyromaniac/tree/main/stdlib

## Component Sections
Components consist of up to 4 sections, each of them optional. *Python*-style
comments and newlines before and between the sections are ignored.

A component may be completely empty. According to the following rules, an empty
component will be interpreted as only having an (empty) *YAML* section and will
therefore evaluate to `None`. Such a component will not be suitable as a main
component which must evaluate to a dict.

First of all, components may start with a docstring describing their
functioning, inputs, and output. It only serves the purpose of documentation
and doesn't affect how the component's output is constructed. The docstring
must be a valid static Python string. It must not be a byte string or an
f-string. It is advisable to follow the *Python* convention of employing triple
quotes as your string delimiters.

Secondly, a signature may follow describing the input parameters with their
types and defaults. It must start with an opening parenthesis and end with a
closing one. Details about the syntax, semantics, and type coercion are
described on the [Signature Section][signature] page.

The next possible section is the *Python* code block. It starts with a line
containing exactly three dashes and may close with another such line. If the
*Python* code block is not closed, it must end in an expression whose result
the entire component will evaluate to. Details about the syntax and execution
context can be found on the [Python Section][python] page.

Except for when the component contains an unclosed *Python* code block, the
rest of the component constitutes the *YAML* section. The *YAML* section may
contain *Jinja* control structures that will be evaluated to produce the
component's final result. Details about the syntax and execution context can be
found on the [YAML Section][yaml] page.

A minimal yet pointless component containing all 4 sections looks as follows.
Like the empty component, it evaluates to `None`.

```python
""
()
---
---
```

[signature]: components-signature.html
[python]: components-python.html
[yaml]: components-yaml.html

## The Main Component
*Pyromaniac*'s entrypoint will be your main component. You can pass it via
standard input or read it from a file. For multi-component configurations, it
is recommended to employ a component named *main.pyro* and specify a single
dot as the first positional command line parameter to *Pyromaniac*.

The result of the main component will be used to compile the final *Ignition*
file. All *Pyromaniac* cares about is that your main component evaluates to a
dict. Whether you define it in place, construct it by combining a bunch of
other components, load it from a *TOML* file, etc., is up to you. *Pyromaniac*
will finalize the result and feed it to *Butane* to produce the *Ignition*
output.

## Finalization
Before serializing your main component's result to *YAML* as input for
*Butane*, *Pyromaniac* will process it by executing the following steps.

*Pyromaniac* will expand all composite keys, raising an error if any
conflicting keys are encountered. `{"a.b": [{"c.d": 42}], "a.b[1]": 69}` will
be converted to `{'a': {'b': [{'c': {'d': 42}}, 69]}}`.

All keys starting with an underscore will recursively be stripped from the
dict. You may use them to carry metadata between your components.

Finally, the *variant* and *version* fields will be set to *fcos* and the
latest *Butane* version respectively, if they are not specified in your
configuration.

## Component Trees
You can organize your *Pyromaniac* components and files in arbitrarily nested
directories. Ideally, every directory should be self-contained i.e., its
components shouldn't reference any data from ancestor directories and instead
be parameterized through arguments defined in its signature. Directory and
component names must be valid *Python* identifiers.

*Pyromaniac* makes it easy to reference components and files relative to the
currently evaluated component using the global *_* (underscore) variable
available in both *Python* code blocks and the *YAML*/*Jinja* section. You'll
find more information about it on the [Python Section][python] page.
{% endraw %}

0 comments on commit b5ba032

Please sign in to comment.