Skip to content

Commit b7ac3d3

Browse files
docs: add developer guide and related documentation for generators and validation
Co-authored-by: Copilot <copilot@github.com>
1 parent dc9bf01 commit b7ac3d3

7 files changed

Lines changed: 376 additions & 0 deletions

File tree

docs/dev/docstrings.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Docstring Format
2+
3+
Copia parses generator docstrings to build the in-app help and the documentation site. The format is a subset of Google-style docstrings with one Copia-specific addition.
4+
5+
---
6+
7+
## Structure
8+
9+
```python
10+
def my_generator(param: str = "default") -> str:
11+
"""Short description of what the generator produces.
12+
13+
Locale dependent:
14+
yes
15+
16+
Args:
17+
param: Description of the parameter. Defaults to 'default'.
18+
19+
Returns:
20+
Description of the returned value.
21+
22+
Note:
23+
Optional warning or constraint the user should know about.
24+
"""
25+
```
26+
27+
---
28+
29+
## Sections
30+
31+
### Description
32+
33+
The first lines before any section header. Keep it short — one or two sentences. This is the most important part of the docstring, as it gives users a quick understanding of what the generator does.
34+
35+
```python
36+
"""Generate a random job title."""
37+
```
38+
39+
### `Locale dependent`
40+
41+
Indicates whether the output varies based on the active generation locale. Must be `yes` or `no`.
42+
43+
```
44+
Locale dependent:
45+
yes
46+
```
47+
48+
If omitted, the generator is assumed to be locale-independent.
49+
50+
!!! note title="Not entirely strict"
51+
To be honest, if the line below Locale dependent is not exactly `yes` it's treated as `no`. But it's best to follow the format strictly to avoid confusion.
52+
53+
### `Args`
54+
55+
Documents each parameter. One line per parameter in the format `name: description.`
56+
if the parameter has a default value, include it in the description for clarity. The type is inferred from the function signature — do not repeat it here.
57+
58+
```
59+
Args:
60+
length: Number of characters. Defaults to 12.
61+
special_chars: Include special characters. Defaults to True.
62+
```
63+
64+
### `Returns`
65+
66+
A single line describing the returned value. The type is inferred from the return annotation — do not repeat it here.
67+
68+
```
69+
Returns:
70+
A randomly generated username string.
71+
```
72+
73+
### `Note`
74+
75+
An optional warning or constraint. Use sparingly — only for things that could cause runtime errors or surprising behavior.
76+
77+
```
78+
Note:
79+
Only uses values already present in the database.
80+
An empty column will raise an error at runtime.
81+
```
82+
83+
---
84+
85+
## What gets rendered
86+
87+
The parser extracts these sections and renders them as:
88+
89+
- A signature blockquote
90+
- A description paragraph
91+
- A locale dependent checkbox
92+
- An arguments table
93+
- A returns line
94+
- A warning blockquote for notes

docs/dev/generators.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Adding a Generator
2+
3+
Generators are plain Python functions registered in the `GENERATORS_REGISTRY`. Adding one requires writing the function, documenting it, and placing it in the right module.
4+
5+
---
6+
7+
## 1. Write the function
8+
9+
A generator is a regular Python function. It must follow these rules:
10+
11+
- It must have a return type annotation
12+
- It must only use types compatible with SQL — `str`, `int`, `float`, `bool`, `date`, `datetime`, `UUID`
13+
- It must not use `**kwargs`
14+
- It may use `*args` (var positional), but not simultaneously with other positional-or-keyword parameters
15+
16+
```python
17+
from copia.generators._core import get_faker
18+
19+
def job() -> str:
20+
return get_faker().job()
21+
```
22+
23+
Use `get_faker()` to access the global Faker instance. This ensures the generator respects the active locale and generation settings.
24+
25+
---
26+
27+
## 2. Document it
28+
29+
Follow the [docstring format](docstrings.md) to document the generator. Copia uses the docstring to generate the in-app help and the documentation site.
30+
31+
```python
32+
def job() -> str:
33+
"""Generate a random job title.
34+
35+
Locale dependent:
36+
yes
37+
38+
Returns:
39+
A random job title string.
40+
"""
41+
return get_faker().job()
42+
```
43+
44+
---
45+
46+
## 3. Register it
47+
48+
Place the function in the appropriate module under `src/copia/generators/` and the rest will be automatically handled by `src/copia/generators/__init__.py`
49+
50+
---
51+
52+
## 4. Verify
53+
54+
1. Run the test with `pytest tests/test_generators_discover.py` to ensure there are no import warnings or errors related to your new generator.
55+
56+
2. Run `copia` and press `?`. to check how your generator docstring looks in the in-app help. If you see any formatting issues, adjust your docstring accordingly.

docs/dev/generators_registry.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# The `generators_registry` System
2+
3+
in the `generators/__init__.py` file
4+
5+
there's a function called `_build_generators_registry`.
6+
this function is responsible for dynamically building the registry of generators based on a few rules and conventions.
7+
8+
---
9+
10+
## How it works
11+
12+
- when the module is imported, `_build_generators_registry` is called to populate the `GENERATORS_REGISTRY` dictionary.
13+
14+
- it scans the module's global namespace (copia.generators) for all non private modules (those that don't start with `_`).
15+
16+
- for each module, it looks for all non private functions defined in that module. (again, those that don't start with `_`).
17+
18+
- each function is registered in the `GENERATORS_REGISTRY` with its name as the key and the function object as the value.
19+
20+
!!! note "Automatic discovery"
21+
all this happens automatically when you import the generators module. You don't need to do anything special to register your generator — just define it in a non-private module and it will be picked up.
22+
23+
---
24+
25+
## Constraints
26+
27+
### No overriding
28+
29+
A generator name must be globally unique. If a function with the same name is already registered, `discover()` raises a `ImportWarning`.
30+
31+
```python
32+
Traceback (most recent call last):
33+
ImportWarning: found overrinding generator func at copia.generators.address with identifier city
34+
```
35+
36+
### No `**kwargs`
37+
38+
Generators must not use `**kwargs`. The DSL only supports positional and named arguments — variadic keyword arguments are not representable in the grammar.
39+
40+
```python
41+
# Invalid
42+
def my_gen(**kwargs) -> str: ...
43+
```
44+
45+
### `*args` and named parameters are mutually exclusive
46+
47+
A generator may use either `*args` (var positional) or POSITIONAL_OR_KEYWORD parameters — but not both simultaneously.
48+
49+
```python
50+
# Valid — only *args
51+
def enum(*args) -> Any: ...
52+
53+
# Valid — only POSITIONAL_OR_KEYWORD params
54+
def email(safe: bool = True, domain: str = "") -> str: ...
55+
56+
# All Valids — *args with only named params
57+
def enum(*args, extra: str) -> str: ...
58+
def yet_another_gen(*args, extra: str = "") -> str: ...
59+
60+
# Invalid — POSITIONAL_OR_KEYWORD params with *args (var positional)
61+
def invalid_gen(extra: str, *args) -> str: ...
62+
63+
# WHY ? Because the Semantic Validator for the DSL cannot currently handle this combination. It would require a more complex parsing logic to determine which arguments are meant for `*args` and which are actual positionals parameters, especially since the user can pass any number of values for `*args`. To keep things simple and unambiguous, we enforce this constraint.
64+
```
65+
66+
!!! note "About union types"
67+
The current implementation of the docstring parser and the semantic validator does not support union types (e.g., `str | int`) in generator signatures. This will be updated in future updates. and no, even Any is not supported too.
68+
69+
---
70+
71+
## Adding new generators
72+
73+
To add a new generator, simply define a function in any non-private module under `copia.generators` and ensure it follows the constraints outlined above. The function will be automatically registered and available for use in the DSL.
74+
75+
first check if the module you want to add the generator to already exists. If it does, add your function there. If not, create a new module and add your function there.
76+
77+
since the registry is built dynamically, you don't need to do anything else to make your generator available — just define it and it will be picked up when the module is imported.
78+
79+
to ensure your generator is properly documented, follow the docstring format outlined in the [docstrings guide](./docstrings.md). This will help users understand how to use your generator and what it does.
80+
81+
and to finish, simply run pytest tests/test_generators_discover.py to avoid any import warnings or errors related to your new generator. If you see an ImportWarning about overriding a generator, it means you have a naming conflict — either rename your generator or remove the conflicting one. If you see an error about `**kwargs` or invalid parameter combinations, adjust your function signature accordingly.
82+
83+
> just keep in mind, the function signature is a direct interface to users, so keep it simple and intuitive. Avoid complex parameter combinations that could confuse users or make the generator difficult to use in the DSL. The goal is to provide a clear and straightforward way for users to generate data without having to worry about the underlying implementation details.

docs/dev/index.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Developer Guide
2+
3+
This section documents the internals of Copia for contributors and developers who want to extend it.
4+
5+
---
6+
7+
## Contents
8+
9+
- [Adding a generator](generators.md) — how to write and register a new generator
10+
- [Docstring format](docstrings.md) — the docstring convention Copia uses to generate documentation
11+
- [The dynamic generators system](generators_registry.md) — how generators are registered and what constraints apply
12+
- [The semantic validator](validator.md) — how Copia validates DSL calls against generator signatures
13+
- [How `ref` works](ref.md) — the internals of the `ref()` generator

docs/dev/ref.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# How `ref` Works
2+
3+
`ref` is the only generator that reads from the database rather than generating data. It samples a random value from an existing column.
4+
5+
---
6+
7+
## Usage
8+
9+
```
10+
user_id: ref('users.id')
11+
```
12+
13+
The argument is a string in `table.column` format. `ref` fetches all existing values from that column and returns one at random on each call.
14+
15+
---
16+
17+
## Internals
18+
19+
### Parsing the target
20+
21+
When the DSL is parsed, `ref('users.id')` produces a `GeneratorCall` with a single positional argument — the string `"users.id"`. Before generation, the runner walks all parsed columns, identifies `ref` calls, and extracts the `table.column` string via `_parse_ref_input()`.
22+
23+
The string is split on `.` and must produce exactly two non-empty parts — `table` and `column`. Any other format raises a `GeneratorValueError` immediately.
24+
25+
### Phase 1 — Initialize
26+
27+
Before any generation, the runner scans all parsed columns for `ref` calls and builds an empty `REF_COLLECTION` structure:
28+
29+
```python
30+
REF_COLLECTION = {
31+
"users": {
32+
"id": []
33+
},
34+
"posts": {
35+
"id": []
36+
}
37+
}
38+
```
39+
40+
All tables and columns are identified upfront. Columns belonging to the same table are grouped together — this is what makes the next phase efficient.
41+
42+
### Phase 2 — Populate
43+
44+
With the structure in hand, the runner fetches all columns of a given table in a single query per table. If two `ref` calls target `users.id` and `users.email`, only one query is issued:
45+
46+
```sql
47+
SELECT id, email FROM users
48+
```
49+
50+
This is more efficient than one query per `ref` call. The results are distributed into the corresponding lists in `REF_COLLECTION`.
51+
52+
### Sampling
53+
54+
On each row generation, `ref('users.id')` samples a random value from `REF_COLLECTION["users"]["id"]` using `random.choice()`.
55+
56+
### Empty column behavior
57+
58+
If the target column is empty, `REF_COLLECTION["table"]["column"]` will be an empty list. Sampling from an empty list raises a `GeneratorValueError` with a descriptive message pointing to the empty column.
59+
60+
This is why the `ref` documentation warns: **always populate parent tables before referencing them.**
61+
62+
---
63+
64+
## Limitations
65+
66+
- `ref` can only reference values already in the database at the time of the run. Values generated in the same run are not available to `ref`.
67+
- `ref` requires an active database connection.

docs/dev/validator.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# The Semantic Validator
2+
3+
The semantic validator checks that a parsed DSL call is valid against the registered generator signatures. It runs after parsing and before generation.
4+
5+
---
6+
7+
## What it validates
8+
9+
Given a `GeneratorCall` from the parser, the validator checks:
10+
11+
- The generator name exists in `GENERATORS_REGISTRY`
12+
- The number of positional arguments does not exceed what the signature accepts
13+
- All named arguments exist in the signature
14+
- The types of all arguments match the parameter annotations
15+
16+
If any check fails, the validator raises a typed exception that the TUI catches and displays in the results viewer.
17+
18+
---
19+
20+
## Errors
21+
22+
| Exception | Cause | Can be skipped? |
23+
|-----------|-------|-----------------|
24+
| `UnknownGeneratorError` | The generator name is not in the registry | Never |
25+
| `TooManyPositionalsError` | More positional args than the signature accepts | Only for *args generators |
26+
| `UnknownNamedParamError` | A named argument does not exist in the signature | Never |
27+
| `MissingRequiredParamError` | A required parameter was not provided | Never |
28+
| `TypeMismatchError` | An argument type does not match the annotation | Never |
29+
| `PositionalNamedCollisionError` | A parameter is passed both positionally and by name | Only for *args generators |
30+
31+
---
32+
33+
## Type checking
34+
35+
The validator maps DSL argument types to Python types:
36+
37+
| DSL value | Python type |
38+
|-----------|-------------|
39+
| `42` | `int` |
40+
| `3.14` | `float` |
41+
| `'hello'` | `str` |
42+
| `True` / `False` | `bool` |
43+
44+
`Literal` annotations are checked by value — the argument must be one of the allowed values.
45+
46+
---
47+
48+
## Var positional generators
49+
50+
Generators that use `*args` (like `enum`) bypass the arity check — any number of positional arguments is valid. The type of each argument is still checked against the parameter annotation if present.
51+
52+
---
53+
54+
## When validation runs
55+
56+
Validation runs once when the user clicks **Run**, before any generation starts. If validation fails, no rows are generated and the error is displayed in the logs tab.

mkdocs.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ nav:
4848
- Blog:
4949
- blog/index.md
5050
- Changelog: changelog.md
51+
- Developer Guide:
52+
- dev/index.md
53+
- dev/generators.md
54+
- dev/docstrings.md
55+
- dev/generators_registry.md
56+
- dev/validator.md
57+
- dev/ref.md
5158

5259

5360
markdown_extensions:

0 commit comments

Comments
 (0)