modflow-devtools 1.7.0__tar.gz → 1.9.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.
Files changed (46) hide show
  1. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/PKG-INFO +17 -7
  2. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/README.md +3 -4
  3. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/__init__.py +2 -2
  4. modflow_devtools-1.9.0/modflow_devtools/cli.py +63 -0
  5. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/dfn.py +61 -49
  6. modflow_devtools-1.9.0/modflow_devtools/dfn2toml.py +126 -0
  7. modflow_devtools-1.9.0/modflow_devtools/dfns/__init__.py +931 -0
  8. modflow_devtools-1.9.0/modflow_devtools/dfns/__main__.py +268 -0
  9. modflow_devtools-1.9.0/modflow_devtools/dfns/dfns.toml +24 -0
  10. modflow_devtools-1.9.0/modflow_devtools/dfns/fetch.py +25 -0
  11. modflow_devtools-1.9.0/modflow_devtools/dfns/make_registry.py +184 -0
  12. modflow_devtools-1.9.0/modflow_devtools/dfns/parse.py +212 -0
  13. modflow_devtools-1.9.0/modflow_devtools/dfns/registry.py +789 -0
  14. modflow_devtools-1.9.0/modflow_devtools/dfns/schema/block.py +22 -0
  15. modflow_devtools-1.9.0/modflow_devtools/dfns/schema/field.py +21 -0
  16. modflow_devtools-1.9.0/modflow_devtools/dfns/schema/ref.py +13 -0
  17. modflow_devtools-1.9.0/modflow_devtools/dfns/schema/v1.py +60 -0
  18. modflow_devtools-1.9.0/modflow_devtools/dfns/schema/v2.py +32 -0
  19. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/download.py +31 -24
  20. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/fixtures.py +5 -15
  21. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/markers.py +4 -10
  22. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/misc.py +33 -21
  23. modflow_devtools-1.9.0/modflow_devtools/models/__init__.py +1382 -0
  24. modflow_devtools-1.9.0/modflow_devtools/models/__main__.py +417 -0
  25. modflow_devtools-1.9.0/modflow_devtools/models/make_registry.py +392 -0
  26. modflow_devtools-1.9.0/modflow_devtools/models/models.toml +34 -0
  27. modflow_devtools-1.9.0/modflow_devtools/programs/__init__.py +1897 -0
  28. modflow_devtools-1.9.0/modflow_devtools/programs/__main__.py +418 -0
  29. modflow_devtools-1.9.0/modflow_devtools/programs/make_registry.py +521 -0
  30. modflow_devtools-1.9.0/modflow_devtools/programs/programs.csv +26 -0
  31. modflow_devtools-1.9.0/modflow_devtools/programs/programs.toml +54 -0
  32. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/snapshots.py +3 -15
  33. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/zip.py +1 -3
  34. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/pyproject.toml +26 -4
  35. modflow_devtools-1.7.0/modflow_devtools/dfn2toml.py +0 -46
  36. modflow_devtools-1.7.0/modflow_devtools/make_registry.py +0 -89
  37. modflow_devtools-1.7.0/modflow_devtools/models.py +0 -522
  38. modflow_devtools-1.7.0/modflow_devtools/registry/examples.toml +0 -382
  39. modflow_devtools-1.7.0/modflow_devtools/registry/models.toml +0 -9485
  40. modflow_devtools-1.7.0/modflow_devtools/registry/registry.toml +0 -31890
  41. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/.gitignore +0 -0
  42. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/LICENSE.md +0 -0
  43. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/build.py +0 -0
  44. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/imports.py +0 -0
  45. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/latex.py +0 -0
  46. {modflow_devtools-1.7.0 → modflow_devtools-1.9.0}/modflow_devtools/ostags.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modflow-devtools
3
- Version: 1.7.0
3
+ Version: 1.9.0
4
4
  Summary: Python tools for MODFLOW development
5
5
  Project-URL: Documentation, https://modflow-devtools.readthedocs.io/en/latest/
6
6
  Project-URL: Bug Tracker, https://github.com/MODFLOW-ORG/modflow-devtools/issues
@@ -37,6 +37,7 @@ Requires-Dist: ninja; extra == 'dev'
37
37
  Requires-Dist: numpy; extra == 'dev'
38
38
  Requires-Dist: pandas; extra == 'dev'
39
39
  Requires-Dist: pooch; extra == 'dev'
40
+ Requires-Dist: pydantic; extra == 'dev'
40
41
  Requires-Dist: pytest!=8.1.0; extra == 'dev'
41
42
  Requires-Dist: pytest-cov; extra == 'dev'
42
43
  Requires-Dist: pytest-dotenv; extra == 'dev'
@@ -45,17 +46,26 @@ Requires-Dist: pyyaml; extra == 'dev'
45
46
  Requires-Dist: ruff; extra == 'dev'
46
47
  Requires-Dist: sphinx; extra == 'dev'
47
48
  Requires-Dist: sphinx-rtd-theme; extra == 'dev'
48
- Requires-Dist: syrupy; extra == 'dev'
49
+ Requires-Dist: syrupy<5.0.0; extra == 'dev'
49
50
  Requires-Dist: tomli; extra == 'dev'
50
51
  Requires-Dist: tomli-w; extra == 'dev'
51
52
  Provides-Extra: dfn
52
53
  Requires-Dist: boltons; extra == 'dfn'
54
+ Requires-Dist: pooch; extra == 'dfn'
55
+ Requires-Dist: pydantic; extra == 'dfn'
53
56
  Requires-Dist: tomli; extra == 'dfn'
54
57
  Requires-Dist: tomli-w; extra == 'dfn'
55
58
  Provides-Extra: docs
56
59
  Requires-Dist: myst-parser; extra == 'docs'
57
60
  Requires-Dist: sphinx; extra == 'docs'
58
61
  Requires-Dist: sphinx-rtd-theme; extra == 'docs'
62
+ Provides-Extra: ecosystem
63
+ Requires-Dist: boltons; extra == 'ecosystem'
64
+ Requires-Dist: filelock; extra == 'ecosystem'
65
+ Requires-Dist: pooch; extra == 'ecosystem'
66
+ Requires-Dist: pydantic; extra == 'ecosystem'
67
+ Requires-Dist: tomli; extra == 'ecosystem'
68
+ Requires-Dist: tomli-w; extra == 'ecosystem'
59
69
  Provides-Extra: lint
60
70
  Requires-Dist: codespell[toml]; extra == 'lint'
61
71
  Requires-Dist: mypy; extra == 'lint'
@@ -64,6 +74,7 @@ Provides-Extra: models
64
74
  Requires-Dist: boltons; extra == 'models'
65
75
  Requires-Dist: filelock; extra == 'models'
66
76
  Requires-Dist: pooch; extra == 'models'
77
+ Requires-Dist: pydantic; extra == 'models'
67
78
  Requires-Dist: tomli; extra == 'models'
68
79
  Requires-Dist: tomli-w; extra == 'models'
69
80
  Provides-Extra: test
@@ -82,7 +93,7 @@ Requires-Dist: pytest-dotenv; extra == 'test'
82
93
  Requires-Dist: pytest-xdist; extra == 'test'
83
94
  Requires-Dist: pyyaml; extra == 'test'
84
95
  Requires-Dist: ruff; extra == 'test'
85
- Requires-Dist: syrupy; extra == 'test'
96
+ Requires-Dist: syrupy<5.0.0; extra == 'test'
86
97
  Description-Content-Type: text/markdown
87
98
 
88
99
  # MODFLOW developer tools
@@ -130,11 +141,10 @@ Python development tools for MODFLOW 6 and related projects.
130
141
 
131
142
  Python3.10+, dependency-free by default.
132
143
 
133
- Several optional dependency groups are available, oriented around specific use cases:
144
+ Two main dependency groups are available, oriented around specific use cases:
134
145
 
135
- - `dfn`: MF6 definition file parsing
136
- - `test`: pytest fixtures/extensions
137
- - `models`: example model access
146
+ - `test`: pytest fixtures, markers, and extensions
147
+ - `ecosystem`: program/model management, definition file utilities
138
148
 
139
149
  ## Installation
140
150
 
@@ -43,11 +43,10 @@ Python development tools for MODFLOW 6 and related projects.
43
43
 
44
44
  Python3.10+, dependency-free by default.
45
45
 
46
- Several optional dependency groups are available, oriented around specific use cases:
46
+ Two main dependency groups are available, oriented around specific use cases:
47
47
 
48
- - `dfn`: MF6 definition file parsing
49
- - `test`: pytest fixtures/extensions
50
- - `models`: example model access
48
+ - `test`: pytest fixtures, markers, and extensions
49
+ - `ecosystem`: program/model management, definition file utilities
51
50
 
52
51
  ## Installation
53
52
 
@@ -1,6 +1,6 @@
1
1
  __author__ = "Joseph D. Hughes"
2
- __date__ = "Jun 23, 2025"
3
- __version__ = "1.7.0"
2
+ __date__ = "Feb 25, 2026"
3
+ __version__ = "1.9.0"
4
4
  __maintainer__ = "Joseph D. Hughes"
5
5
  __email__ = "jdhughes@usgs.gov"
6
6
  __status__ = "Production"
@@ -0,0 +1,63 @@
1
+ """
2
+ Root CLI for modflow-devtools.
3
+
4
+ Usage:
5
+ mf models sync
6
+ mf models info
7
+ mf models list
8
+ mf models copy <model> <workspace>
9
+ mf models cp <model> <workspace> # cp is an alias for copy
10
+ mf programs sync
11
+ mf programs info
12
+ mf programs list
13
+ mf programs install <program>
14
+ mf programs uninstall <program>
15
+ mf programs history
16
+ """
17
+
18
+ import argparse
19
+ import sys
20
+
21
+
22
+ def main():
23
+ """Main entry point for the mf CLI."""
24
+ parser = argparse.ArgumentParser(
25
+ prog="mf",
26
+ description="MODFLOW development tools",
27
+ )
28
+ subparsers = parser.add_subparsers(dest="subcommand", help="Available commands")
29
+
30
+ # Models subcommand
31
+ subparsers.add_parser("models", help="Manage MODFLOW model registries")
32
+
33
+ # Programs subcommand
34
+ subparsers.add_parser("programs", help="Manage MODFLOW program registries")
35
+
36
+ # Parse only the first level to determine which submodule to invoke
37
+ args, remaining = parser.parse_known_args()
38
+
39
+ if not args.subcommand:
40
+ parser.print_help()
41
+ sys.exit(1)
42
+
43
+ # Dispatch to the appropriate module CLI with remaining args
44
+ if args.subcommand == "models":
45
+ from modflow_devtools.models.__main__ import main as models_main
46
+
47
+ # Replace sys.argv to make it look like we called the submodule directly
48
+ sys.argv = ["mf models", *remaining]
49
+ models_main()
50
+ elif args.subcommand == "programs":
51
+ import warnings
52
+
53
+ # Suppress experimental warning for official CLI
54
+ with warnings.catch_warnings():
55
+ warnings.filterwarnings("ignore", message=".*modflow_devtools.programs.*experimental.*")
56
+ from modflow_devtools.programs.__main__ import main as programs_main
57
+
58
+ sys.argv = ["mf programs", *remaining]
59
+ programs_main()
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
@@ -185,6 +185,29 @@ class Dfn(TypedDict):
185
185
  `str` to `Field`, and metadata, of which only a
186
186
  limited set of keys are allowed. Block names and
187
187
  metadata keys may not overlap.
188
+
189
+ Attributes
190
+ ----------
191
+ name : str
192
+ Component name.
193
+ advanced : bool
194
+ Whether this is an advanced package.
195
+ multi : bool
196
+ Whether this is a multi-package.
197
+ ref : Ref | None
198
+ Metadata if this component is a subpackage (child's perspective).
199
+ Populated from: # flopy subpackage <key> <abbr> <param> <val>
200
+ sln : Sln | None
201
+ Solution package metadata.
202
+ fkeys : Dfns | None
203
+ Field-level foreign key references to other components.
204
+ Maps field names to Ref objects. Populated from flopy subpackage
205
+ metadata when specific fields reference other components.
206
+ subcomponents : list[str] | None
207
+ Allowed child component types (schema-level constraint).
208
+ Populated from: # mf6 subpackage <abbr>
209
+ Example: ['UTL-NCF'] means this component can have utl-ncf children.
210
+ Distinct from fkeys, which are field-level references.
188
211
  """
189
212
 
190
213
  name: str
@@ -193,6 +216,7 @@ class Dfn(TypedDict):
193
216
  ref: Ref | None = None
194
217
  sln: Sln | None = None
195
218
  fkeys: Dfns | None = None
219
+ subcomponents: list[str] | None = None
196
220
 
197
221
  @staticmethod
198
222
  def _load_v1_flat(f, common: dict | None = None) -> tuple[Mapping, list[str]]:
@@ -220,6 +244,11 @@ class Dfn(TypedDict):
220
244
  _, sep, tail = line.partition("package-type")
221
245
  if sep == "package-type":
222
246
  meta.append(f"package-type {tail.strip()}")
247
+ # Parse mf6 subpackage declarations (schema-level composition constraints).
248
+ # Distinct from flopy subpackage (field-level foreign keys, parsed above).
249
+ _, sep, tail = line.partition("mf6 subpackage")
250
+ if sep == "mf6 subpackage":
251
+ meta.append(f"mf6-subpackage {tail.strip()}")
223
252
  continue
224
253
 
225
254
  # if we hit a newline and the parameter dict
@@ -249,16 +278,11 @@ class Dfn(TypedDict):
249
278
  subs = literal_eval(subs)
250
279
  cmmn = common.get(key, None)
251
280
  if cmmn is None:
252
- warn(
253
- "Can't substitute description text, "
254
- f"common variable not found: {key}"
255
- )
281
+ warn(f"Can't substitute description text, common variable not found: {key}")
256
282
  else:
257
283
  descr = cmmn.get("description", "")
258
284
  if any(subs):
259
- descr = descr.replace("\\", "").replace(
260
- "{#1}", subs["{#1}"]
261
- )
285
+ descr = descr.replace("\\", "").replace("{#1}", subs["{#1}"])
262
286
  field["description"] = descr
263
287
 
264
288
  # add the final parameter
@@ -331,8 +355,7 @@ class Dfn(TypedDict):
331
355
 
332
356
  # explicit record
333
357
  if n_item_names == 1 and (
334
- item_types[0].startswith("record")
335
- or item_types[0].startswith("keystring")
358
+ item_types[0].startswith("record") or item_types[0].startswith("keystring")
336
359
  ):
337
360
  return _convert_field(next(iter(flat.getlist(item_names[0]))))
338
361
 
@@ -343,9 +366,7 @@ class Dfn(TypedDict):
343
366
  type="record",
344
367
  block=block,
345
368
  fields=_fields(),
346
- description=description.replace(
347
- "is the list of", "is the record of"
348
- ),
369
+ description=description.replace("is the list of", "is the record of"),
349
370
  reader=reader,
350
371
  **field,
351
372
  )
@@ -358,19 +379,13 @@ class Dfn(TypedDict):
358
379
  }
359
380
  first = next(iter(fields.values()))
360
381
  single = len(fields) == 1
361
- item_type = (
362
- "keystring"
363
- if single and "keystring" in first["type"]
364
- else "record"
365
- )
382
+ item_type = "keystring" if single and "keystring" in first["type"] else "record"
366
383
  return Field(
367
384
  name=first["name"] if single else _name,
368
385
  type=item_type,
369
386
  block=block,
370
387
  fields=first["fields"] if single else fields,
371
- description=description.replace(
372
- "is the list of", f"is the {item_type} of"
373
- ),
388
+ description=description.replace("is the list of", f"is the {item_type} of"),
374
389
  reader=reader,
375
390
  **field,
376
391
  )
@@ -390,11 +405,7 @@ class Dfn(TypedDict):
390
405
  fields = {}
391
406
  for name in names:
392
407
  v = flat.get(name, None)
393
- if (
394
- not v
395
- or not v.get("in_record", False)
396
- or v["type"].startswith("record")
397
- ):
408
+ if not v or not v.get("in_record", False) or v["type"].startswith("record"):
398
409
  continue
399
410
  fields[name] = v
400
411
  return fields
@@ -490,11 +501,7 @@ class Dfn(TypedDict):
490
501
 
491
502
  def _sln() -> Sln | None:
492
503
  sln = next(
493
- iter(
494
- m
495
- for m in meta
496
- if isinstance(m, str) and m.startswith("solution_package")
497
- ),
504
+ iter(m for m in meta if isinstance(m, str) and m.startswith("solution_package")),
498
505
  None,
499
506
  )
500
507
  if sln:
@@ -505,9 +512,7 @@ class Dfn(TypedDict):
505
512
  def _sub() -> Ref | None:
506
513
  def _parent():
507
514
  line = next(
508
- iter(
509
- m for m in meta if isinstance(m, str) and m.startswith("parent")
510
- ),
515
+ iter(m for m in meta if isinstance(m, str) and m.startswith("parent")),
511
516
  None,
512
517
  )
513
518
  if not line:
@@ -517,9 +522,7 @@ class Dfn(TypedDict):
517
522
 
518
523
  def _rest():
519
524
  line = next(
520
- iter(
521
- m for m in meta if isinstance(m, str) and m.startswith("subpac")
522
- ),
525
+ iter(m for m in meta if isinstance(m, str) and m.startswith("subpac")),
523
526
  None,
524
527
  )
525
528
  if not line:
@@ -548,6 +551,22 @@ class Dfn(TypedDict):
548
551
  return Ref(parent=parent, **rest)
549
552
  return None
550
553
 
554
+ def _subcomponents() -> list[str] | None:
555
+ """
556
+ Extract allowed child component types from mf6 subpackage metadata.
557
+
558
+ This parses '# mf6 subpackage <abbr>' declarations to determine
559
+ schema-level composition constraints (which component types can be
560
+ children). Distinct from fkeys, which are field-level foreign keys
561
+ populated from '# flopy subpackage ...' declarations.
562
+ """
563
+ result = []
564
+ for m in meta:
565
+ if m.startswith("mf6-subpackage "):
566
+ abbr = m.removeprefix("mf6-subpackage ").strip().upper()
567
+ result.append(abbr)
568
+ return result if result else None
569
+
551
570
  return cls(
552
571
  name=name,
553
572
  fkeys=fkeys,
@@ -555,6 +574,7 @@ class Dfn(TypedDict):
555
574
  multi=_multi(),
556
575
  sln=_sln(),
557
576
  ref=_sub(),
577
+ subcomponents=_subcomponents(),
558
578
  **blocks,
559
579
  )
560
580
 
@@ -586,13 +606,11 @@ class Dfn(TypedDict):
586
606
 
587
607
  @staticmethod
588
608
  def _load_all_v1(dfndir: PathLike) -> Dfns:
589
- paths: list[Path] = [
590
- p for p in dfndir.glob("*.dfn") if p.stem not in ["common", "flopy"]
591
- ]
609
+ paths: list[Path] = [p for p in dfndir.glob("*.dfn") if p.stem not in ["common", "flopy"]]
592
610
 
593
611
  # load common variables
594
612
  common_path: Path | None = dfndir / "common.dfn"
595
- if not common_path.is_file:
613
+ if not common_path.is_file():
596
614
  common = None
597
615
  else:
598
616
  with common_path.open() as f:
@@ -618,9 +636,7 @@ class Dfn(TypedDict):
618
636
 
619
637
  @staticmethod
620
638
  def _load_all_v2(dfndir: PathLike) -> Dfns:
621
- paths: list[Path] = [
622
- p for p in dfndir.glob("*.toml") if p.stem not in ["common", "flopy"]
623
- ]
639
+ paths: list[Path] = [p for p in dfndir.glob("*.toml") if p.stem not in ["common", "flopy"]]
624
640
  dfns: Dfns = {}
625
641
  for path in paths:
626
642
  with path.open(mode="rb") as f:
@@ -640,9 +656,7 @@ class Dfn(TypedDict):
640
656
  raise ValueError(f"Unsupported version, expected one of {version.__args__}")
641
657
 
642
658
 
643
- def get_dfns(
644
- owner: str, repo: str, ref: str, outdir: str | PathLike, verbose: bool = False
645
- ):
659
+ def get_dfns(owner: str, repo: str, ref: str, outdir: str | PathLike, verbose: bool = False):
646
660
  """Fetch definition files from the MODFLOW 6 repository."""
647
661
  url = f"https://github.com/{owner}/{repo}/archive/{ref}.zip"
648
662
  if verbose:
@@ -655,6 +669,4 @@ def get_dfns(
655
669
  raise ValueError(f"Missing proj dir in {dl_path}, found {contents}")
656
670
  if verbose:
657
671
  print("Copying dfns from download dir to output dir")
658
- shutil.copytree(
659
- proj_path / "doc" / "mf6io" / "mf6ivar" / "dfn", outdir, dirs_exist_ok=True
660
- )
672
+ shutil.copytree(proj_path / "doc" / "mf6io" / "mf6ivar" / "dfn", outdir, dirs_exist_ok=True)
@@ -0,0 +1,126 @@
1
+ """Convert DFNs to TOML."""
2
+
3
+ import argparse
4
+ import sys
5
+ import textwrap
6
+ from dataclasses import asdict
7
+ from os import PathLike
8
+ from pathlib import Path
9
+
10
+ import tomli_w as tomli
11
+ from boltons.iterutils import remap
12
+
13
+ from modflow_devtools.dfns import Dfn, is_valid, load, load_flat, map, to_flat, to_tree
14
+ from modflow_devtools.dfns.schema.block import block_sort_key
15
+ from modflow_devtools.misc import drop_none_or_empty
16
+
17
+ # mypy: ignore-errors
18
+
19
+
20
+ def convert(inpath: PathLike, outdir: PathLike, schema_version: str = "2") -> None:
21
+ """
22
+ Convert DFN files in `inpath` to TOML files in `outdir`.
23
+ By default, convert the definitions to schema version 2.
24
+ """
25
+ inpath = Path(inpath).expanduser().absolute()
26
+ outdir = Path(outdir).expanduser().absolute()
27
+ outdir.mkdir(exist_ok=True, parents=True)
28
+
29
+ if inpath.is_file():
30
+ if inpath.name == "common.dfn":
31
+ raise ValueError("Cannot convert common.dfn as a standalone file")
32
+
33
+ common_path = inpath.parent / "common.dfn"
34
+ if common_path.exists():
35
+ with common_path.open() as f:
36
+ from modflow_devtools.dfn import parse_dfn
37
+
38
+ common, _ = parse_dfn(f)
39
+ else:
40
+ common = {}
41
+
42
+ with inpath.open() as f:
43
+ dfn = load(f, name=inpath.stem, common=common, format="dfn")
44
+
45
+ dfn = map(dfn, schema_version=schema_version)
46
+ _convert(dfn, outdir / f"{inpath.stem}.toml")
47
+ else:
48
+ dfns = {
49
+ name: map(dfn, schema_version=schema_version) for name, dfn in load_flat(inpath).items()
50
+ }
51
+ tree = to_tree(dfns)
52
+ flat = to_flat(tree)
53
+ for dfn_name, dfn in flat.items():
54
+ _convert(dfn, outdir / f"{dfn_name}.toml")
55
+
56
+
57
+ def _convert(dfn: Dfn, outpath: Path) -> None:
58
+ with Path.open(outpath, "wb") as f:
59
+ # TODO if we start using c/attrs, swap out
60
+ # all this for a custom unstructuring hook
61
+ dfn_dict = asdict(dfn)
62
+ dfn_dict["schema_version"] = str(dfn_dict["schema_version"])
63
+ if blocks := dfn_dict.pop("blocks", None):
64
+ for block_name, block_fields in blocks.items():
65
+ if block_name not in dfn_dict:
66
+ dfn_dict[block_name] = {}
67
+ for field_name, field_data in block_fields.items():
68
+ dfn_dict[block_name][field_name] = field_data
69
+
70
+ tomli.dump(
71
+ dict(
72
+ sorted(
73
+ remap(dfn_dict, visit=drop_none_or_empty).items(),
74
+ key=block_sort_key,
75
+ )
76
+ ),
77
+ f,
78
+ )
79
+
80
+
81
+ if __name__ == "__main__":
82
+ """
83
+ Convert DFN files in the original format and schema version 1
84
+ to TOML files, by default also converting to schema version 2.
85
+ """
86
+
87
+ parser = argparse.ArgumentParser(
88
+ description="Convert DFN files to TOML.",
89
+ epilog=textwrap.dedent(
90
+ """\
91
+ Convert DFN files in the original format and schema version 1
92
+ to TOML files, by default also converting to schema version 2.
93
+ """
94
+ ),
95
+ )
96
+ parser.add_argument(
97
+ "--indir",
98
+ "-i",
99
+ type=str,
100
+ help="Directory containing DFN files, or a single DFN file.",
101
+ )
102
+ parser.add_argument(
103
+ "--outdir",
104
+ "-o",
105
+ help="Output directory.",
106
+ )
107
+ parser.add_argument(
108
+ "--schema-version",
109
+ "-s",
110
+ type=str,
111
+ default="2",
112
+ help="Schema version to convert to.",
113
+ )
114
+ parser.add_argument(
115
+ "--validate",
116
+ "-v",
117
+ action="store_true",
118
+ help="Validate DFN files without converting them.",
119
+ )
120
+ args = parser.parse_args()
121
+
122
+ if args.validate:
123
+ if not is_valid(args.indir):
124
+ sys.exit(1)
125
+ else:
126
+ convert(args.indir, args.outdir, args.schema_version)