Skip to content

Commit b4e02c5

Browse files
committed
Support metadata for pyinfra plugins.
* Add pyinfra-metadata-schema-1.0.0.json. * Add pyinfra-metadata.toml with current plugins. * Add Jinja2 templating to sphinx build. * Extended build-public-docs.sh to build local docs. * Added generated index for facts and operations.
1 parent 4c1c782 commit b4e02c5

File tree

8 files changed

+910
-81
lines changed

8 files changed

+910
-81
lines changed

docs/conf.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
import sys
13
from datetime import date
24
from os import environ, mkdir, path
35
from shutil import rmtree
@@ -6,6 +8,9 @@
68

79
from pyinfra import __version__, local
810

11+
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
12+
import metadata # noqa # this is a local module
13+
914
copyright = "Nick Barrett {0} — pyinfra v{1}".format(
1015
date.today().year,
1116
__version__,
@@ -57,9 +62,39 @@
5762
]
5863

5964

65+
def rstjinja(app, docname, source):
66+
"""
67+
Render our pages as a jinja template for fancy templating goodness.
68+
"""
69+
# this should only be run when building html
70+
if app.builder.format != "html":
71+
return
72+
src = source[0]
73+
rendered = app.builder.templates.render_string(src, app.config.html_context)
74+
source[0] = rendered
75+
76+
6077
def setup(app):
78+
app.connect("source-read", rstjinja)
6179
this_dir = path.dirname(path.realpath(__file__))
6280
scripts_dir = path.abspath(path.join(this_dir, "..", "scripts"))
81+
metadata_file = path.abspath(path.join(this_dir, "..", "pyinfra-metadata.toml"))
82+
if not path.exists(metadata_file):
83+
raise ValueError("No pyinfra-metadata.toml in project root")
84+
with open(metadata_file, "r") as file:
85+
metadata_text = file.read()
86+
plugins = metadata.parse_plugins(metadata_text)
87+
88+
operation_plugins = [p for p in plugins if p.type == "operation"]
89+
fact_plugins = [p for p in plugins if p.type == "fact"]
90+
html_context = {
91+
"operation_plugins": operation_plugins,
92+
"fact_plugins": fact_plugins,
93+
"tags": metadata.ALLOWED_TAGS,
94+
"docs_language": language,
95+
"docs_version": version,
96+
}
97+
app.config.html_context = html_context
6398

6499
for auto_docs_name in ("operations", "facts", "apidoc", "connectors"):
65100
auto_docs_path = path.join(this_dir, auto_docs_name)

docs/facts.rst

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,26 +35,53 @@ You can leverage facts within :doc:`operations <using-operations>` like this:
3535
3636
**Want a new fact?** Check out :doc:`the writing facts guide <./api/operations>`.
3737

38-
Facts, like :doc:`operations <operations>`, are namespaced as different modules - shortcuts to each of these can be found in the sidebar.
39-
4038
.. raw:: html
4139

42-
<style type="text/css">
43-
#facts-index .toctree-wrapper > ul {
44-
padding: 0;
45-
}
46-
#facts-index .toctree-wrapper > ul > li {
47-
padding: 0;
48-
list-style: none;
49-
margin: 20px 0;
50-
}
51-
#facts-index .toctree-wrapper > ul > li > ul > li {
52-
display: inline-block;
53-
}
54-
</style>
55-
56-
.. toctree::
57-
:maxdepth: 2
58-
:glob:
59-
60-
facts/*
40+
<div class="container my-4">
41+
<!-- Dropdown Filter -->
42+
<div class="mb-4">
43+
<label for="tag-dropdown" class="form-label">Filter by Tag:</label>
44+
<select class="form-select" id="tag-dropdown">
45+
<option value="All">All</option>
46+
{% for tag in tags %}
47+
<option value="{{ tag.title_case }}">{{ tag.title_case }}</option>
48+
{% endfor %}
49+
</select>
50+
</div>
51+
52+
<!-- Cards Grid -->
53+
<div class="row" id="card-container">
54+
{% for plugin in fact_plugins %}
55+
<div class="col-md-4 mb-4 card-item">
56+
<div class="card h-100">
57+
<div class="card-body">
58+
<h5 class="card-title">
59+
<a href="{{ docs_language }}/{{ docs_version }}/facts/{{ plugin.name }}.html">
60+
{{ plugin.name }}
61+
</a>
62+
<p class="card-text">{{ plugin.description }}</p>
63+
{% for tag in plugin.tags %}
64+
<span class="badge bg-secondary">{{ tag.title_case }}</span>
65+
{% endfor %}
66+
</div>
67+
</div>
68+
</div>
69+
{% endfor %}
70+
</div>
71+
</div>
72+
<script>
73+
document.getElementById('tag-dropdown').addEventListener('change', function () {
74+
const selectedTag = this.value;
75+
const cards = document.querySelectorAll('.card-item');
76+
77+
cards.forEach(card => {
78+
const tags = Array.from(card.querySelectorAll('.badge')).map(badge => badge.textContent.trim());
79+
80+
if (selectedTag === 'All' || tags.includes(selectedTag)) {
81+
card.style.display = 'block';
82+
} else {
83+
card.style.display = 'none';
84+
}
85+
});
86+
});
87+
</script>

docs/metadata.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
Support parsing pyinfra plugins and parsing
3+
their metadata to docs generator.
4+
"""
5+
6+
import tomllib
7+
from dataclasses import dataclass
8+
from typing import Literal
9+
10+
AllowedTagType = Literal[
11+
"boot",
12+
"containers",
13+
"database",
14+
"service-management",
15+
"package-manager",
16+
"python",
17+
"ruby",
18+
"javascript",
19+
"configuration-management",
20+
"security",
21+
"storage",
22+
"system",
23+
"system-ops",
24+
"system-facts",
25+
"rust",
26+
"version-control-system",
27+
]
28+
29+
30+
@dataclass(frozen=True)
31+
class Tag:
32+
"""Representation of a plugin tag."""
33+
34+
value: AllowedTagType
35+
36+
def __post_init__(self):
37+
allowed_tags = set(AllowedTagType.__args__)
38+
if self.value not in allowed_tags:
39+
raise ValueError(f"Invalid tag: {self.value}. Allowed: {allowed_tags}")
40+
41+
@property
42+
def title_case(self) -> str:
43+
return " ".join([t.title() for t in self.value.split("-")])
44+
45+
def __eq__(self, other):
46+
if isinstance(other, Tag):
47+
return self.value == other.value
48+
if isinstance(other, str):
49+
return self.value == other
50+
return NotImplemented
51+
52+
53+
ALLOWED_TAGS = [Tag(tag) for tag in set(AllowedTagType.__args__)]
54+
55+
56+
@dataclass
57+
class Plugin:
58+
"""Representation of a pyinfra plugin."""
59+
60+
name: str
61+
# description: str # FUTURE we should grab these from doc strings
62+
path: str
63+
type: Literal["operation", "fact", "connector", "deploy"]
64+
tags: list[Tag]
65+
66+
67+
def parse_plugins(metadata_text: str) -> list[Plugin]:
68+
"""Given the contents of a pyinfra-metadata.toml parse out the plugins."""
69+
pyinfra_metadata = tomllib.loads(metadata_text).get("pyinfra", None)
70+
if not pyinfra_metadata:
71+
raise ValueError("Missing [pyinfra.plugins] section in pyinfra-metadata.toml")
72+
73+
plugins = []
74+
for p in pyinfra_metadata["plugins"]:
75+
data = pyinfra_metadata["plugins"][p]
76+
# ensure Tag types and not strings
77+
data["tags"] = [Tag(t) for t in data["tags"]]
78+
plugin = Plugin(**data)
79+
plugins.append(plugin)
80+
return plugins

docs/operations.rst

Lines changed: 49 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,50 +7,52 @@ Operations are used to describe changes to make to systems in the inventory. Use
77

88
.. raw:: html
99

10-
<h3>Popular operations by category</h3>
11-
12-
.. admonition:: Basics
13-
:class: note inline
14-
15-
:doc:`operations/files`, :doc:`operations/server`, :doc:`operations/git`, :doc:`operations/systemd`
16-
17-
.. admonition:: System Packages
18-
:class: note inline
19-
20-
:doc:`operations/apt`, :doc:`operations/apk`, :doc:`operations/brew`, :doc:`operations/dnf`, :doc:`operations/yum`
21-
22-
.. admonition:: Language Packages
23-
:class: note inline
24-
25-
:doc:`operations/gem`, :doc:`operations/npm`, :doc:`operations/pip`
26-
27-
.. admonition:: Databases
28-
:class: note inline
29-
30-
:doc:`operations/postgresql`, :doc:`operations/mysql`
31-
32-
.. raw:: html
33-
34-
<h3>All operations alphabetically</h3>
35-
36-
.. raw:: html
37-
38-
<style type="text/css">
39-
#operations-index .toctree-wrapper > ul {
40-
padding: 0;
41-
}
42-
#operations-index .toctree-wrapper > ul > li {
43-
padding: 0;
44-
list-style: none;
45-
margin: 20px 0;
46-
}
47-
#operations-index .toctree-wrapper > ul > li > ul > li {
48-
display: inline-block;
49-
}
50-
</style>
51-
52-
.. toctree::
53-
:maxdepth: 2
54-
:glob:
55-
56-
operations/*
10+
<div class="container my-4">
11+
<!-- Dropdown Filter -->
12+
<div class="mb-4">
13+
<label for="tag-dropdown" class="form-label">Filter by Tag:</label>
14+
<select class="form-select" id="tag-dropdown">
15+
<option value="All">All</option>
16+
{% for tag in tags %}
17+
<option value="{{ tag.title_case }}">{{ tag.title_case }}</option>
18+
{% endfor %}
19+
</select>
20+
</div>
21+
22+
<!-- Cards Grid -->
23+
<div class="row" id="card-container">
24+
{% for plugin in operation_plugins %}
25+
<div class="col-md-4 mb-4 card-item">
26+
<div class="card h-100">
27+
<div class="card-body">
28+
<h5 class="card-title">
29+
<a href="{{ docs_language }}/{{ docs_version }}/operations/{{ plugin.name }}.html">
30+
{{ plugin.name }}
31+
</a>
32+
</h5>
33+
<p class="card-text">{{ plugin.description }}</p>
34+
{% for tag in plugin.tags %}
35+
<span class="badge bg-secondary">{{ tag.title_case }}</span>
36+
{% endfor %}
37+
</div>
38+
</div>
39+
</div>
40+
{% endfor %}
41+
</div>
42+
</div>
43+
<script>
44+
document.getElementById('tag-dropdown').addEventListener('change', function () {
45+
const selectedTag = this.value;
46+
const cards = document.querySelectorAll('.card-item');
47+
48+
cards.forEach(card => {
49+
const tags = Array.from(card.querySelectorAll('.badge')).map(badge => badge.textContent.trim());
50+
51+
if (selectedTag === 'All' || tags.includes(selectedTag)) {
52+
card.style.display = 'block';
53+
} else {
54+
card.style.display = 'none';
55+
}
56+
});
57+
});
58+
</script>

pyinfra-metadata-schema-1.0.0.json

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"title": "pyinfra-metadata Configuration",
4+
"type": "object",
5+
"properties": {
6+
"$schema": {
7+
"type": "string"
8+
},
9+
"pyinfra": {
10+
"type": "object",
11+
"properties": {
12+
"plugins": {
13+
"type": "object",
14+
"minProperties": 1,
15+
"patternProperties": {
16+
"^.*$": {
17+
"type": "object",
18+
"properties": {
19+
"name": {
20+
"type": "string"
21+
},
22+
"type": {
23+
"type": "string",
24+
"enum": [
25+
"operation",
26+
"fact",
27+
"connector"
28+
]
29+
},
30+
"path": {
31+
"type": "string"
32+
},
33+
"tags": {
34+
"type": "array",
35+
"items": {
36+
"type": "string"
37+
}
38+
}
39+
},
40+
"required": [
41+
"name",
42+
"type",
43+
"path",
44+
"tags"
45+
],
46+
"additionalProperties": false
47+
}
48+
}
49+
}},
50+
"required": [
51+
"plugins"
52+
],
53+
"additionalProperties": false
54+
}
55+
},
56+
"required": ["pyinfra"]
57+
}

0 commit comments

Comments
 (0)