ghga-transpiler 2.3.2__py3-none-any.whl → 3.0.0rc1__py3-none-any.whl

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.
ghga_transpiler/cli.py CHANGED
@@ -16,15 +16,17 @@
16
16
  #
17
17
  """CLI-specific wrappers around core functions."""
18
18
 
19
+ from __future__ import annotations
20
+
19
21
  import sys
22
+ from enum import Enum
20
23
  from pathlib import Path
21
- from typing import Optional
24
+ from typing import Annotated
22
25
 
23
26
  import typer
24
27
 
25
- from . import __version__, io
26
- from .config.exceptions import UnknownVersionError
27
- from .core import InvalidSematicVersion, convert_workbook
28
+ from . import __version__, transpiler_io
29
+ from .transpile import transpile
28
30
 
29
31
  cli = typer.Typer()
30
32
 
@@ -36,44 +38,70 @@ def version_callback(value: bool):
36
38
  raise typer.Exit()
37
39
 
38
40
 
41
+ def format_callback(value: str):
42
+ """Validates the user input for format parameter"""
43
+ if value not in ["json", "yaml"]:
44
+ raise typer.BadParameter("Only 'json' or 'yaml' is allowed.")
45
+ return value
46
+
47
+
48
+ class Format(str, Enum):
49
+ """Enum class for output format types"""
50
+
51
+ json = "json"
52
+ yaml = "yaml"
53
+
54
+
39
55
  @cli.command()
40
- def transpile(
41
- spread_sheet: Path = typer.Argument(
42
- ...,
43
- exists=True,
44
- help="The path to input file (XLSX)",
45
- dir_okay=False,
46
- readable=True,
47
- ),
48
- output_file: Optional[Path] = typer.Argument(
49
- None, help="The path to output file (JSON).", dir_okay=False
50
- ),
51
- force: bool = typer.Option(
52
- False, "--force", "-f", help="Override output file if it exists."
53
- ),
54
- version: bool = typer.Option(
55
- False,
56
- "--version",
57
- "-v",
58
- callback=version_callback,
59
- is_eager=True,
60
- help="Print package version",
61
- ),
56
+ def main(
57
+ spread_sheet: Annotated[
58
+ Path,
59
+ typer.Argument(
60
+ exists=True,
61
+ help="The path to input file (XLSX)",
62
+ dir_okay=False,
63
+ readable=True,
64
+ ),
65
+ ],
66
+ output_file: Annotated[
67
+ Path | None,
68
+ typer.Argument(help="The path to output file (JSON).", dir_okay=False),
69
+ ] = None,
70
+ format: Annotated[
71
+ Format,
72
+ typer.Option(
73
+ "--format",
74
+ "-t",
75
+ help="Output format: 'json' or 'yaml'",
76
+ callback=format_callback,
77
+ is_eager=True,
78
+ ),
79
+ ] = Format.json,
80
+ force: Annotated[
81
+ bool, typer.Option("--force", "-f", help="Override output file if it exists.")
82
+ ] = False,
83
+ version: Annotated[
84
+ bool,
85
+ typer.Option(
86
+ "--version",
87
+ "-v",
88
+ callback=version_callback,
89
+ is_eager=True,
90
+ help="Print package version",
91
+ ),
92
+ ] = False,
62
93
  ):
63
94
  """ghga-transpiler is a command line utility to transpile the official GHGA
64
- metadata XLSX workbooks to JSON. Please note that ghga-transpiler does not
65
- validate that the provided metadata is compliant with the GHGA Metadata
66
- Schema. This can be achieved by running ghga-validator on the JSON data
67
- generated by the ghga-transpiler.
95
+ metadata XLSX workbooks to JSON. TODO Validation
68
96
  """
69
97
  try:
70
- ghga_workbook = io.read_workbook(spread_sheet)
71
- except (SyntaxError, UnknownVersionError, InvalidSematicVersion) as exc:
98
+ ghga_datapack = transpile(spread_sheet)
99
+ except SyntaxError as exc:
72
100
  sys.exit(f"Unable to parse input file '{spread_sheet}': {exc}")
73
-
74
- converted = convert_workbook(ghga_workbook)
75
-
101
+ yaml_format = format == "yaml"
76
102
  try:
77
- io.write_json(data=converted, path=output_file, force=force)
103
+ transpiler_io.write_datapack(
104
+ data=ghga_datapack, path=output_file, yaml_format=yaml_format, force=force
105
+ )
78
106
  except FileExistsError as exc:
79
107
  sys.exit(f"ERROR: {exc}")
@@ -0,0 +1,136 @@
1
+ # Copyright 2021 - 2025 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2
+ # for the German Human Genome-Phenome Archive (GHGA)
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ """Module to process config file"""
18
+
19
+ from collections import Counter
20
+ from collections.abc import Callable
21
+ from typing import NamedTuple
22
+
23
+ from pydantic import (
24
+ BaseModel,
25
+ ConfigDict,
26
+ Field,
27
+ model_validator,
28
+ )
29
+
30
+ from .exceptions import DuplicatedName
31
+ from .transformations import to_attributes, to_list, to_snake_case, to_snake_case_list
32
+
33
+
34
+ class RelationMeta(NamedTuple):
35
+ """A data model for relation properties of a column"""
36
+
37
+ name: str
38
+ target_class: str | None
39
+
40
+
41
+ class ColumnMeta(BaseModel):
42
+ """A data model for column properties"""
43
+
44
+ model_config = ConfigDict(populate_by_name=True, frozen=True)
45
+
46
+ sheet_name: str = Field(..., alias="sheet")
47
+ column_name: str = Field(..., alias="column")
48
+ multivalued: bool
49
+ type: str
50
+ ref_class: str | None
51
+ ref_id: str | None = Field(..., alias="ref_class_id_property")
52
+ enum: bool
53
+ required: bool
54
+
55
+ def transformation(self) -> Callable | None:
56
+ """Assigns transformation function based on column properties"""
57
+ if self.enum:
58
+ return to_snake_case_list() if self.multivalued else to_snake_case()
59
+ if self.multivalued:
60
+ return to_attributes() if self.type == "object" else to_list()
61
+ return lambda value: value
62
+
63
+ def is_relation(self) -> bool:
64
+ """Return whether this is a relation column"""
65
+ return bool(self.ref_class)
66
+
67
+
68
+ class SheetMeta(BaseModel):
69
+ """A data model for worksheet settings"""
70
+
71
+ model_config = ConfigDict(populate_by_name=True, frozen=True)
72
+
73
+ name: str = Field(..., validation_alias="sheet")
74
+ header_row: int
75
+ start_row: int = Field(..., validation_alias="data_start")
76
+ start_column: int = 1
77
+ end_column: int = Field(..., validation_alias="n_cols")
78
+ primary_key: str
79
+
80
+
81
+ class WorksheetSettings(BaseModel):
82
+ """A data model for a worksheet"""
83
+
84
+ model_config = ConfigDict(frozen=True)
85
+
86
+ settings: SheetMeta
87
+ columns: tuple[ColumnMeta, ...]
88
+
89
+ def get_transformations(self) -> dict:
90
+ """Merges the transformation of a worksheet"""
91
+ return {
92
+ column.column_name: column.transformation()
93
+ for column in self.columns
94
+ if column.transformation() is not None
95
+ }
96
+
97
+ def get_relations(self) -> list[RelationMeta]:
98
+ """Returns relations of a worksheet where column_name is considered as the
99
+ relation name and the ref_class as the relation's target class
100
+ """
101
+ return [
102
+ RelationMeta(column.column_name, column.ref_class)
103
+ for column in self.columns
104
+ if column.is_relation()
105
+ ]
106
+
107
+
108
+ class WorkbookConfig(BaseModel):
109
+ """A data model containing transpiler configurations"""
110
+
111
+ worksheets: dict[str, WorksheetSettings]
112
+
113
+ @model_validator(mode="after")
114
+ def check_name(cls, values): # noqa
115
+ """Ensure that each worksheet has a unique sheet_name and name attributes."""
116
+ # Check for duplicate worksheet names
117
+ ws_counter = Counter(values.worksheets.keys())
118
+ dup_ws_names = [name for name, count in ws_counter.items() if count > 1]
119
+ if dup_ws_names:
120
+ raise DuplicatedName(
121
+ "Duplicate worksheet names: " + ", ".join(dup_ws_names)
122
+ )
123
+
124
+ # Check for duplicate attribute names
125
+ attrs_counter = Counter(
126
+ f"{column.sheet_name}.{column.column_name}"
127
+ for ws in values.worksheets.values()
128
+ for column in ws.columns
129
+ )
130
+ dup_attrs = [name for name, count in attrs_counter.items() if count > 1]
131
+ if dup_attrs:
132
+ raise DuplicatedName(
133
+ "Duplicate target attribute names: " + ", ".join(dup_attrs)
134
+ )
135
+
136
+ return values
@@ -26,5 +26,17 @@ class MissingWorkbookContent(KeyError):
26
26
  """Raised when any worksheet given in the config yaml does not exist in the spreadsheet"""
27
27
 
28
28
 
29
- class UnknownVersionError(RuntimeError):
30
- """Raised when the version encountered in the workbook is unknown"""
29
+ class WorkbookNotFound(FileNotFoundError):
30
+ """Raised when path to the workbook file not found on a path."""
31
+
32
+
33
+ class MetaColumnNotFound(KeyError):
34
+ """Raised when the 'sheet' column holding the sheet names on the meta_sheets
35
+ (__column_meta, __sheet_meta) does not exist.
36
+ """
37
+
38
+
39
+ class MetaColumnNotUnique(ValueError):
40
+ """Raised when the 'sheet' column holding the sheet names on the meta_sheets
41
+ (__column_meta, __sheet_meta) is not unique.
42
+ """
@@ -0,0 +1,125 @@
1
+ # Copyright 2021 - 2025 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2
+ # for the German Human Genome-Phenome Archive (GHGA)
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Helper functions to parse the configuration sheets in a workbook"""
17
+
18
+ from collections import defaultdict
19
+
20
+ from openpyxl import Workbook
21
+ from pydantic import BaseModel, Field
22
+
23
+ from .config import WorkbookConfig
24
+ from .exceptions import MetaColumnNotFound, MetaColumnNotUnique
25
+
26
+
27
+ class MetaInfo(BaseModel):
28
+ """Class with constants that are required to parse the configuration worksheets
29
+ of a workbook.
30
+ """
31
+
32
+ column_meta: str = Field(
33
+ default="__column_meta",
34
+ description="Name of a sheet that"
35
+ + " consists of column settings of the individual"
36
+ + " worksheets in a workbook.",
37
+ )
38
+ sheet_meta: str = Field(
39
+ default="__sheet_meta",
40
+ description="Name of a sheet that"
41
+ + " consists of general settings of individual worksheets"
42
+ + " (e.g. header_row, start_column) in a workbook.",
43
+ )
44
+ name_column: str = Field(
45
+ default="sheet",
46
+ description="The name of the column in"
47
+ + " column_meta and sheet_meta worksheets that holds the"
48
+ + " names of the worksheets in the workbook that the settings"
49
+ + " are applied to.",
50
+ )
51
+
52
+
53
+ def read_meta_information(workbook: Workbook, meta_sheet_name: str):
54
+ """Reads the content of a worksheet"""
55
+ if meta_sheet_name in workbook.sheetnames:
56
+ sheet_meta_header = [cell.value for cell in workbook[meta_sheet_name][1]]
57
+ sheet_meta_values = workbook[meta_sheet_name].iter_rows(
58
+ min_row=2, values_only=True
59
+ )
60
+ return [
61
+ dict(zip(sheet_meta_header, val, strict=True)) for val in sheet_meta_values
62
+ ]
63
+ raise SyntaxError(
64
+ f"Unable to extract the sheet {meta_sheet_name} from the workbook."
65
+ )
66
+
67
+
68
+ def reshape_columns_meta(column_meta: list, name_column: str) -> dict[str, list]:
69
+ """Reshapes column metadata into a dictionary where keys are worksheet
70
+ names and values are lists of column metadata dictionaries. Worksheet names comes
71
+ from the column 'name_column'.
72
+ """
73
+ worksheet_columns: dict[str, list[dict]] = defaultdict(list)
74
+ for item in column_meta:
75
+ try:
76
+ sheet_name = item.get(name_column)
77
+ except KeyError as err:
78
+ raise MetaColumnNotFound(
79
+ f"{name_column} column not found in column meta sheet"
80
+ ) from err
81
+ worksheet_columns[sheet_name].append(item)
82
+ return worksheet_columns
83
+
84
+
85
+ def reshape_settings_meta(settings_meta: list, name_column: str) -> dict[str, dict]:
86
+ """Reshapes settings metadata into a dictionary where keys
87
+ are worksheet names and values are worksheet settings dictionaries.
88
+ Worksheet names comes from the column 'name_column'.
89
+ """
90
+ worksheet_settings: dict = {}
91
+ for item in settings_meta:
92
+ try:
93
+ sheet_name = item.get(name_column)
94
+ except KeyError as err:
95
+ raise MetaColumnNotFound(
96
+ f"{name_column} column not found in settings meta sheet"
97
+ ) from err
98
+ if sheet_name in worksheet_settings:
99
+ raise MetaColumnNotUnique(
100
+ f"Duplicate sheet name {sheet_name} in settings meta column {
101
+ name_column
102
+ }"
103
+ )
104
+ worksheet_settings[sheet_name] = item
105
+ return worksheet_settings
106
+
107
+
108
+ def worksheet_meta_information(
109
+ workbook: Workbook, meta_info: MetaInfo = MetaInfo()
110
+ ) -> dict[str, dict]:
111
+ """Creates a dictionary containing both settings and columns metadata for each worksheet"""
112
+ settings = read_meta_information(workbook, meta_info.sheet_meta)
113
+ columns = read_meta_information(workbook, meta_info.column_meta)
114
+ reshaped_settings = reshape_settings_meta(settings, meta_info.name_column)
115
+ reshaped_columns = reshape_columns_meta(columns, meta_info.name_column)
116
+ return {
117
+ key: {"settings": reshaped_settings[key], "columns": reshaped_columns[key]}
118
+ for key in reshaped_settings
119
+ }
120
+
121
+
122
+ def get_workbook_config(workbook: Workbook) -> WorkbookConfig:
123
+ """Gets workbook configurations from the worksheet __sheet_meta"""
124
+ worksheet_meta = worksheet_meta_information(workbook)
125
+ return WorkbookConfig.model_validate({"worksheets": worksheet_meta})
@@ -0,0 +1,98 @@
1
+ # Copyright 2021 - 2025 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2
+ # for the German Human Genome-Phenome Archive (GHGA)
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ """This module contains the models describing a GHGA Workbook."""
18
+
19
+ from collections import Counter
20
+
21
+ from pydantic import BaseModel, Field, model_serializer, model_validator
22
+
23
+ from .exceptions import DuplicatedName
24
+
25
+
26
+ class GHGAWorksheetRow(BaseModel):
27
+ """A model defining a row in a worksheet encompassing a content and the relations
28
+ keeping the references to other classes.
29
+ """
30
+
31
+ relations: dict = Field(
32
+ ...,
33
+ description="A dictionary mapping resource identifiers to their"
34
+ + " corresponding classes. This field details the resources referenced within"
35
+ + " the worksheet row.",
36
+ )
37
+
38
+ content: dict = Field(
39
+ ...,
40
+ description="A dictionary containing key-value pairs where keys"
41
+ + " represent the properties of the data fields, and values represent"
42
+ + " the corresponding data. This field does not include information"
43
+ + " about the relations.",
44
+ )
45
+
46
+
47
+ class GHGAWorksheet(BaseModel):
48
+ """A model defining a GHGA worksheet."""
49
+
50
+ worksheet: dict[str, dict[str, GHGAWorksheetRow]] = Field(
51
+ ...,
52
+ description="A nested dictionary representing a GHGA worksheet."
53
+ + " The outer dictionary maps worksheet names (strings) to inner dictionaries."
54
+ + " Each inner dictionary maps row primary key values (strings) to their"
55
+ + " corresponding `GHGAWorksheetRow` instances.",
56
+ )
57
+
58
+ @model_serializer()
59
+ def serialize_model(self):
60
+ """Custom serializer method that returns a dictionary representation of the
61
+ worksheet, omitting the attribute name 'worksheet' from the serialized output.
62
+ """
63
+ return {key: value for key, value in self.worksheet.items()}
64
+
65
+
66
+ class GHGAWorkbook(BaseModel):
67
+ """A model defining a GHGA workbook consists of multiple worksheets."""
68
+
69
+ workbook: tuple[GHGAWorksheet, ...] = Field(
70
+ ...,
71
+ description="A tuple of `GHGAWorksheet` instances."
72
+ + "Each `GHGAWorksheet` represents a worksheet within the workbook.",
73
+ )
74
+
75
+ @model_validator(mode="after")
76
+ def check_name(cls, values): # noqa
77
+ """Function to ensure that workbook consists of worksheets with unique names."""
78
+ attrs_counter = Counter(
79
+ key for ws in values.workbook for key, _ in ws.worksheet.items()
80
+ )
81
+ dup_ws_names = [name for name, count in attrs_counter.items() if count > 1]
82
+ if dup_ws_names:
83
+ raise DuplicatedName(
84
+ "Duplicate worksheet names:: " + ", ".join(dup_ws_names)
85
+ )
86
+ return values
87
+
88
+ @model_serializer()
89
+ def serialize_model(self):
90
+ """Custom serializer method that returns a dictionary representation of the
91
+ workbook, omitting the attribute name 'workbook' from the serialized output and
92
+ returning a flattened dictionary instead of a tuple of worksheets.
93
+ """
94
+ return {
95
+ key: value
96
+ for worksheet in self.workbook
97
+ for key, value in worksheet.worksheet.items()
98
+ }
@@ -36,7 +36,7 @@ def to_attributes() -> Callable:
36
36
  def split_one(value: str) -> dict:
37
37
  """Returns a dictionary with key, value as keys, splitted string as values"""
38
38
  splitted = (elem.strip() for elem in value.split("="))
39
- return dict(zip(("key", "value"), splitted))
39
+ return dict(zip(("key", "value"), splitted, strict=True))
40
40
 
41
41
  def split_mult(value: str) -> list[dict]:
42
42
  """Converts string to attributes"""
@@ -0,0 +1,53 @@
1
+ # Copyright 2021 - 2025 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2
+ # for the German Human Genome-Phenome Archive (GHGA)
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ """This module contains functionalities for processing excel sheets into json object."""
18
+
19
+ from pathlib import Path
20
+
21
+ from arcticfreeze import FrozenDict
22
+ from openpyxl import Workbook
23
+ from schemapack.spec.datapack import DataPack
24
+
25
+ from .config import WorkbookConfig
26
+ from .metasheet_parser import get_workbook_config
27
+ from .models import GHGAWorkbook
28
+ from .transpiler_io import read_workbook
29
+ from .workbook_parser import GHGAWorkbookParser
30
+
31
+
32
+ def parse_workbook(workbook: Workbook, config: WorkbookConfig) -> GHGAWorkbook:
33
+ """Converts a workbook into GHGAWorkbook"""
34
+ return GHGAWorkbookParser(config=config, workbook=workbook).parse()
35
+
36
+
37
+ def transpile_to_datapack(workbook: GHGAWorkbook) -> DataPack:
38
+ """Convert GHAWorkbook into a Datapack instance."""
39
+ return DataPack(
40
+ datapack="0.3.0",
41
+ resources=FrozenDict(workbook.model_dump()),
42
+ rootResource=None,
43
+ rootClass=None,
44
+ )
45
+
46
+
47
+ def transpile(spread_sheet: Path) -> DataPack:
48
+ """The main flow with the steps to transpile a spreadsheet into a datapack."""
49
+ workbook = read_workbook(spread_sheet)
50
+ workbook_config = get_workbook_config(workbook)
51
+ ghga_workbook = parse_workbook(workbook, workbook_config)
52
+ ghga_datapack = transpile_to_datapack(ghga_workbook)
53
+ return ghga_datapack
@@ -19,37 +19,36 @@
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- import json
23
22
  import sys
24
- from importlib import resources
25
23
  from pathlib import Path
26
- from typing import TextIO
27
24
 
28
- from openpyxl import load_workbook
25
+ from openpyxl import Workbook, load_workbook
26
+ from schemapack import dumps_datapack
27
+ from schemapack.spec.datapack import DataPack
29
28
 
30
- from .core import GHGAWorkbook
29
+ from .exceptions import WorkbookNotFound
31
30
 
32
31
 
33
- def read_workbook(
34
- path: Path, configs_package: resources.Package = "ghga_transpiler.configs"
35
- ) -> GHGAWorkbook:
32
+ def read_workbook(path: Path) -> Workbook:
36
33
  """Function to read-in a workbook"""
37
- return GHGAWorkbook(load_workbook(path), configs_package=configs_package)
38
-
39
-
40
- def _write_json(data: dict, file: TextIO):
41
- """Write the data to the specified file in JSON format"""
42
- json.dump(obj=data, fp=file, ensure_ascii=False, indent=4)
43
-
44
-
45
- def write_json(data: dict, path: Path | None, force: bool) -> None:
46
- """Write the data provided as a dictionary to the specified output path or
47
- to stdout if the path is None.
34
+ try:
35
+ return load_workbook(path)
36
+ except FileNotFoundError as err:
37
+ raise WorkbookNotFound(f"Spreadsheet file not found on {path}") from err
38
+
39
+
40
+ def write_datapack(
41
+ data: DataPack, path: Path | None, yaml_format: bool, force: bool
42
+ ) -> None:
43
+ """Writes data as JSON to the specified output path or
44
+ to stdout if the path is None, or overwrites an existing output file if
45
+ 'force' is True.
48
46
  """
47
+ datapack = dumps_datapack(data, yaml_format=yaml_format)
49
48
  if path is None:
50
- _write_json(data, sys.stdout)
49
+ sys.stdout.write(datapack)
51
50
  elif path.exists() and not force:
52
51
  raise FileExistsError(f"File already exists: {path}")
53
52
  else:
54
53
  with open(file=path, mode="w", encoding="utf8") as outfile:
55
- _write_json(data, outfile)
54
+ outfile.write(datapack)