tomfoolery.engine

  1from typing import Any
  2
  3import ast_comments as ast
  4import black
  5import isort
  6from pathier import Pathier, Pathish
  7
  8from tomfoolery import utilities
  9
 10root = Pathier(__file__).parent
 11
 12
 13class TomFoolery:
 14    def __init__(self, module: ast.Module | None = None, recursive: bool = True):
 15        """If no `module` is given, an empty new one will be created.
 16
 17        When generating a `dataclass` from a dictionary,
 18        if `recursive` is `True` then values that are also dictionaries will have a `dataclass` generated.
 19
 20        The annotation for that field in the original `dataclass` will be typed as an instance of the second `dataclass`.
 21
 22        If `recursive` is `False`, values that are dictionaries will be typed as such.
 23
 24        i.e.
 25        from a file named "chonker.toml"
 26        >>> {
 27        >>>  "name": "yeehaw",
 28        >>>  "stats": {
 29        >>>      "average": 77.54,
 30        >>>      "max": 94.22,
 31        >>>      "min": 22.76
 32        >>>  }
 33        >>> }
 34
 35        With recursive == True
 36        >>> @dataclass
 37        >>> class Stats:
 38        >>>     average: float
 39        >>>     max: float
 40        >>>     min: float
 41        >>>
 42        >>> @dataclass
 43        >>> class Chonker:
 44        >>>     name: str
 45        >>>     stats: Stats
 46
 47        With recursive == False
 48        >>> @dataclass
 49        >>> class Chonker:
 50        >>>     name: str
 51        >>>     stats: dict
 52        """
 53        self.module: ast.Module = module or ast.Module([], [])
 54        self.recursive = recursive
 55
 56    @property
 57    def class_names(self) -> list[str]:
 58        """List of class names in `self.module.body`."""
 59        return [node.name for node in self.module.body if type(node) == ast.ClassDef]
 60
 61    @property
 62    def source(self) -> str:
 63        """Returns the source code this object represents."""
 64        try:
 65            return self.format_str(ast.unparse(self.module))
 66        except Exception as e:
 67            return ast.unparse(self.module)
 68
 69    def format_str(self, code: str) -> str:
 70        """Sort imports and format with `black`."""
 71        return black.format_str(isort.api.sort_code_string(code), mode=black.Mode())  # type: ignore
 72
 73    # Seat |===================================== Import Nodes =====================================|
 74
 75    @property
 76    def dacite_import_node(self) -> ast.Import:
 77        return ast.Import([ast.alias("dacite")])
 78
 79    @property
 80    def dataclass_import_node(self) -> ast.ImportFrom:
 81        return ast.ImportFrom(
 82            "dataclasses", [ast.alias("dataclass"), ast.alias("asdict")], 0
 83        )
 84
 85    @property
 86    def import_nodes(self) -> list[ast.Import | ast.ImportFrom]:
 87        return [
 88            self.dacite_import_node,
 89            self.dataclass_import_node,
 90            self.pathier_import_node,
 91            self.typing_extensions_import_node,
 92        ]
 93
 94    @property
 95    def pathier_import_node(self) -> ast.ImportFrom:
 96        return ast.ImportFrom(
 97            "pathier", [ast.alias("Pathier"), ast.alias("Pathish")], 0
 98        )
 99
100    @property
101    def typing_extensions_import_node(self) -> ast.ImportFrom:
102        return ast.ImportFrom("typing_extensions", [ast.alias("Self")])
103
104    # Seat |======================================== Nodes ========================================|
105
106    @property
107    def dataclass_node(self) -> ast.Name:
108        """A node representing `@dataclass`."""
109        return ast.Name("dataclass", ast.Load())
110
111    @property
112    def dump_node(self) -> ast.FunctionDef:
113        """The dumping function for the generated `dataclass`."""
114        dump = self.nodes_from_file(root / "_dump.py")[0]
115        return dump if isinstance(dump, ast.FunctionDef) else ast.FunctionDef()
116
117    @property
118    def load_node(self) -> ast.FunctionDef:
119        """The loading function for the generated `dataclass`."""
120        load = self.nodes_from_file(root / "_load.py")[0]
121        return load if isinstance(load, ast.FunctionDef) else ast.FunctionDef()
122
123    def add_dataclass(self, dataclass: ast.ClassDef):
124        """Add or merge `dataclass` into `self.module.body`."""
125        if dataclass.name not in self.class_names:
126            self.module.body.append(dataclass)
127        else:
128            classdex = self.class_index(dataclass.name)
129            self.module.body[classdex] = self.merge_dataclasses(self.module.body[classdex], dataclass)  # type: ignore
130
131    def class_index(self, class_name: str) -> int:
132        """Return the `self.module.body` index for a class with `class_name`."""
133        for i, node in enumerate(self.module.body):
134            if isinstance(node, ast.ClassDef) and node.name == class_name:
135                return i
136        return len(self.module.body)
137
138    def fix_order(self):
139        """Reorder `self.module.body` so that definitions preceede instances.
140
141        i.e. A newly added class is defined before another class creates an instance."""
142        new_body = []
143        for node in self.module.body:
144            if isinstance(node, ast.ClassDef):
145                placed = False
146                for i, new_node in enumerate(new_body):
147                    if node.name in ast.unparse(new_node):
148                        new_body.insert(i, node)
149                        placed = True
150                        break
151                if not placed:
152                    new_body.append(node)
153            else:
154                new_body.append(node)
155        self.module.body = new_body
156
157    def last_annassign_index(self, node: ast.ClassDef) -> int:
158        """Return the `node.body` index of the last annotated assignment node.
159        Assumes all annotated assignments are sequential and the first elements of `node`.
160        """
161        for i, child in enumerate(node.body):
162            if not isinstance(child, ast.AnnAssign):
163                return i - 1
164        return len(node.body)
165
166    def merge_dataclasses(
167        self, class1: ast.ClassDef, class2: ast.ClassDef
168    ) -> ast.ClassDef:
169        """Add annotated assignments and functions from `class2` to `class1` and return the result."""
170        funcs = [node.name for node in class1.body if isinstance(node, ast.FunctionDef)]
171        assigns = [
172            node.target.id
173            for node in class1.body
174            if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name)
175        ]
176        for node in class2.body:
177            if isinstance(node, ast.FunctionDef) and node.name not in funcs:
178                class1.body.append(node)
179            elif (
180                isinstance(node, ast.AnnAssign)
181                and isinstance(node.target, ast.Name)
182                and (node.target.id not in assigns)
183            ):
184                class1.body.insert(self.last_annassign_index(class1) + 1, node)
185        return class1
186
187    def nodes_from_file(self, file: Pathish) -> list[ast.stmt]:
188        """Return ast-parsed module body from `file`."""
189        node = ast.parse(Pathier(file).read_text())
190        return node.body if isinstance(node, ast.Module) else []
191
192    # Seat |======================================= Builders =======================================|
193
194    def annotated_assignments_from_dict(
195        self, data: dict[str, Any]
196    ) -> list[ast.AnnAssign]:
197        """Return a list of annotated assignment nodes built from `data`.
198
199        If `recursive` is `True` (the default),
200        any values in `data` that are themselves a dictionary,
201        will have a `dataclass` built and inserted in `self.classes`.
202
203        The field for that value will be annotated as an instance of that secondary `dataclass`.
204        """
205        assigns = []
206        for key, val in data.items():
207            if self.recursive and isinstance(val, dict):
208                dataclass = self.build_dataclass(key, val)
209                self.add_dataclass(dataclass)
210                assigns.append(
211                    self.build_annotated_assignment(
212                        key, utilities.key_to_classname(key), False
213                    )
214                )
215            else:
216                assigns.append(self.build_annotated_assignment(key, val))
217        return assigns
218
219    def build_annotated_assignment(
220        self, name: str, val: Any, evaluate_type: bool = True
221    ) -> ast.AnnAssign:
222        """Return an annotated assignment node with `name` and an annotation based on the type of `val`.
223
224        If `evaluate_type` is `False`, then `val` will be used directly as the type annotation instead of `type(val).__name__`.
225        """
226        return ast.AnnAssign(
227            ast.Name(name, ast.Store()),
228            ast.Name(utilities.build_type(val) if evaluate_type else val, ast.Load()),
229            None,
230            1,
231        )
232
233    def build_dataclass(
234        self, name: str, data: dict[str, Any], add_methods: bool = False
235    ) -> ast.ClassDef:
236        """Build a `dataclass` with `name` from `data` and insert it into `self.classes`.
237
238        If `add_methods` is `True`, `load()` and `dump()` functions will be added to the class.
239        """
240        class_ = ast.ClassDef(
241            utilities.key_to_classname(name),
242            [],
243            [],
244            self.annotated_assignments_from_dict(data),
245            [self.dataclass_node],
246        )
247        if add_methods:
248            class_.body.extend([self.load_node, self.dump_node])
249        return class_
250
251    # Seat |======================================== Main ========================================|
252
253    def generate(self, name: str, data: dict[str, Any]) -> str:
254        """Generate a `dataclass` with `name` from `data` and return the source code.
255
256        Currently, all keys in `data` and any of its nested dictionaries must be valid Python variable names.
257        """
258        for node in self.import_nodes:
259            if node not in self.module.body:
260                self.module.body.insert(0, node)
261        dataclass = self.build_dataclass(name, data, True)
262        self.add_dataclass(dataclass)
263        self.fix_order()
264        return self.source
265
266
267def generate_from_file(
268    datapath: Pathish, outpath: Pathish | None = None, recursive: bool = True
269):
270    """Generate a `dataclass` named after the file `datapath` points at.
271
272    If `outpath` is not given, the output file will be the same as `datapath`, but with a `.py` extension.
273
274    Can be any `.toml` or `.json` file where all keys are valid Python variable names.
275
276    If `recursive` is `True`, dictionary values will be converted to dataclasses.
277    """
278
279    datapath = Pathier(datapath)
280    if outpath:
281        outpath = Pathier(outpath)
282    else:
283        outpath = datapath.with_suffix(".py")
284    module = ast.parse(outpath.read_text()) if outpath.exists() else None
285    data = datapath.loads()
286    fool = TomFoolery(module, recursive)  # type: ignore
287    source = fool.generate(datapath.stem, data)
288    source = source.replace("filepath", datapath.name)
289    try:
290        source = fool.format_str(source)
291    except Exception as e:
292        print("Unable to format output.")
293    outpath.write_text(source)
class TomFoolery:
 14class TomFoolery:
 15    def __init__(self, module: ast.Module | None = None, recursive: bool = True):
 16        """If no `module` is given, an empty new one will be created.
 17
 18        When generating a `dataclass` from a dictionary,
 19        if `recursive` is `True` then values that are also dictionaries will have a `dataclass` generated.
 20
 21        The annotation for that field in the original `dataclass` will be typed as an instance of the second `dataclass`.
 22
 23        If `recursive` is `False`, values that are dictionaries will be typed as such.
 24
 25        i.e.
 26        from a file named "chonker.toml"
 27        >>> {
 28        >>>  "name": "yeehaw",
 29        >>>  "stats": {
 30        >>>      "average": 77.54,
 31        >>>      "max": 94.22,
 32        >>>      "min": 22.76
 33        >>>  }
 34        >>> }
 35
 36        With recursive == True
 37        >>> @dataclass
 38        >>> class Stats:
 39        >>>     average: float
 40        >>>     max: float
 41        >>>     min: float
 42        >>>
 43        >>> @dataclass
 44        >>> class Chonker:
 45        >>>     name: str
 46        >>>     stats: Stats
 47
 48        With recursive == False
 49        >>> @dataclass
 50        >>> class Chonker:
 51        >>>     name: str
 52        >>>     stats: dict
 53        """
 54        self.module: ast.Module = module or ast.Module([], [])
 55        self.recursive = recursive
 56
 57    @property
 58    def class_names(self) -> list[str]:
 59        """List of class names in `self.module.body`."""
 60        return [node.name for node in self.module.body if type(node) == ast.ClassDef]
 61
 62    @property
 63    def source(self) -> str:
 64        """Returns the source code this object represents."""
 65        try:
 66            return self.format_str(ast.unparse(self.module))
 67        except Exception as e:
 68            return ast.unparse(self.module)
 69
 70    def format_str(self, code: str) -> str:
 71        """Sort imports and format with `black`."""
 72        return black.format_str(isort.api.sort_code_string(code), mode=black.Mode())  # type: ignore
 73
 74    # Seat |===================================== Import Nodes =====================================|
 75
 76    @property
 77    def dacite_import_node(self) -> ast.Import:
 78        return ast.Import([ast.alias("dacite")])
 79
 80    @property
 81    def dataclass_import_node(self) -> ast.ImportFrom:
 82        return ast.ImportFrom(
 83            "dataclasses", [ast.alias("dataclass"), ast.alias("asdict")], 0
 84        )
 85
 86    @property
 87    def import_nodes(self) -> list[ast.Import | ast.ImportFrom]:
 88        return [
 89            self.dacite_import_node,
 90            self.dataclass_import_node,
 91            self.pathier_import_node,
 92            self.typing_extensions_import_node,
 93        ]
 94
 95    @property
 96    def pathier_import_node(self) -> ast.ImportFrom:
 97        return ast.ImportFrom(
 98            "pathier", [ast.alias("Pathier"), ast.alias("Pathish")], 0
 99        )
100
101    @property
102    def typing_extensions_import_node(self) -> ast.ImportFrom:
103        return ast.ImportFrom("typing_extensions", [ast.alias("Self")])
104
105    # Seat |======================================== Nodes ========================================|
106
107    @property
108    def dataclass_node(self) -> ast.Name:
109        """A node representing `@dataclass`."""
110        return ast.Name("dataclass", ast.Load())
111
112    @property
113    def dump_node(self) -> ast.FunctionDef:
114        """The dumping function for the generated `dataclass`."""
115        dump = self.nodes_from_file(root / "_dump.py")[0]
116        return dump if isinstance(dump, ast.FunctionDef) else ast.FunctionDef()
117
118    @property
119    def load_node(self) -> ast.FunctionDef:
120        """The loading function for the generated `dataclass`."""
121        load = self.nodes_from_file(root / "_load.py")[0]
122        return load if isinstance(load, ast.FunctionDef) else ast.FunctionDef()
123
124    def add_dataclass(self, dataclass: ast.ClassDef):
125        """Add or merge `dataclass` into `self.module.body`."""
126        if dataclass.name not in self.class_names:
127            self.module.body.append(dataclass)
128        else:
129            classdex = self.class_index(dataclass.name)
130            self.module.body[classdex] = self.merge_dataclasses(self.module.body[classdex], dataclass)  # type: ignore
131
132    def class_index(self, class_name: str) -> int:
133        """Return the `self.module.body` index for a class with `class_name`."""
134        for i, node in enumerate(self.module.body):
135            if isinstance(node, ast.ClassDef) and node.name == class_name:
136                return i
137        return len(self.module.body)
138
139    def fix_order(self):
140        """Reorder `self.module.body` so that definitions preceede instances.
141
142        i.e. A newly added class is defined before another class creates an instance."""
143        new_body = []
144        for node in self.module.body:
145            if isinstance(node, ast.ClassDef):
146                placed = False
147                for i, new_node in enumerate(new_body):
148                    if node.name in ast.unparse(new_node):
149                        new_body.insert(i, node)
150                        placed = True
151                        break
152                if not placed:
153                    new_body.append(node)
154            else:
155                new_body.append(node)
156        self.module.body = new_body
157
158    def last_annassign_index(self, node: ast.ClassDef) -> int:
159        """Return the `node.body` index of the last annotated assignment node.
160        Assumes all annotated assignments are sequential and the first elements of `node`.
161        """
162        for i, child in enumerate(node.body):
163            if not isinstance(child, ast.AnnAssign):
164                return i - 1
165        return len(node.body)
166
167    def merge_dataclasses(
168        self, class1: ast.ClassDef, class2: ast.ClassDef
169    ) -> ast.ClassDef:
170        """Add annotated assignments and functions from `class2` to `class1` and return the result."""
171        funcs = [node.name for node in class1.body if isinstance(node, ast.FunctionDef)]
172        assigns = [
173            node.target.id
174            for node in class1.body
175            if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name)
176        ]
177        for node in class2.body:
178            if isinstance(node, ast.FunctionDef) and node.name not in funcs:
179                class1.body.append(node)
180            elif (
181                isinstance(node, ast.AnnAssign)
182                and isinstance(node.target, ast.Name)
183                and (node.target.id not in assigns)
184            ):
185                class1.body.insert(self.last_annassign_index(class1) + 1, node)
186        return class1
187
188    def nodes_from_file(self, file: Pathish) -> list[ast.stmt]:
189        """Return ast-parsed module body from `file`."""
190        node = ast.parse(Pathier(file).read_text())
191        return node.body if isinstance(node, ast.Module) else []
192
193    # Seat |======================================= Builders =======================================|
194
195    def annotated_assignments_from_dict(
196        self, data: dict[str, Any]
197    ) -> list[ast.AnnAssign]:
198        """Return a list of annotated assignment nodes built from `data`.
199
200        If `recursive` is `True` (the default),
201        any values in `data` that are themselves a dictionary,
202        will have a `dataclass` built and inserted in `self.classes`.
203
204        The field for that value will be annotated as an instance of that secondary `dataclass`.
205        """
206        assigns = []
207        for key, val in data.items():
208            if self.recursive and isinstance(val, dict):
209                dataclass = self.build_dataclass(key, val)
210                self.add_dataclass(dataclass)
211                assigns.append(
212                    self.build_annotated_assignment(
213                        key, utilities.key_to_classname(key), False
214                    )
215                )
216            else:
217                assigns.append(self.build_annotated_assignment(key, val))
218        return assigns
219
220    def build_annotated_assignment(
221        self, name: str, val: Any, evaluate_type: bool = True
222    ) -> ast.AnnAssign:
223        """Return an annotated assignment node with `name` and an annotation based on the type of `val`.
224
225        If `evaluate_type` is `False`, then `val` will be used directly as the type annotation instead of `type(val).__name__`.
226        """
227        return ast.AnnAssign(
228            ast.Name(name, ast.Store()),
229            ast.Name(utilities.build_type(val) if evaluate_type else val, ast.Load()),
230            None,
231            1,
232        )
233
234    def build_dataclass(
235        self, name: str, data: dict[str, Any], add_methods: bool = False
236    ) -> ast.ClassDef:
237        """Build a `dataclass` with `name` from `data` and insert it into `self.classes`.
238
239        If `add_methods` is `True`, `load()` and `dump()` functions will be added to the class.
240        """
241        class_ = ast.ClassDef(
242            utilities.key_to_classname(name),
243            [],
244            [],
245            self.annotated_assignments_from_dict(data),
246            [self.dataclass_node],
247        )
248        if add_methods:
249            class_.body.extend([self.load_node, self.dump_node])
250        return class_
251
252    # Seat |======================================== Main ========================================|
253
254    def generate(self, name: str, data: dict[str, Any]) -> str:
255        """Generate a `dataclass` with `name` from `data` and return the source code.
256
257        Currently, all keys in `data` and any of its nested dictionaries must be valid Python variable names.
258        """
259        for node in self.import_nodes:
260            if node not in self.module.body:
261                self.module.body.insert(0, node)
262        dataclass = self.build_dataclass(name, data, True)
263        self.add_dataclass(dataclass)
264        self.fix_order()
265        return self.source
TomFoolery(module: ast.Module | None = None, recursive: bool = True)
15    def __init__(self, module: ast.Module | None = None, recursive: bool = True):
16        """If no `module` is given, an empty new one will be created.
17
18        When generating a `dataclass` from a dictionary,
19        if `recursive` is `True` then values that are also dictionaries will have a `dataclass` generated.
20
21        The annotation for that field in the original `dataclass` will be typed as an instance of the second `dataclass`.
22
23        If `recursive` is `False`, values that are dictionaries will be typed as such.
24
25        i.e.
26        from a file named "chonker.toml"
27        >>> {
28        >>>  "name": "yeehaw",
29        >>>  "stats": {
30        >>>      "average": 77.54,
31        >>>      "max": 94.22,
32        >>>      "min": 22.76
33        >>>  }
34        >>> }
35
36        With recursive == True
37        >>> @dataclass
38        >>> class Stats:
39        >>>     average: float
40        >>>     max: float
41        >>>     min: float
42        >>>
43        >>> @dataclass
44        >>> class Chonker:
45        >>>     name: str
46        >>>     stats: Stats
47
48        With recursive == False
49        >>> @dataclass
50        >>> class Chonker:
51        >>>     name: str
52        >>>     stats: dict
53        """
54        self.module: ast.Module = module or ast.Module([], [])
55        self.recursive = recursive

If no module is given, an empty new one will be created.

When generating a dataclass from a dictionary, if recursive is True then values that are also dictionaries will have a dataclass generated.

The annotation for that field in the original dataclass will be typed as an instance of the second dataclass.

If recursive is False, values that are dictionaries will be typed as such.

i.e. from a file named "chonker.toml"

>>> {
>>>  "name": "yeehaw",
>>>  "stats": {
>>>      "average": 77.54,
>>>      "max": 94.22,
>>>      "min": 22.76
>>>  }
>>> }

With recursive == True

>>> @dataclass
>>> class Stats:
>>>     average: float
>>>     max: float
>>>     min: float
>>>
>>> @dataclass
>>> class Chonker:
>>>     name: str
>>>     stats: Stats

With recursive == False

>>> @dataclass
>>> class Chonker:
>>>     name: str
>>>     stats: dict
class_names: list[str]

List of class names in self.module.body.

source: str

Returns the source code this object represents.

def format_str(self, code: str) -> str:
70    def format_str(self, code: str) -> str:
71        """Sort imports and format with `black`."""
72        return black.format_str(isort.api.sort_code_string(code), mode=black.Mode())  # type: ignore

Sort imports and format with black.

dataclass_node: ast.Name

A node representing @dataclass.

dump_node: ast.FunctionDef

The dumping function for the generated dataclass.

load_node: ast.FunctionDef

The loading function for the generated dataclass.

def add_dataclass(self, dataclass: ast.ClassDef):
124    def add_dataclass(self, dataclass: ast.ClassDef):
125        """Add or merge `dataclass` into `self.module.body`."""
126        if dataclass.name not in self.class_names:
127            self.module.body.append(dataclass)
128        else:
129            classdex = self.class_index(dataclass.name)
130            self.module.body[classdex] = self.merge_dataclasses(self.module.body[classdex], dataclass)  # type: ignore

Add or merge dataclass into self.module.body.

def class_index(self, class_name: str) -> int:
132    def class_index(self, class_name: str) -> int:
133        """Return the `self.module.body` index for a class with `class_name`."""
134        for i, node in enumerate(self.module.body):
135            if isinstance(node, ast.ClassDef) and node.name == class_name:
136                return i
137        return len(self.module.body)

Return the self.module.body index for a class with class_name.

def fix_order(self):
139    def fix_order(self):
140        """Reorder `self.module.body` so that definitions preceede instances.
141
142        i.e. A newly added class is defined before another class creates an instance."""
143        new_body = []
144        for node in self.module.body:
145            if isinstance(node, ast.ClassDef):
146                placed = False
147                for i, new_node in enumerate(new_body):
148                    if node.name in ast.unparse(new_node):
149                        new_body.insert(i, node)
150                        placed = True
151                        break
152                if not placed:
153                    new_body.append(node)
154            else:
155                new_body.append(node)
156        self.module.body = new_body

Reorder self.module.body so that definitions preceede instances.

i.e. A newly added class is defined before another class creates an instance.

def last_annassign_index(self, node: ast.ClassDef) -> int:
158    def last_annassign_index(self, node: ast.ClassDef) -> int:
159        """Return the `node.body` index of the last annotated assignment node.
160        Assumes all annotated assignments are sequential and the first elements of `node`.
161        """
162        for i, child in enumerate(node.body):
163            if not isinstance(child, ast.AnnAssign):
164                return i - 1
165        return len(node.body)

Return the node.body index of the last annotated assignment node. Assumes all annotated assignments are sequential and the first elements of node.

def merge_dataclasses(self, class1: ast.ClassDef, class2: ast.ClassDef) -> ast.ClassDef:
167    def merge_dataclasses(
168        self, class1: ast.ClassDef, class2: ast.ClassDef
169    ) -> ast.ClassDef:
170        """Add annotated assignments and functions from `class2` to `class1` and return the result."""
171        funcs = [node.name for node in class1.body if isinstance(node, ast.FunctionDef)]
172        assigns = [
173            node.target.id
174            for node in class1.body
175            if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name)
176        ]
177        for node in class2.body:
178            if isinstance(node, ast.FunctionDef) and node.name not in funcs:
179                class1.body.append(node)
180            elif (
181                isinstance(node, ast.AnnAssign)
182                and isinstance(node.target, ast.Name)
183                and (node.target.id not in assigns)
184            ):
185                class1.body.insert(self.last_annassign_index(class1) + 1, node)
186        return class1

Add annotated assignments and functions from class2 to class1 and return the result.

def nodes_from_file( self, file: pathier.pathier.Pathier | pathlib.Path | str) -> list[ast.stmt]:
188    def nodes_from_file(self, file: Pathish) -> list[ast.stmt]:
189        """Return ast-parsed module body from `file`."""
190        node = ast.parse(Pathier(file).read_text())
191        return node.body if isinstance(node, ast.Module) else []

Return ast-parsed module body from file.

def annotated_assignments_from_dict(self, data: dict[str, typing.Any]) -> list[ast.AnnAssign]:
195    def annotated_assignments_from_dict(
196        self, data: dict[str, Any]
197    ) -> list[ast.AnnAssign]:
198        """Return a list of annotated assignment nodes built from `data`.
199
200        If `recursive` is `True` (the default),
201        any values in `data` that are themselves a dictionary,
202        will have a `dataclass` built and inserted in `self.classes`.
203
204        The field for that value will be annotated as an instance of that secondary `dataclass`.
205        """
206        assigns = []
207        for key, val in data.items():
208            if self.recursive and isinstance(val, dict):
209                dataclass = self.build_dataclass(key, val)
210                self.add_dataclass(dataclass)
211                assigns.append(
212                    self.build_annotated_assignment(
213                        key, utilities.key_to_classname(key), False
214                    )
215                )
216            else:
217                assigns.append(self.build_annotated_assignment(key, val))
218        return assigns

Return a list of annotated assignment nodes built from data.

If recursive is True (the default), any values in data that are themselves a dictionary, will have a dataclass built and inserted in self.classes.

The field for that value will be annotated as an instance of that secondary dataclass.

def build_annotated_assignment(self, name: str, val: Any, evaluate_type: bool = True) -> ast.AnnAssign:
220    def build_annotated_assignment(
221        self, name: str, val: Any, evaluate_type: bool = True
222    ) -> ast.AnnAssign:
223        """Return an annotated assignment node with `name` and an annotation based on the type of `val`.
224
225        If `evaluate_type` is `False`, then `val` will be used directly as the type annotation instead of `type(val).__name__`.
226        """
227        return ast.AnnAssign(
228            ast.Name(name, ast.Store()),
229            ast.Name(utilities.build_type(val) if evaluate_type else val, ast.Load()),
230            None,
231            1,
232        )

Return an annotated assignment node with name and an annotation based on the type of val.

If evaluate_type is False, then val will be used directly as the type annotation instead of type(val).__name__.

def build_dataclass( self, name: str, data: dict[str, typing.Any], add_methods: bool = False) -> ast.ClassDef:
234    def build_dataclass(
235        self, name: str, data: dict[str, Any], add_methods: bool = False
236    ) -> ast.ClassDef:
237        """Build a `dataclass` with `name` from `data` and insert it into `self.classes`.
238
239        If `add_methods` is `True`, `load()` and `dump()` functions will be added to the class.
240        """
241        class_ = ast.ClassDef(
242            utilities.key_to_classname(name),
243            [],
244            [],
245            self.annotated_assignments_from_dict(data),
246            [self.dataclass_node],
247        )
248        if add_methods:
249            class_.body.extend([self.load_node, self.dump_node])
250        return class_

Build a dataclass with name from data and insert it into self.classes.

If add_methods is True, load() and dump() functions will be added to the class.

def generate(self, name: str, data: dict[str, typing.Any]) -> str:
254    def generate(self, name: str, data: dict[str, Any]) -> str:
255        """Generate a `dataclass` with `name` from `data` and return the source code.
256
257        Currently, all keys in `data` and any of its nested dictionaries must be valid Python variable names.
258        """
259        for node in self.import_nodes:
260            if node not in self.module.body:
261                self.module.body.insert(0, node)
262        dataclass = self.build_dataclass(name, data, True)
263        self.add_dataclass(dataclass)
264        self.fix_order()
265        return self.source

Generate a dataclass with name from data and return the source code.

Currently, all keys in data and any of its nested dictionaries must be valid Python variable names.

def generate_from_file( datapath: pathier.pathier.Pathier | pathlib.Path | str, outpath: pathier.pathier.Pathier | pathlib.Path | str | None = None, recursive: bool = True):
268def generate_from_file(
269    datapath: Pathish, outpath: Pathish | None = None, recursive: bool = True
270):
271    """Generate a `dataclass` named after the file `datapath` points at.
272
273    If `outpath` is not given, the output file will be the same as `datapath`, but with a `.py` extension.
274
275    Can be any `.toml` or `.json` file where all keys are valid Python variable names.
276
277    If `recursive` is `True`, dictionary values will be converted to dataclasses.
278    """
279
280    datapath = Pathier(datapath)
281    if outpath:
282        outpath = Pathier(outpath)
283    else:
284        outpath = datapath.with_suffix(".py")
285    module = ast.parse(outpath.read_text()) if outpath.exists() else None
286    data = datapath.loads()
287    fool = TomFoolery(module, recursive)  # type: ignore
288    source = fool.generate(datapath.stem, data)
289    source = source.replace("filepath", datapath.name)
290    try:
291        source = fool.format_str(source)
292    except Exception as e:
293        print("Unable to format output.")
294    outpath.write_text(source)

Generate a dataclass named after the file datapath points at.

If outpath is not given, the output file will be the same as datapath, but with a .py extension.

Can be any .toml or .json file where all keys are valid Python variable names.

If recursive is True, dictionary values will be converted to dataclasses.