Skip to content

Settings

sereto.models.settings

BaseRecipe

Bases: SeretoBaseModel

Base recipe for rendering and converting files using RenderTools.

Attributes:

Name Type Description
name str

name of the recipe

tools Annotated[list[str], MinLen(1)]

list of RenderTool names to run

Source code in sereto/models/settings.py
82
83
84
85
86
87
88
89
90
91
class BaseRecipe(SeretoBaseModel):
    """Base recipe for rendering and converting files using `RenderTool`s.

    Attributes:
        name: name of the recipe
        tools: list of `RenderTool` names to run
    """

    name: str
    tools: Annotated[list[str], MinLen(1)]

ConvertRecipe

Bases: BaseRecipe

Recipe for converting between file formats using RenderTools.

Attributes:

Name Type Description
name str

name of the recipe

tools Annotated[list[str], MinLen(1)]

list of RenderTool names to run

input_format FileFormat

input file format

output_format FileFormat

output file format

Source code in sereto/models/settings.py
106
107
108
109
110
111
112
113
114
115
116
117
class ConvertRecipe(BaseRecipe):
    """Recipe for converting between file formats using `RenderTool`s.

    Attributes:
        name: name of the recipe
        tools: list of `RenderTool` names to run
        input_format: input file format
        output_format: output file format
    """

    input_format: FileFormat = Field(strict=False)
    output_format: FileFormat = Field(strict=False)

Plugins

Bases: SeretoBaseModel

Plugins settings.

Attributes:

Name Type Description
enabled bool

whether plugins are enabled

directory str

path to the directory containing plugins (%TEMPLATES% will be replaced with the templates path`)

Source code in sereto/models/settings.py
308
309
310
311
312
313
314
315
316
317
class Plugins(SeretoBaseModel):
    """Plugins settings.

    Attributes:
        enabled: whether plugins are enabled
        directory: path to the directory containing plugins (`%TEMPLATES%` will be replaced with the templates path`)
    """

    enabled: bool = False
    directory: str = "%TEMPLATES%/plugins"

Render

Bases: SeretoBaseModel

Rendering settings.

Attributes:

Name Type Description
report_recipes Annotated[list[RenderRecipe], MinLen(1)]

list of RenderRecipes for rendering reports

finding_group_recipes Annotated[list[RenderRecipe], MinLen(1)]

list of RenderRecipes for rendering finding groups

sow_recipes Annotated[list[RenderRecipe], MinLen(1)]

list of RenderRecipes for rendering SoWs

target_recipes Annotated[list[RenderRecipe], MinLen(1)]

list of RenderRecipes for rendering targets

convert_recipes Annotated[list[ConvertRecipe], MinLen(1)]

list of ConvertRecipes for converting between file formats

tools Annotated[list[RenderTool], MinLen(1)]

list of RenderTools used in recipes

Source code in sereto/models/settings.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
class Render(SeretoBaseModel):
    """Rendering settings.

    Attributes:
        report_recipes: list of `RenderRecipe`s for rendering reports
        finding_group_recipes: list of `RenderRecipe`s for rendering finding groups
        sow_recipes: list of `RenderRecipe`s for rendering SoWs
        target_recipes: list of `RenderRecipe`s for rendering targets
        convert_recipes: list of `ConvertRecipe`s for converting between file formats
        tools: list of `RenderTool`s used in recipes
    """

    report_recipes: Annotated[list[RenderRecipe], MinLen(1)]
    finding_group_recipes: Annotated[list[RenderRecipe], MinLen(1)]
    sow_recipes: Annotated[list[RenderRecipe], MinLen(1)]
    target_recipes: Annotated[list[RenderRecipe], MinLen(1)]
    convert_recipes: Annotated[list[ConvertRecipe], MinLen(1)]
    tools: Annotated[list[RenderTool], MinLen(1)]

    @model_validator(mode="after")
    def render_validator(self) -> Self:
        for recipe in self.report_recipes + self.finding_group_recipes + self.sow_recipes:
            if not all(tool in [t.name for t in self.tools] for tool in recipe.tools):
                raise ValueError(f"unknown tools in recipe {recipe.name!r}")
        tool_names = [t.name for t in self.tools]
        if len(tool_names) != len(set(tool_names)):
            raise ValueError("tools with duplicate name detected")
        return self

    @validate_call
    def get_report_recipe(self, name: str | None) -> RenderRecipe:
        """Get a report recipe by name.

        Args:
            name: The name of the recipe to get. If None, the first recipe is returned.
        """
        if name is None:
            return self.report_recipes[0]

        if len(res := [r for r in self.report_recipes if r.name == name]) != 1:
            raise SeretoValueError(f"no report recipe found with name {name!r}")

        return res[0]

    @validate_call
    def get_finding_group_recipe(self, name: str | None) -> RenderRecipe:
        """Get a finding group recipe by name.

        Args:
            name: The name of the recipe to get. If None, the first recipe is returned.
        """
        if name is None:
            return self.finding_group_recipes[0]

        if len(res := [r for r in self.finding_group_recipes if r.name == name]) != 1:
            raise SeretoValueError(f"no finding recipe found with name {name!r}")

        return res[0]

    @validate_call
    def get_sow_recipe(self, name: str | None) -> RenderRecipe:
        """Get a SoW recipe by name.

        Args:
            name: The name of the recipe to get. If None, the first recipe is returned.
        """
        if name is None:
            return self.sow_recipes[0]

        if len(res := [r for r in self.sow_recipes if r.name == name]) != 1:
            raise SeretoValueError(f"no SoW recipe found with name {name!r}")

        return res[0]

    @validate_call
    def get_target_recipe(self, name: str | None) -> RenderRecipe:
        """Get a target recipe by name.

        Args:
            name: The name of the recipe to get. If None, the first recipe is returned.
        """
        if name is None:
            return self.target_recipes[0]

        if len(res := [r for r in self.target_recipes if r.name == name]) != 1:
            raise SeretoValueError(f"no target recipe found with name {name!r}")

        return res[0]

    @validate_call
    def get_convert_recipe(
        self, name: str | None, input_format: FileFormat, output_format: FileFormat
    ) -> ConvertRecipe:
        """Get a convert recipe by name, input format, and output format.

        Args:
            name: The name of the recipe to get. If None, the first matching recipe is returned.
            input_format: The input file format.
            output_format: The output file format.
        """
        acceptable_recipes = [
            r for r in self.convert_recipes if r.input_format == input_format and r.output_format == output_format
        ]
        if len(acceptable_recipes) == 0:
            raise SeretoValueError(f"no convert recipe found for {input_format.value} -> {output_format.value}")

        if name is None:
            return acceptable_recipes[0]

        if len(res := [r for r in acceptable_recipes if r.name == name]) != 1:
            raise SeretoValueError(
                f"no convert recipe found for {input_format.value} -> {output_format.value} with name {name!r}"
            )

        return res[0]

get_convert_recipe(name, input_format, output_format)

Get a convert recipe by name, input format, and output format.

Parameters:

Name Type Description Default
name str | None

The name of the recipe to get. If None, the first matching recipe is returned.

required
input_format FileFormat

The input file format.

required
output_format FileFormat

The output file format.

required
Source code in sereto/models/settings.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
@validate_call
def get_convert_recipe(
    self, name: str | None, input_format: FileFormat, output_format: FileFormat
) -> ConvertRecipe:
    """Get a convert recipe by name, input format, and output format.

    Args:
        name: The name of the recipe to get. If None, the first matching recipe is returned.
        input_format: The input file format.
        output_format: The output file format.
    """
    acceptable_recipes = [
        r for r in self.convert_recipes if r.input_format == input_format and r.output_format == output_format
    ]
    if len(acceptable_recipes) == 0:
        raise SeretoValueError(f"no convert recipe found for {input_format.value} -> {output_format.value}")

    if name is None:
        return acceptable_recipes[0]

    if len(res := [r for r in acceptable_recipes if r.name == name]) != 1:
        raise SeretoValueError(
            f"no convert recipe found for {input_format.value} -> {output_format.value} with name {name!r}"
        )

    return res[0]

get_finding_group_recipe(name)

Get a finding group recipe by name.

Parameters:

Name Type Description Default
name str | None

The name of the recipe to get. If None, the first recipe is returned.

required
Source code in sereto/models/settings.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
@validate_call
def get_finding_group_recipe(self, name: str | None) -> RenderRecipe:
    """Get a finding group recipe by name.

    Args:
        name: The name of the recipe to get. If None, the first recipe is returned.
    """
    if name is None:
        return self.finding_group_recipes[0]

    if len(res := [r for r in self.finding_group_recipes if r.name == name]) != 1:
        raise SeretoValueError(f"no finding recipe found with name {name!r}")

    return res[0]

get_report_recipe(name)

Get a report recipe by name.

Parameters:

Name Type Description Default
name str | None

The name of the recipe to get. If None, the first recipe is returned.

required
Source code in sereto/models/settings.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
@validate_call
def get_report_recipe(self, name: str | None) -> RenderRecipe:
    """Get a report recipe by name.

    Args:
        name: The name of the recipe to get. If None, the first recipe is returned.
    """
    if name is None:
        return self.report_recipes[0]

    if len(res := [r for r in self.report_recipes if r.name == name]) != 1:
        raise SeretoValueError(f"no report recipe found with name {name!r}")

    return res[0]

get_sow_recipe(name)

Get a SoW recipe by name.

Parameters:

Name Type Description Default
name str | None

The name of the recipe to get. If None, the first recipe is returned.

required
Source code in sereto/models/settings.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
@validate_call
def get_sow_recipe(self, name: str | None) -> RenderRecipe:
    """Get a SoW recipe by name.

    Args:
        name: The name of the recipe to get. If None, the first recipe is returned.
    """
    if name is None:
        return self.sow_recipes[0]

    if len(res := [r for r in self.sow_recipes if r.name == name]) != 1:
        raise SeretoValueError(f"no SoW recipe found with name {name!r}")

    return res[0]

get_target_recipe(name)

Get a target recipe by name.

Parameters:

Name Type Description Default
name str | None

The name of the recipe to get. If None, the first recipe is returned.

required
Source code in sereto/models/settings.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
@validate_call
def get_target_recipe(self, name: str | None) -> RenderRecipe:
    """Get a target recipe by name.

    Args:
        name: The name of the recipe to get. If None, the first recipe is returned.
    """
    if name is None:
        return self.target_recipes[0]

    if len(res := [r for r in self.target_recipes if r.name == name]) != 1:
        raise SeretoValueError(f"no target recipe found with name {name!r}")

    return res[0]

RenderRecipe

Bases: BaseRecipe

Recipe for rendering files using RenderTools.

Attributes:

Name Type Description
name str

name of the recipe

tools Annotated[list[str], MinLen(1)]

list of RenderTool names to run

intermediate_format FileFormat

supported FileFormat

Source code in sereto/models/settings.py
 94
 95
 96
 97
 98
 99
100
101
102
103
class RenderRecipe(BaseRecipe):
    """Recipe for rendering files using `RenderTool`s.

    Attributes:
        name: name of the recipe
        tools: list of `RenderTool` names to run
        intermediate_format: supported `FileFormat`
    """

    intermediate_format: FileFormat = Field(strict=False, default=FileFormat.tex)

RenderTool

Bases: SeretoBaseModel

Commands used in recipes.

Attributes:

Name Type Description
name str

name of the tool

command str

command to run

args list[str]

list of arguments to pass to the command

Source code in sereto/models/settings.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class RenderTool(SeretoBaseModel):
    """Commands used in recipes.

    Attributes:
        name: name of the tool
        command: command to run
        args: list of arguments to pass to the command
    """

    name: str
    command: str
    args: list[str]

    @validate_call
    def run(
        self, cwd: DirectoryPath | None = None, input: bytes | None = None, replacements: dict[str, str] | None = None
    ) -> bytes:
        # Prepare the command
        command = [self.command] + self.args
        if replacements is not None:
            command = replace_strings(text=command, replacements=replacements)
        logger.info("Running command: {}", " ".join(command))
        command_preview = escape(" ".join(command))
        logger.info("[bold bright_cyan]▶ Running command[/]: [italic dim]{}[/]", command_preview, markup=True)

        # Run the command and measure the execution time
        start_time = time.time()
        result = subprocess.run(command, cwd=cwd, input=input, capture_output=True)
        end_time = time.time()

        # Check if the command failed
        if result.returncode != 0:
            stderr_raw = result.stderr.decode("utf-8", errors="replace").rstrip()
            stderr = escape(stderr_raw) if stderr_raw else "no stderr output"
            logger.error(
                "[bold bright_red]✖ Command failed[/] (exit code {}): [italic dim]{}[/]\n"
                "[bright_black]┌─ stderr ────────────────────────────────────────────[/]\n"
                "[bright_black]│[/] [red]{}[/]\n"
                "[bright_black]└──────────────────────────────────────────────────────[/]",
                result.returncode,
                command_preview,
                stderr,
                markup=True,
            )
            raise SeretoCalledProcessError("command execution failed")

        # Report success
        logger.info("Command finished in {:.2f} s", end_time - start_time)

        # Return the command output
        return result.stdout

Settings

Bases: SeretoBaseSettings

Global settings:

Attributes:

Name Type Description
projects_path DirectoryPath

path to the directory containing all projects

templates_path DirectoryPath

path to the directory containing templates

default_people list[Person]

list of default people to use in new projects

render Render

rendering settings

categories TypeCategories

supported categories - list of strings (2-20 lower-alpha characters; also dash and underscore is possible in all positions except the first and last one)

risk_due_dates dict[Risk, timedelta]

due dates for fixing the findings, for each risk level, as a timedelta

plugins Plugins

plugins settings

Raises:

Type Description
SeretoPathError

If the file is not found or permission is denied.

SeretoValueError

If the JSON file is invalid.

Source code in sereto/models/settings.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
class Settings(SeretoBaseSettings):
    """Global settings:

    Attributes:
        projects_path: path to the directory containing all projects
        templates_path: path to the directory containing templates
        default_people: list of default people to use in new projects
        render: rendering settings
        categories: supported categories - list of strings (2-20 lower-alpha characters; also dash and underscore is
            possible in all positions except the first and last one)
        risk_due_dates: due dates for fixing the findings, for each risk level, as a timedelta
        plugins: plugins settings

    Raises:
        SeretoPathError: If the file is not found or permission is denied.
        SeretoValueError: If the JSON file is invalid.
    """

    projects_path: DirectoryPath
    templates_path: DirectoryPath
    default_people: list[Person] = Field(default_factory=list)
    render: Render = Field(default=DEFAULT_RENDER_CONFIG)
    categories: TypeCategories = Field(default=DEFAULT_CATEGORIES)
    risk_due_dates: dict[Risk, timedelta] = Field(
        default_factory=lambda: {
            Risk.critical: timedelta(days=7),
            Risk.high: timedelta(days=14),
            Risk.medium: timedelta(days=30),
            Risk.low: timedelta(days=90),
        },
        strict=False,
    )
    plugins: Plugins = Field(default_factory=Plugins)

    @field_validator("categories", mode="after")
    @classmethod
    def unique_categories(cls, categories: TypeCategories) -> TypeCategories:
        """Ensure that all category names are unique and preserves their original order."""

        if not categories:
            return []
        seen: set[str] = set()
        unique: TypeCategories = []
        for category in categories:
            if category is None or category in seen:
                continue
            seen.add(category)
            unique.append(category)
        return unique

    @staticmethod
    def get_path() -> Path:
        return Path(get_app_dir(app_name="sereto")) / "settings.json"

    @classmethod
    def load_from(cls, file: FilePath) -> Self:
        try:
            return cls.model_validate_json(file.read_bytes())
        except FileNotFoundError:
            raise SeretoPathError(f"file not found at '{file}'") from None
        except PermissionError:
            raise SeretoPathError(f"permission denied for '{file}'") from None
        except ValidationError as e:
            raise SeretoValueError(f"invalid settings\n\n{e}") from e

unique_categories(categories) classmethod

Ensure that all category names are unique and preserves their original order.

Source code in sereto/models/settings.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
@field_validator("categories", mode="after")
@classmethod
def unique_categories(cls, categories: TypeCategories) -> TypeCategories:
    """Ensure that all category names are unique and preserves their original order."""

    if not categories:
        return []
    seen: set[str] = set()
    unique: TypeCategories = []
    for category in categories:
        if category is None or category in seen:
            continue
        seen.add(category)
        unique.append(category)
    return unique