papertrail 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- papertrail-0.1.0/PKG-INFO +155 -0
- papertrail-0.1.0/README.md +141 -0
- papertrail-0.1.0/pyproject.toml +48 -0
- papertrail-0.1.0/src/papertrail/__init__.py +3 -0
- papertrail-0.1.0/src/papertrail/__main__.py +30 -0
- papertrail-0.1.0/src/papertrail/adapters/__init__.py +0 -0
- papertrail-0.1.0/src/papertrail/adapters/io_funcs.py +43 -0
- papertrail-0.1.0/src/papertrail/core/__init__.py +4 -0
- papertrail-0.1.0/src/papertrail/core/collection/__init__.py +5 -0
- papertrail-0.1.0/src/papertrail/core/collection/example.py +121 -0
- papertrail-0.1.0/src/papertrail/core/collection/record.py +19 -0
- papertrail-0.1.0/src/papertrail/core/collection/recorder.py +41 -0
- papertrail-0.1.0/src/papertrail/core/logger.py +31 -0
- papertrail-0.1.0/src/papertrail/core/transformation/__init__.py +0 -0
- papertrail-0.1.0/src/papertrail/core/transformation/ast_editing.py +84 -0
- papertrail-0.1.0/src/papertrail/core/transformation/format_examples.py +50 -0
- papertrail-0.1.0/src/papertrail/core/transformation/transform.py +24 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: papertrail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: ed cuss
|
|
6
|
+
Author-email: ed cuss <edcussmusic@gmail.com>
|
|
7
|
+
Requires-Dist: attrs>=25.4.0
|
|
8
|
+
Requires-Dist: black>=26.1.0
|
|
9
|
+
Requires-Dist: io-adapters==0.2.2
|
|
10
|
+
Requires-Dist: pytest>=9.0.2
|
|
11
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# papertrail
|
|
16
|
+
|
|
17
|
+
### Motivation
|
|
18
|
+
- Documentation can get out of date quickly
|
|
19
|
+
- Sometimes its harder to explain what a function does in plain english than it is to just show what the input and outputs would be:
|
|
20
|
+
- e.g:
|
|
21
|
+
- "Multiply the value strings by the amount that is the key".
|
|
22
|
+
- vs
|
|
23
|
+
- `f({"a": 1, "b": 2, "c": 4}) == ['a', 'bb', 'cccc']`
|
|
24
|
+
- Tests are the best form of documentation because they complain when they go out of sync
|
|
25
|
+
- Doctests are an interesting way of writing tests however they can be fiddly and harder to set up than proper testing frameworks
|
|
26
|
+
- Resorting to only using doctests means missing out on features like fixtures and parameterisation
|
|
27
|
+
- Having some tests that are written in tests and some that are in doctests means keeping track of two places as the "source of truth"
|
|
28
|
+
|
|
29
|
+
### Installation
|
|
30
|
+
```shell
|
|
31
|
+
uv add papertrail
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Example
|
|
35
|
+
Use the `example` function to capture an example from a test to add to the docstring.
|
|
36
|
+
|
|
37
|
+
Wrap the example around the function before the equality operation and the function result will be captured and added to the docstring of the function.
|
|
38
|
+
This only happens if the tests pass!
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from papertrail import example
|
|
42
|
+
|
|
43
|
+
def test_add():
|
|
44
|
+
assert example(add, 2, 3) == 5
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This has captured the example and now once the tests are finished the docstring of the function will be updated like this:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
def add(a: int, b: int) -> int:
|
|
51
|
+
"""Papertrail examples:
|
|
52
|
+
|
|
53
|
+
>>> add(2, 3) == 5
|
|
54
|
+
True
|
|
55
|
+
::
|
|
56
|
+
"""
|
|
57
|
+
return a + b
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
You can use it with parametrized tests too:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
@pytest.mark.parametrize(
|
|
64
|
+
("args", "kwargs", "expected_result"),
|
|
65
|
+
[
|
|
66
|
+
pytest.param((2, 2), {}, 0),
|
|
67
|
+
pytest.param((2,), {"b": 3}, -1),
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
def test_func_b(args, kwargs, expected_result):
|
|
71
|
+
assert example(func_b, *args, **kwargs) == expected_result
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This captures all the cases and adds them to the functions docstring:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
def func_b(a: float, b: float) -> float:
|
|
78
|
+
"""Simple docstring
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
a (float)
|
|
82
|
+
b (float)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
float
|
|
86
|
+
|
|
87
|
+
Papertrail examples:
|
|
88
|
+
|
|
89
|
+
>>> func_b(3, 4) == -1
|
|
90
|
+
True
|
|
91
|
+
::
|
|
92
|
+
"""
|
|
93
|
+
return a - b
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
# Repo map
|
|
97
|
+
```
|
|
98
|
+
├── .github
|
|
99
|
+
│ └── workflows
|
|
100
|
+
│ ├── ci_tests.yaml
|
|
101
|
+
│ └── publish.yaml
|
|
102
|
+
├── docs
|
|
103
|
+
│ └── source
|
|
104
|
+
│ └── conf.py
|
|
105
|
+
├── src
|
|
106
|
+
│ └── papertrail
|
|
107
|
+
│ ├── adapters
|
|
108
|
+
│ │ ├── __init__.py
|
|
109
|
+
│ │ └── io_funcs.py # The IO functions for the IO adapters
|
|
110
|
+
│ ├── core
|
|
111
|
+
│ │ ├── collection
|
|
112
|
+
│ │ │ ├── __init__.py
|
|
113
|
+
│ │ │ ├── example.py # User entrypoint `example` and it's inner class `Example`
|
|
114
|
+
│ │ │ ├── record.py
|
|
115
|
+
│ │ │ └── recorder.py
|
|
116
|
+
│ │ ├── transformation
|
|
117
|
+
│ │ │ ├── __init__.py
|
|
118
|
+
│ │ │ ├── ast_editing.py
|
|
119
|
+
│ │ │ ├── format_examples.py
|
|
120
|
+
│ │ │ └── transform.py # Updates the the docstrings for all the funcs in the files in the example cache.
|
|
121
|
+
│ │ ├── __init__.py
|
|
122
|
+
│ │ └── logger.py
|
|
123
|
+
│ ├── __init__.py
|
|
124
|
+
│ └── __main__.py # the pytest hook for sessionfinish
|
|
125
|
+
├── tests
|
|
126
|
+
│ ├── _mock_data
|
|
127
|
+
│ │ ├── mock_src
|
|
128
|
+
│ │ │ ├── __init__.py
|
|
129
|
+
│ │ │ ├── mod_a.py
|
|
130
|
+
│ │ │ └── mod_b.py
|
|
131
|
+
│ │ └── tests
|
|
132
|
+
│ │ ├── __init__.py
|
|
133
|
+
│ │ ├── conftest.py
|
|
134
|
+
│ │ ├── test_mod_a.py
|
|
135
|
+
│ │ └── test_mod_b.py
|
|
136
|
+
│ ├── core
|
|
137
|
+
│ │ ├── collection
|
|
138
|
+
│ │ │ ├── __init__.py
|
|
139
|
+
│ │ │ ├── test_example.py
|
|
140
|
+
│ │ │ ├── test_record.py
|
|
141
|
+
│ │ │ └── test_recorder.py
|
|
142
|
+
│ │ └── transformation
|
|
143
|
+
│ │ ├── __init__.py
|
|
144
|
+
│ │ ├── test_ast_editing.py
|
|
145
|
+
│ │ └── test_format_examples.py
|
|
146
|
+
│ └── test_main.py
|
|
147
|
+
├── .pre-commit-config.yaml
|
|
148
|
+
├── README.md
|
|
149
|
+
├── pyproject.toml
|
|
150
|
+
├── ruff.toml
|
|
151
|
+
└── uv.lock
|
|
152
|
+
|
|
153
|
+
(generated with repo-mapper-rs)
|
|
154
|
+
::
|
|
155
|
+
```
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# papertrail
|
|
2
|
+
|
|
3
|
+
### Motivation
|
|
4
|
+
- Documentation can get out of date quickly
|
|
5
|
+
- Sometimes its harder to explain what a function does in plain english than it is to just show what the input and outputs would be:
|
|
6
|
+
- e.g:
|
|
7
|
+
- "Multiply the value strings by the amount that is the key".
|
|
8
|
+
- vs
|
|
9
|
+
- `f({"a": 1, "b": 2, "c": 4}) == ['a', 'bb', 'cccc']`
|
|
10
|
+
- Tests are the best form of documentation because they complain when they go out of sync
|
|
11
|
+
- Doctests are an interesting way of writing tests however they can be fiddly and harder to set up than proper testing frameworks
|
|
12
|
+
- Resorting to only using doctests means missing out on features like fixtures and parameterisation
|
|
13
|
+
- Having some tests that are written in tests and some that are in doctests means keeping track of two places as the "source of truth"
|
|
14
|
+
|
|
15
|
+
### Installation
|
|
16
|
+
```shell
|
|
17
|
+
uv add papertrail
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Example
|
|
21
|
+
Use the `example` function to capture an example from a test to add to the docstring.
|
|
22
|
+
|
|
23
|
+
Wrap the example around the function before the equality operation and the function result will be captured and added to the docstring of the function.
|
|
24
|
+
This only happens if the tests pass!
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from papertrail import example
|
|
28
|
+
|
|
29
|
+
def test_add():
|
|
30
|
+
assert example(add, 2, 3) == 5
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This has captured the example and now once the tests are finished the docstring of the function will be updated like this:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
def add(a: int, b: int) -> int:
|
|
37
|
+
"""Papertrail examples:
|
|
38
|
+
|
|
39
|
+
>>> add(2, 3) == 5
|
|
40
|
+
True
|
|
41
|
+
::
|
|
42
|
+
"""
|
|
43
|
+
return a + b
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
You can use it with parametrized tests too:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
@pytest.mark.parametrize(
|
|
50
|
+
("args", "kwargs", "expected_result"),
|
|
51
|
+
[
|
|
52
|
+
pytest.param((2, 2), {}, 0),
|
|
53
|
+
pytest.param((2,), {"b": 3}, -1),
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
def test_func_b(args, kwargs, expected_result):
|
|
57
|
+
assert example(func_b, *args, **kwargs) == expected_result
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This captures all the cases and adds them to the functions docstring:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
def func_b(a: float, b: float) -> float:
|
|
64
|
+
"""Simple docstring
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
a (float)
|
|
68
|
+
b (float)
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
float
|
|
72
|
+
|
|
73
|
+
Papertrail examples:
|
|
74
|
+
|
|
75
|
+
>>> func_b(3, 4) == -1
|
|
76
|
+
True
|
|
77
|
+
::
|
|
78
|
+
"""
|
|
79
|
+
return a - b
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
# Repo map
|
|
83
|
+
```
|
|
84
|
+
├── .github
|
|
85
|
+
│ └── workflows
|
|
86
|
+
│ ├── ci_tests.yaml
|
|
87
|
+
│ └── publish.yaml
|
|
88
|
+
├── docs
|
|
89
|
+
│ └── source
|
|
90
|
+
│ └── conf.py
|
|
91
|
+
├── src
|
|
92
|
+
│ └── papertrail
|
|
93
|
+
│ ├── adapters
|
|
94
|
+
│ │ ├── __init__.py
|
|
95
|
+
│ │ └── io_funcs.py # The IO functions for the IO adapters
|
|
96
|
+
│ ├── core
|
|
97
|
+
│ │ ├── collection
|
|
98
|
+
│ │ │ ├── __init__.py
|
|
99
|
+
│ │ │ ├── example.py # User entrypoint `example` and it's inner class `Example`
|
|
100
|
+
│ │ │ ├── record.py
|
|
101
|
+
│ │ │ └── recorder.py
|
|
102
|
+
│ │ ├── transformation
|
|
103
|
+
│ │ │ ├── __init__.py
|
|
104
|
+
│ │ │ ├── ast_editing.py
|
|
105
|
+
│ │ │ ├── format_examples.py
|
|
106
|
+
│ │ │ └── transform.py # Updates the the docstrings for all the funcs in the files in the example cache.
|
|
107
|
+
│ │ ├── __init__.py
|
|
108
|
+
│ │ └── logger.py
|
|
109
|
+
│ ├── __init__.py
|
|
110
|
+
│ └── __main__.py # the pytest hook for sessionfinish
|
|
111
|
+
├── tests
|
|
112
|
+
│ ├── _mock_data
|
|
113
|
+
│ │ ├── mock_src
|
|
114
|
+
│ │ │ ├── __init__.py
|
|
115
|
+
│ │ │ ├── mod_a.py
|
|
116
|
+
│ │ │ └── mod_b.py
|
|
117
|
+
│ │ └── tests
|
|
118
|
+
│ │ ├── __init__.py
|
|
119
|
+
│ │ ├── conftest.py
|
|
120
|
+
│ │ ├── test_mod_a.py
|
|
121
|
+
│ │ └── test_mod_b.py
|
|
122
|
+
│ ├── core
|
|
123
|
+
│ │ ├── collection
|
|
124
|
+
│ │ │ ├── __init__.py
|
|
125
|
+
│ │ │ ├── test_example.py
|
|
126
|
+
│ │ │ ├── test_record.py
|
|
127
|
+
│ │ │ └── test_recorder.py
|
|
128
|
+
│ │ └── transformation
|
|
129
|
+
│ │ ├── __init__.py
|
|
130
|
+
│ │ ├── test_ast_editing.py
|
|
131
|
+
│ │ └── test_format_examples.py
|
|
132
|
+
│ └── test_main.py
|
|
133
|
+
├── .pre-commit-config.yaml
|
|
134
|
+
├── README.md
|
|
135
|
+
├── pyproject.toml
|
|
136
|
+
├── ruff.toml
|
|
137
|
+
└── uv.lock
|
|
138
|
+
|
|
139
|
+
(generated with repo-mapper-rs)
|
|
140
|
+
::
|
|
141
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "papertrail"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "ed cuss", email = "edcussmusic@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"attrs>=25.4.0",
|
|
12
|
+
"black>=26.1.0",
|
|
13
|
+
"io-adapters==0.2.2",
|
|
14
|
+
"pytest>=9.0.2",
|
|
15
|
+
"python-dotenv>=1.2.1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.8.13,<0.9.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"hypothesis>=6.151.4",
|
|
25
|
+
"ipykernel>=7.1.0",
|
|
26
|
+
"pre-commit>=4.5.1",
|
|
27
|
+
"pytest>=9.0.2",
|
|
28
|
+
"pytest-cov>=7.0.0",
|
|
29
|
+
"repo-mapper-rs>=0.4.0",
|
|
30
|
+
"ruff>=0.14.14",
|
|
31
|
+
"sphinx>=9.0.4",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# [project.entry-points.pytest11]
|
|
35
|
+
# papertrail = "papertrail.__main__"
|
|
36
|
+
|
|
37
|
+
[tool.coverage.run]
|
|
38
|
+
branch = true
|
|
39
|
+
source = ["src"]
|
|
40
|
+
omit = [
|
|
41
|
+
"src/papertrail/__main__.py",
|
|
42
|
+
"src/papertrail/core/logger.py"
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.coverage.report]
|
|
46
|
+
show_missing = true
|
|
47
|
+
skip_covered = true
|
|
48
|
+
fail_under = 95
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""repo-map-desc: the pytest hook for sessionfinish
|
|
2
|
+
|
|
3
|
+
Calls an inner main function for testability
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Generator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from _pytest.config import ExitCode
|
|
13
|
+
from _pytest.main import Session
|
|
14
|
+
|
|
15
|
+
from papertrail.core.collection.recorder import _RECORDER, Recorder
|
|
16
|
+
from papertrail.core.transformation.transform import update_modified_docstrings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.hookimpl(hookwrapper=True, trylast=True)
|
|
20
|
+
def pytest_sessionfinish(
|
|
21
|
+
session: Session, # noqa: ARG001
|
|
22
|
+
exitstatus: int | ExitCode, # noqa: ARG001
|
|
23
|
+
) -> Generator[Any, None, None]:
|
|
24
|
+
yield
|
|
25
|
+
main(_RECORDER)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(recorder: Recorder) -> None:
|
|
29
|
+
recorder.prepare_files().write_examples()
|
|
30
|
+
update_modified_docstrings(recorder.adapter, recorder.path)
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""repo-map-desc: The IO functions for the IO adapters
|
|
2
|
+
|
|
3
|
+
Currently only uses simple registries
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from enum import Enum, unique
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from io_adapters import register_read_fn, register_write_fn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@unique
|
|
15
|
+
class FileType(Enum):
|
|
16
|
+
STR = "str"
|
|
17
|
+
JSON = "json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register_read_fn(FileType.STR)
|
|
21
|
+
def read_str(path: str, **kwargs: dict[str, Any]) -> str:
|
|
22
|
+
return Path(path).read_text(**kwargs)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register_write_fn(FileType.STR)
|
|
26
|
+
def write_str(data: str, path: str, **kwargs: dict[str, Any]) -> None:
|
|
27
|
+
_make_dirs(path).write_text(data, **kwargs)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@register_read_fn(FileType.JSON)
|
|
31
|
+
def read_json(path: str, **kwargs: dict[str, Any]) -> dict:
|
|
32
|
+
return json.loads(Path(path).read_text(), **kwargs)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@register_write_fn(FileType.JSON)
|
|
36
|
+
def write_json(data: str, path: str, **kwargs: dict[str, Any]) -> None:
|
|
37
|
+
_make_dirs(path).write_text(json.dumps(data, indent=2, sort_keys=False, default=str, **kwargs))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _make_dirs(path: str) -> Path:
|
|
41
|
+
path = Path(path)
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return path
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""repo-map-desc: User entrypoint `example` and it's inner class `Example`
|
|
2
|
+
|
|
3
|
+
The equality operator is where the magic happens
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
import attrs
|
|
14
|
+
|
|
15
|
+
from papertrail.core.collection.record import ExampleRecord
|
|
16
|
+
from papertrail.core.collection.recorder import _RECORDER, Recorder
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@attrs.define
|
|
22
|
+
class Example:
|
|
23
|
+
fn: Callable
|
|
24
|
+
args: tuple[Any, ...]
|
|
25
|
+
kwargs: dict[str, Any]
|
|
26
|
+
value: Any
|
|
27
|
+
recorder: Recorder
|
|
28
|
+
|
|
29
|
+
def __eq__(self, expected: T) -> bool:
|
|
30
|
+
record = ExampleRecord(
|
|
31
|
+
fn_name=self.fn.__name__,
|
|
32
|
+
module=self.fn.__module__,
|
|
33
|
+
src_file=str(Path(inspect.getsourcefile(self.fn))),
|
|
34
|
+
args=self.args,
|
|
35
|
+
kwargs=self.kwargs,
|
|
36
|
+
returned=self.value,
|
|
37
|
+
expected=expected,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self.recorder.record_example(record)
|
|
41
|
+
return self.value == expected
|
|
42
|
+
|
|
43
|
+
def __hash__(self) -> int:
|
|
44
|
+
hash_value = "".join(
|
|
45
|
+
[
|
|
46
|
+
self.fn.__name__,
|
|
47
|
+
self.fn.__module__,
|
|
48
|
+
str(self.args),
|
|
49
|
+
str(self.kwargs),
|
|
50
|
+
str(self.value),
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
return hash(hash_value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def example(fn: Callable, *args: tuple[Any, ...], **kwargs: dict[str, Any]) -> Example:
|
|
57
|
+
"""Capture an example from a test to add to the docstring.
|
|
58
|
+
|
|
59
|
+
Wrap the example around the function before the equality operation and the function result will be captured and added to the docstring of the function.
|
|
60
|
+
This only happens if the tests pass!
|
|
61
|
+
|
|
62
|
+
.. code-block:: python
|
|
63
|
+
|
|
64
|
+
from papertrail import example
|
|
65
|
+
|
|
66
|
+
def test_add():
|
|
67
|
+
assert example(add, 2, 3) == 5
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
This has captured the example and now once the tests are finished the docstring of the function will be updated like this:
|
|
71
|
+
|
|
72
|
+
.. code-block:: python
|
|
73
|
+
|
|
74
|
+
def add(a: int, b: int) -> int:
|
|
75
|
+
\"\"\"Papertrail examples:
|
|
76
|
+
|
|
77
|
+
>>> add(2, 3) == 5
|
|
78
|
+
True
|
|
79
|
+
::
|
|
80
|
+
\"\"\"
|
|
81
|
+
return a + b
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
You can use it with parametrized tests too:
|
|
85
|
+
|
|
86
|
+
.. code-block:: python
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize(
|
|
89
|
+
("args", "kwargs", "expected_result"),
|
|
90
|
+
[
|
|
91
|
+
pytest.param((2, 2), {}, 0),
|
|
92
|
+
pytest.param((2,), {"b": 3}, -1),
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
def test_func_b(args, kwargs, expected_result):
|
|
96
|
+
assert example(func_b, *args, **kwargs) == expected_result
|
|
97
|
+
|
|
98
|
+
This captures all the cases and adds them to the functions docstring:
|
|
99
|
+
|
|
100
|
+
.. code-block:: python
|
|
101
|
+
|
|
102
|
+
def func_b(a: float, b: float) -> float:
|
|
103
|
+
\"\"\"Simple docstring
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
a (float)
|
|
107
|
+
b (float)
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
float
|
|
111
|
+
|
|
112
|
+
Papertrail examples:
|
|
113
|
+
|
|
114
|
+
>>> func_b(3, 4) == -1
|
|
115
|
+
True
|
|
116
|
+
::
|
|
117
|
+
\"\"\"
|
|
118
|
+
return a - b
|
|
119
|
+
"""
|
|
120
|
+
value = fn(*args, **kwargs)
|
|
121
|
+
return Example(fn, args, kwargs, value, recorder=_RECORDER)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import attrs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@attrs.define(frozen=True, eq=True)
|
|
9
|
+
class ExampleRecord:
|
|
10
|
+
fn_name: str
|
|
11
|
+
module: str
|
|
12
|
+
src_file: str
|
|
13
|
+
args: tuple[Any, ...]
|
|
14
|
+
kwargs: dict[str, Any]
|
|
15
|
+
returned: Any
|
|
16
|
+
expected: Any
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, str]:
|
|
19
|
+
return attrs.asdict(self)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
import attrs
|
|
7
|
+
from io_adapters import IoAdapter, RealAdapter
|
|
8
|
+
|
|
9
|
+
from papertrail.adapters.io_funcs import FileType
|
|
10
|
+
from papertrail.core.collection.record import ExampleRecord
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@attrs.define
|
|
14
|
+
class Recorder:
|
|
15
|
+
path: str | Path = "./.papertrail_cache/examples.json"
|
|
16
|
+
records: list[ExampleRecord] = attrs.field(factory=list)
|
|
17
|
+
adapter: IoAdapter = attrs.field(factory=RealAdapter)
|
|
18
|
+
files: dict = attrs.field(factory=dict)
|
|
19
|
+
|
|
20
|
+
def __attrs_post_init__(self) -> None:
|
|
21
|
+
self.path = Path(self.path)
|
|
22
|
+
|
|
23
|
+
def record_example(self, example: ExampleRecord) -> Self:
|
|
24
|
+
self.records.append(example)
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
def prepare_files(self) -> Self:
|
|
28
|
+
self.files[(self.path, FileType.JSON)] = [r.to_dict() for r in self.records]
|
|
29
|
+
self.files[(self.path.parent / ".gitignore", FileType.STR)] = (
|
|
30
|
+
"# automatically created by papertrail\n*"
|
|
31
|
+
)
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def write_examples(self) -> Self:
|
|
35
|
+
for k, data in self.files.items():
|
|
36
|
+
path, file_type = k
|
|
37
|
+
self.adapter.write(data, path, file_type)
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_RECORDER = Recorder()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from logging.handlers import RotatingFileHandler
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
logger.propagate = False
|
|
10
|
+
|
|
11
|
+
REPO_ROOT = Path(__file__).absolute().parents[3]
|
|
12
|
+
load_dotenv(f"{REPO_ROOT}/envs/.env")
|
|
13
|
+
|
|
14
|
+
if os.getenv("LOGGING_ENABLED", "false").lower() == "true":
|
|
15
|
+
formatter = logging.Formatter(
|
|
16
|
+
"%(asctime)s | %(levelname)-8s [%(filename)s:%(lineno)d:%(funcName)s] %(message)s"
|
|
17
|
+
)
|
|
18
|
+
logger.setLevel(logging.DEBUG)
|
|
19
|
+
stream_handler = logging.StreamHandler()
|
|
20
|
+
stream_handler.setLevel(logging.DEBUG)
|
|
21
|
+
stream_handler.setFormatter(formatter)
|
|
22
|
+
|
|
23
|
+
log_path = f"{REPO_ROOT}/logs/app.log"
|
|
24
|
+
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
25
|
+
|
|
26
|
+
file_handler = RotatingFileHandler(log_path, maxBytes=2_000_000, backupCount=1)
|
|
27
|
+
file_handler.setLevel(logging.DEBUG)
|
|
28
|
+
file_handler.setFormatter(formatter)
|
|
29
|
+
|
|
30
|
+
logger.addHandler(stream_handler)
|
|
31
|
+
logger.addHandler(file_handler)
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
import attrs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def update_function_docstrings(code: str, examples: dict[str, str]) -> str:
|
|
9
|
+
replacements = create_replacements(code, examples)
|
|
10
|
+
return replace_docstrings(code, replacements)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@attrs.define(frozen=True)
|
|
14
|
+
class DocString:
|
|
15
|
+
doc: str
|
|
16
|
+
start_line: int
|
|
17
|
+
end_line: int
|
|
18
|
+
indent: str = " "
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_doc_node(cls, node: ast.AST) -> Self:
|
|
22
|
+
return cls(node.value.value.rstrip(), node.lineno - 1, node.end_lineno)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_non_doc_node(cls, node: ast.AST) -> Self:
|
|
26
|
+
start = node.lineno - 1
|
|
27
|
+
return cls("", start, start)
|
|
28
|
+
|
|
29
|
+
def with_example(self, example: str) -> Self:
|
|
30
|
+
example = "".join(
|
|
31
|
+
[
|
|
32
|
+
f"{self.indent}{line}" if line.strip() else line
|
|
33
|
+
for line in example.splitlines(keepends=True)
|
|
34
|
+
]
|
|
35
|
+
)
|
|
36
|
+
pattern = r"(?ms)^[ \t]*Papertrail examples:\n.*?\n[ \t]*::"
|
|
37
|
+
replaced_doc, n = re.subn(pattern, example, self.doc)
|
|
38
|
+
|
|
39
|
+
new_doc = replaced_doc if n else f"{self.doc}\n\n{example}"
|
|
40
|
+
return DocString(new_doc.strip(), self.start_line, self.end_line)
|
|
41
|
+
|
|
42
|
+
def to_doc(self) -> str:
|
|
43
|
+
return f'{self.indent}"""{self.doc.strip()}\n{self.indent}"""\n'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_replacements(code: str, examples: dict[str, str]) -> list[DocString]:
|
|
47
|
+
tree = ast.parse(code)
|
|
48
|
+
|
|
49
|
+
replacements = []
|
|
50
|
+
|
|
51
|
+
for node in ast.walk(tree):
|
|
52
|
+
if not _is_fn_with_body(node) or not (example := examples.get(node.name, "")):
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
first = node.body[0]
|
|
56
|
+
|
|
57
|
+
if _is_docstring(first):
|
|
58
|
+
docstring = DocString.from_doc_node(first)
|
|
59
|
+
else:
|
|
60
|
+
docstring = DocString.from_non_doc_node(first)
|
|
61
|
+
|
|
62
|
+
replacements.append(docstring.with_example(example))
|
|
63
|
+
|
|
64
|
+
return replacements
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def replace_docstrings(code: str, replacements: list[DocString]) -> str:
|
|
68
|
+
lines = code.splitlines(keepends=True)
|
|
69
|
+
for doc in reversed(replacements):
|
|
70
|
+
lines[doc.start_line : doc.end_line] = [doc.to_doc()]
|
|
71
|
+
|
|
72
|
+
return "".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_fn_with_body(node: ast.AST) -> bool:
|
|
76
|
+
return isinstance(node, ast.FunctionDef) and node.body
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_docstring(node: ast.AST) -> bool:
|
|
80
|
+
return (
|
|
81
|
+
isinstance(node, ast.Expr)
|
|
82
|
+
and isinstance(node.value, ast.Constant)
|
|
83
|
+
and isinstance(node.value.value, str)
|
|
84
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
import black
|
|
4
|
+
|
|
5
|
+
from papertrail.core.collection.record import ExampleRecord
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def collect_example_strs(examples: list[dict]) -> dict[str, dict[str, list[str]]]:
|
|
9
|
+
fn_examples = defaultdict(_inner)
|
|
10
|
+
|
|
11
|
+
for data in examples:
|
|
12
|
+
ex = ExampleRecord(**data)
|
|
13
|
+
|
|
14
|
+
if ex.returned != ex.expected:
|
|
15
|
+
continue
|
|
16
|
+
|
|
17
|
+
fn_examples[ex.src_file][ex.fn_name].append(example_to_str(ex))
|
|
18
|
+
|
|
19
|
+
return {k: dict(v) for k, v in fn_examples.items()}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _inner() -> defaultdict:
|
|
23
|
+
return defaultdict(list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def example_to_str(example: ExampleRecord) -> str:
|
|
27
|
+
sig = ", ".join(
|
|
28
|
+
part
|
|
29
|
+
for part in (
|
|
30
|
+
", ".join(map(str, example.args)),
|
|
31
|
+
", ".join(f"{k}={v}" for k, v in example.kwargs.items()),
|
|
32
|
+
)
|
|
33
|
+
if part
|
|
34
|
+
)
|
|
35
|
+
expr = f"{example.fn_name}({sig}) == {example.returned}"
|
|
36
|
+
formatted = black.format_str(expr, mode=black.Mode())
|
|
37
|
+
lines = formatted.rstrip().splitlines()
|
|
38
|
+
doctest = "\n".join(
|
|
39
|
+
f"{' >>>' if i == 0 else ' ...'} {line}" for i, line in enumerate(lines)
|
|
40
|
+
)
|
|
41
|
+
return f"{doctest}\n True"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reduce_examples_to_example_str(
|
|
45
|
+
fn_examples: dict[str, dict[str, list[str]]],
|
|
46
|
+
) -> dict[str, dict[str, str]]:
|
|
47
|
+
return {
|
|
48
|
+
path: {k: "Papertrail examples:\n\n" + "\n\n".join(v) + "\n::" for k, v in fn.items()}
|
|
49
|
+
for path, fn in fn_examples.items()
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""repo-map-desc: Updates the the docstrings for all the funcs in the files in the example cache.
|
|
2
|
+
|
|
3
|
+
This is the main entry point for the transformation module.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from io_adapters import IoAdapter
|
|
7
|
+
|
|
8
|
+
from papertrail.adapters.io_funcs import FileType
|
|
9
|
+
from papertrail.core.transformation.ast_editing import update_function_docstrings
|
|
10
|
+
from papertrail.core.transformation.format_examples import (
|
|
11
|
+
collect_example_strs,
|
|
12
|
+
reduce_examples_to_example_str,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def update_modified_docstrings(adapter: IoAdapter, examples_cache_path: str) -> None:
|
|
17
|
+
examples = adapter.read(examples_cache_path, FileType.JSON)
|
|
18
|
+
reduced_examples = reduce_examples_to_example_str(collect_example_strs(examples))
|
|
19
|
+
|
|
20
|
+
for path, module_examples in reduced_examples.items():
|
|
21
|
+
code = adapter.read(path, FileType.STR)
|
|
22
|
+
new_code = update_function_docstrings(code, module_examples)
|
|
23
|
+
if new_code != code:
|
|
24
|
+
adapter.write(new_code, path, FileType.STR)
|