sabr-kit 0.4.3.dev1__tar.gz → 0.4.4__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 (29) hide show
  1. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/PKG-INFO +22 -41
  2. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/README.md +21 -39
  3. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/pyproject.toml +0 -1
  4. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/alignment.py +3 -3
  5. sabr_kit-0.4.4/src/sabr/api.py +159 -0
  6. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/cli.py +46 -14
  7. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/numbering.py +130 -88
  8. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/structure.py +61 -114
  9. sabr_kit-0.4.3.dev1/src/sabr/api.py +0 -119
  10. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/.gitignore +0 -0
  11. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/LICENSE +0 -0
  12. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/__init__.py +0 -0
  13. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/_anarci/LICENSE +0 -0
  14. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/_anarci/__init__.py +0 -0
  15. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/_anarci/schemes.py +0 -0
  16. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/__init__.py +0 -0
  17. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/embeddings_noise_0.0.npz +0 -0
  18. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/embeddings_noise_0.2.npz +0 -0
  19. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/embeddings_noise_0.5.npz +0 -0
  20. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/embeddings_noise_1.0.npz +0 -0
  21. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/embeddings_noise_2.0.npz +0 -0
  22. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/modified_residues.json +0 -0
  23. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/mpnn_encoder.npz +0 -0
  24. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/softalign_embeddings.npz +0 -0
  25. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/softalign_encoder.npz +0 -0
  26. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/assets/softalign_gap.npz +0 -0
  27. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/constants.py +0 -0
  28. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/corrections.py +0 -0
  29. {sabr_kit-0.4.3.dev1 → sabr_kit-0.4.4}/src/sabr/model.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sabr-kit
3
- Version: 0.4.3.dev1
3
+ Version: 0.4.4
4
4
  Summary: Structure-based Antibody Renumbering
5
5
  Project-URL: Homepage, https://github.com/delalamo/sabr
6
6
  Project-URL: Issues, https://github.com/delalamo/sabr/issues
@@ -31,7 +31,6 @@ Requires-Python: >=3.11
31
31
  Requires-Dist: biopython<2,>=1.87
32
32
  Requires-Dist: click<9,>=8.4.2
33
33
  Requires-Dist: dm-haiku<0.1,>=0.0.16
34
- Requires-Dist: gemmi<1,>=0.7.5
35
34
  Requires-Dist: jax<0.11,>=0.10.2
36
35
  Requires-Dist: jaxlib<0.11,>=0.10.2
37
36
  Requires-Dist: numpy<3,>=2.4.6
@@ -78,6 +77,7 @@ sabr -i INPUT -c CHAIN -o OUTPUT
78
77
  [-m sabr|softalign]
79
78
  [--residue-range START END]
80
79
  [--scfv]
80
+ [--no-mmcif]
81
81
  [--overwrite] [-v]
82
82
  ```
83
83
 
@@ -99,13 +99,15 @@ already specifies both domain types, scFv mode requires automatic chain type.
99
99
  Composite references use the selected parameter mode, so `--scfv` can be
100
100
  combined with `--mode softalign`.
101
101
 
102
- Input and output may be PDB (`.pdb`) or mmCIF (`.cif` or `.mmcif`). Use mmCIF
103
- when chain names or ANARCI insertion codes exceed PDB's one-character fields.
104
- Writes are atomic, so a failed run does not leave a partial output.
102
+ Input and output may be PDB (`.pdb`) or mmCIF (`.cif` or `.mmcif`). When a
103
+ requested PDB output needs multi-character insertion codes, SAbR warns and
104
+ automatically writes it to the corresponding `.cif` path instead. Pass
105
+ `--no-mmcif` to forbid this conversion and fail. Other values that exceed PDB
106
+ field limits still require an explicitly named mmCIF output. Writes are atomic,
107
+ so a failed run does not leave a partial output.
105
108
 
106
109
  CLI conversion guarantees preservation of atomic structure content, not
107
- arbitrary non-atomic mmCIF categories. It warns for every mmCIF input. When
108
- those categories matter, load a Gemmi structure and use the in-memory API.
110
+ arbitrary non-atomic mmCIF categories. It warns for every mmCIF input.
109
111
 
110
112
  ## Python API
111
113
 
@@ -117,38 +119,19 @@ structure = PDBParser(QUIET=True).get_structure("antibody", "antibody.pdb")
117
119
  numbered = renumber_structure(structure, chain="H")
118
120
  ```
119
121
 
120
- Gemmi structures use the same function:
122
+ `renumber_structure` accepts a Biopython `Structure`, never mutates its input,
123
+ and returns a new Biopython `Structure`. Non-target chains, hetero residues,
124
+ waters, and residues outside an inclusive `residue_range` are preserved. SAbR
125
+ rejects multi-model structures rather than silently modifying only one model.
126
+ If a partial range would create duplicate residue IDs with unchanged residues,
127
+ the operation fails with an explanation.
121
128
 
122
- ```python
123
- import gemmi
124
- from sabr import renumber_structure
125
-
126
- structure = gemmi.read_structure("antibody.cif")
127
- numbered = renumber_structure(
128
- structure,
129
- chain="heavy_chain",
130
- scheme="chothia",
131
- chain_type="auto",
132
- noise_level=0.0,
133
- mode="softalign",
134
- residue_range=None,
135
- scfv=False,
136
- )
137
- ```
138
-
139
- `renumber_structure` never mutates its input and returns the same concrete
140
- structure type. Non-target chains, hetero residues, waters, and residues
141
- outside an inclusive `residue_range` are preserved. SAbR rejects multi-model
142
- structures rather than silently modifying only one model. If a partial range
143
- would create duplicate residue IDs with unchanged residues, the operation
144
- fails with an explanation.
145
-
146
- The same-type clone preserves metadata represented by the input BioPython or
147
- Gemmi object. Alternate conformers are normalized deterministically: a
148
- complete blank-altloc backbone is preferred, then the complete conformer with
149
- the greatest summed occupancy, with altloc name as the final tie-breaker.
150
- Selections above 1,024 polymer residues are rejected before quadratic model
151
- work; use `residue_range` to select the antibody domain.
129
+ The copy preserves metadata represented by the input Biopython object.
130
+ Alternate conformers are normalized deterministically: a complete blank-altloc
131
+ backbone is preferred, then the complete conformer with the greatest summed
132
+ occupancy, with altloc name as the final tie-breaker. Selections above 1,024
133
+ polymer residues are rejected before quadratic model work; use
134
+ `residue_range` to select the antibody domain.
152
135
 
153
136
  Modified peptide residues are translated only for sequence generation. Their
154
137
  original names and atoms remain unchanged. The committed mapping was generated
@@ -159,9 +142,7 @@ contains only peptide-linking components with exactly one canonical amino-acid
159
142
  parent. Unsupported or ambiguous polymer chemistry fails explicitly; no
160
143
  runtime network access occurs.
161
144
 
162
- Gemmi itself only represents one-character insertion codes. For unusually
163
- long loops that need extended codes, use a BioPython structure in memory or
164
- the CLI with mmCIF output.
145
+ For unusually long loops that need extended insertion codes, use mmCIF output.
165
146
 
166
147
  ## Scientific behavior
167
148
 
@@ -34,6 +34,7 @@ sabr -i INPUT -c CHAIN -o OUTPUT
34
34
  [-m sabr|softalign]
35
35
  [--residue-range START END]
36
36
  [--scfv]
37
+ [--no-mmcif]
37
38
  [--overwrite] [-v]
38
39
  ```
39
40
 
@@ -55,13 +56,15 @@ already specifies both domain types, scFv mode requires automatic chain type.
55
56
  Composite references use the selected parameter mode, so `--scfv` can be
56
57
  combined with `--mode softalign`.
57
58
 
58
- Input and output may be PDB (`.pdb`) or mmCIF (`.cif` or `.mmcif`). Use mmCIF
59
- when chain names or ANARCI insertion codes exceed PDB's one-character fields.
60
- Writes are atomic, so a failed run does not leave a partial output.
59
+ Input and output may be PDB (`.pdb`) or mmCIF (`.cif` or `.mmcif`). When a
60
+ requested PDB output needs multi-character insertion codes, SAbR warns and
61
+ automatically writes it to the corresponding `.cif` path instead. Pass
62
+ `--no-mmcif` to forbid this conversion and fail. Other values that exceed PDB
63
+ field limits still require an explicitly named mmCIF output. Writes are atomic,
64
+ so a failed run does not leave a partial output.
61
65
 
62
66
  CLI conversion guarantees preservation of atomic structure content, not
63
- arbitrary non-atomic mmCIF categories. It warns for every mmCIF input. When
64
- those categories matter, load a Gemmi structure and use the in-memory API.
67
+ arbitrary non-atomic mmCIF categories. It warns for every mmCIF input.
65
68
 
66
69
  ## Python API
67
70
 
@@ -73,38 +76,19 @@ structure = PDBParser(QUIET=True).get_structure("antibody", "antibody.pdb")
73
76
  numbered = renumber_structure(structure, chain="H")
74
77
  ```
75
78
 
76
- Gemmi structures use the same function:
79
+ `renumber_structure` accepts a Biopython `Structure`, never mutates its input,
80
+ and returns a new Biopython `Structure`. Non-target chains, hetero residues,
81
+ waters, and residues outside an inclusive `residue_range` are preserved. SAbR
82
+ rejects multi-model structures rather than silently modifying only one model.
83
+ If a partial range would create duplicate residue IDs with unchanged residues,
84
+ the operation fails with an explanation.
77
85
 
78
- ```python
79
- import gemmi
80
- from sabr import renumber_structure
81
-
82
- structure = gemmi.read_structure("antibody.cif")
83
- numbered = renumber_structure(
84
- structure,
85
- chain="heavy_chain",
86
- scheme="chothia",
87
- chain_type="auto",
88
- noise_level=0.0,
89
- mode="softalign",
90
- residue_range=None,
91
- scfv=False,
92
- )
93
- ```
94
-
95
- `renumber_structure` never mutates its input and returns the same concrete
96
- structure type. Non-target chains, hetero residues, waters, and residues
97
- outside an inclusive `residue_range` are preserved. SAbR rejects multi-model
98
- structures rather than silently modifying only one model. If a partial range
99
- would create duplicate residue IDs with unchanged residues, the operation
100
- fails with an explanation.
101
-
102
- The same-type clone preserves metadata represented by the input BioPython or
103
- Gemmi object. Alternate conformers are normalized deterministically: a
104
- complete blank-altloc backbone is preferred, then the complete conformer with
105
- the greatest summed occupancy, with altloc name as the final tie-breaker.
106
- Selections above 1,024 polymer residues are rejected before quadratic model
107
- work; use `residue_range` to select the antibody domain.
86
+ The copy preserves metadata represented by the input Biopython object.
87
+ Alternate conformers are normalized deterministically: a complete blank-altloc
88
+ backbone is preferred, then the complete conformer with the greatest summed
89
+ occupancy, with altloc name as the final tie-breaker. Selections above 1,024
90
+ polymer residues are rejected before quadratic model work; use
91
+ `residue_range` to select the antibody domain.
108
92
 
109
93
  Modified peptide residues are translated only for sequence generation. Their
110
94
  original names and atoms remain unchanged. The committed mapping was generated
@@ -115,9 +99,7 @@ contains only peptide-linking components with exactly one canonical amino-acid
115
99
  parent. Unsupported or ambiguous polymer chemistry fails explicitly; no
116
100
  runtime network access occurs.
117
101
 
118
- Gemmi itself only represents one-character insertion codes. For unusually
119
- long loops that need extended codes, use a BioPython structure in memory or
120
- the CLI with mmCIF output.
102
+ For unusually long loops that need extended insertion codes, use mmCIF output.
121
103
 
122
104
  ## Scientific behavior
123
105
 
@@ -17,7 +17,6 @@ authors = [
17
17
  dependencies = [
18
18
  "biopython>=1.87,<2",
19
19
  "click>=8.4.2,<9",
20
- "gemmi>=0.7.5,<1",
21
20
  "jax>=0.10.2,<0.11",
22
21
  "jaxlib>=0.10.2,<0.11",
23
22
  "numpy>=2.4.6,<3",
@@ -162,12 +162,12 @@ def load_references(
162
162
  with path.open("rb") as handle:
163
163
  data = np.load(handle, allow_pickle=True)["arr_0"].item()
164
164
  references = {}
165
- for chain_type in constants.CHAIN_TYPES:
166
- embeddings = np.asarray(data[chain_type]["array"])
165
+ for chain_type, reference in data.items():
166
+ embeddings = np.asarray(reference["array"])
167
167
  embeddings.flags.writeable = False
168
168
  references[chain_type] = (
169
169
  embeddings,
170
- tuple(int(position) for position in data[chain_type]["idxs"]),
170
+ tuple(int(position) for position in reference["idxs"]),
171
171
  )
172
172
 
173
173
  if scfv:
@@ -0,0 +1,159 @@
1
+ """The public, in-memory SAbR API."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+
6
+ from sabr import constants
7
+ from sabr.alignment import align, load_references
8
+ from sabr.model import encode
9
+ from sabr.numbering import number_alignment
10
+ from sabr.structure import apply_numbering, extract_chain
11
+
12
+ LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ def _normalize_choice(value: str, name: str, choices: tuple[str, ...]) -> str:
16
+ """Normalize a case-insensitive string constrained to known choices."""
17
+ if not isinstance(value, str) or value.lower() not in choices:
18
+ raise ValueError(f"{name} must be one of {', '.join(choices)}.")
19
+ return value.lower()
20
+
21
+
22
+ def _normalize_chain_type(
23
+ chain_type: str,
24
+ noise_level: float,
25
+ mode: str,
26
+ ) -> str:
27
+ if not isinstance(chain_type, str):
28
+ raise ValueError("chain_type must be a reference type or 'auto'.")
29
+ normalized = "auto" if chain_type.lower() == "auto" else chain_type.upper()
30
+ if normalized == "auto":
31
+ return normalized
32
+
33
+ reference_types = tuple(load_references(noise_level, mode))
34
+ if normalized not in reference_types:
35
+ choices = ", ".join(("auto", *reference_types))
36
+ raise ValueError(f"chain_type must be one of {choices}.")
37
+ return normalized
38
+
39
+
40
+ def _normalize_noise_level(noise_level: float) -> float:
41
+ if isinstance(noise_level, bool):
42
+ raise ValueError(
43
+ f"noise_level must be one of {constants.NOISE_LEVELS}."
44
+ )
45
+ try:
46
+ normalized_noise = float(noise_level)
47
+ except (TypeError, ValueError) as error:
48
+ raise ValueError(
49
+ f"noise_level must be one of {constants.NOISE_LEVELS}."
50
+ ) from error
51
+ if normalized_noise not in constants.NOISE_LEVELS:
52
+ raise ValueError(
53
+ f"noise_level must be one of {constants.NOISE_LEVELS}."
54
+ )
55
+ return normalized_noise
56
+
57
+
58
+ def _validate_residue_range(
59
+ residue_range: tuple[int, int] | None,
60
+ ) -> None:
61
+ """Validate inclusive structure residue-number bounds.
62
+
63
+ The bounds are compared with BioPython ``residue.id[1]`` or Gemmi
64
+ ``residue.seqid.num``. They are residue numbers from the input structure,
65
+ not zero-based sequence or array indices, and need not begin at one or be
66
+ contiguous. Every insertion-code variant sharing a selected residue number
67
+ is included.
68
+ """
69
+ if residue_range is None:
70
+ return
71
+ error = "residue_range must be an inclusive (start, end) tuple."
72
+ if not isinstance(residue_range, tuple):
73
+ raise ValueError(error)
74
+ if len(residue_range) != 2:
75
+ raise ValueError(error)
76
+ if not all(
77
+ isinstance(value, int) and not isinstance(value, bool)
78
+ for value in residue_range
79
+ ):
80
+ raise ValueError(error)
81
+ if residue_range[0] > residue_range[1]:
82
+ raise ValueError("residue_range start must not exceed its end.")
83
+
84
+
85
+ @dataclass(slots=True)
86
+ class _RenumberOptions:
87
+ """Validated and normalized arguments for one renumbering operation."""
88
+
89
+ scheme: str
90
+ chain_type: str
91
+ noise_level: float
92
+ residue_range: tuple[int, int] | None
93
+ mode: str
94
+ scfv: bool
95
+
96
+ def __post_init__(self) -> None:
97
+ self.scheme = _normalize_choice(
98
+ self.scheme, "scheme", constants.NUMBERING_SCHEMES
99
+ )
100
+ self.noise_level = _normalize_noise_level(self.noise_level)
101
+ self.mode = _normalize_choice(self.mode, "mode", constants.MODES)
102
+ self.chain_type = _normalize_chain_type(
103
+ self.chain_type,
104
+ self.noise_level,
105
+ self.mode,
106
+ )
107
+ _validate_residue_range(self.residue_range)
108
+ if not isinstance(self.scfv, bool):
109
+ raise ValueError("scfv must be a boolean.")
110
+ if self.scfv and self.chain_type != "auto":
111
+ raise ValueError("scfv requires chain_type='auto'.")
112
+
113
+
114
+ def renumber_structure(
115
+ structure,
116
+ chain: str,
117
+ scheme: str = "imgt",
118
+ chain_type: str = "auto",
119
+ noise_level: float = 0.0,
120
+ residue_range: tuple[int, int] | None = None,
121
+ mode: str = "sabr",
122
+ scfv: bool = False,
123
+ ):
124
+ """Return a renumbered copy of a Biopython structure.
125
+
126
+ ``mode="softalign"`` selects the SoftAlign encoder, references, and gap
127
+ penalties as one scientifically consistent parameter set. ``scfv=True``
128
+ adds the four supported two-domain composite references.
129
+ """
130
+ options = _RenumberOptions(
131
+ scheme=scheme,
132
+ chain_type=chain_type,
133
+ noise_level=noise_level,
134
+ residue_range=residue_range,
135
+ mode=mode,
136
+ scfv=scfv,
137
+ )
138
+ data = extract_chain(structure, chain, options.residue_range)
139
+ embeddings = encode(data.coords, options.mode)
140
+ imgt_alignment, selected_type, score = align(
141
+ embeddings,
142
+ data.gap_indices,
143
+ options.chain_type,
144
+ options.noise_level,
145
+ mode=options.mode,
146
+ scfv=options.scfv,
147
+ )
148
+ LOGGER.info(
149
+ "Selected %s reference with alignment score %.4f.",
150
+ selected_type,
151
+ score,
152
+ )
153
+ numbered = number_alignment(
154
+ imgt_alignment,
155
+ data.sequence,
156
+ options.scheme,
157
+ selected_type,
158
+ )
159
+ return apply_numbering(structure, chain, data, numbered)
@@ -51,6 +51,22 @@ def _validate_pdb_output(structure) -> None:
51
51
  )
52
52
 
53
53
 
54
+ def _has_extended_insertion_codes(structure) -> bool:
55
+ return any(
56
+ len(residue.id[2].strip()) > 1 for residue in structure.get_residues()
57
+ )
58
+
59
+
60
+ def _resolve_output_path(structure, path: Path, no_mmcif: bool = False) -> Path:
61
+ if (
62
+ path.suffix.lower() == ".pdb"
63
+ and not no_mmcif
64
+ and _has_extended_insertion_codes(structure)
65
+ ):
66
+ return path.with_suffix(".cif")
67
+ return path
68
+
69
+
54
70
  def _write_structure(structure, path: Path) -> None:
55
71
  suffix = path.suffix.lower()
56
72
  if suffix == ".pdb":
@@ -122,6 +138,11 @@ def _write_structure(structure, path: Path) -> None:
122
138
  is_flag=True,
123
139
  help="Also try H:K, H:L, K:H, and L:H composite references.",
124
140
  )
141
+ @click.option(
142
+ "--no-mmcif",
143
+ is_flag=True,
144
+ help="Forbid automatic mmCIF output for extended insertion codes.",
145
+ )
125
146
  @click.option("--overwrite", is_flag=True)
126
147
  @click.option("-v", "--verbose", is_flag=True)
127
148
  def main(
@@ -134,6 +155,7 @@ def main(
134
155
  mode: str,
135
156
  residue_range: tuple | None,
136
157
  scfv: bool,
158
+ no_mmcif: bool,
137
159
  overwrite: bool,
138
160
  verbose: bool,
139
161
  ) -> None:
@@ -143,22 +165,13 @@ def main(
143
165
  format="%(levelname)s: %(message)s",
144
166
  force=True,
145
167
  )
146
- if output_path.exists() and not overwrite:
147
- raise click.ClickException(
148
- f"Output already exists: {output_path}. "
149
- "Use --overwrite to replace it."
150
- )
151
-
152
- temporary = output_path.with_name(
153
- f".{output_path.stem}.{uuid.uuid4().hex}.tmp{output_path.suffix}"
154
- )
168
+ temporary = None
155
169
  try:
156
170
  structure = _read_structure(input_path)
157
171
  if input_path.suffix.lower() in (".cif", ".mmcif"):
158
172
  LOGGER.warning(
159
- "Non-atomic mmCIF categories may not be preserved by CLI "
160
- "conversion; use the in-memory Gemmi API when metadata "
161
- "preservation is required."
173
+ "Non-atomic mmCIF categories are not preserved when parsed "
174
+ "with Biopython."
162
175
  )
163
176
  LOGGER.info("JAX backend: %s", jax.default_backend())
164
177
  result = renumber_structure(
@@ -171,12 +184,31 @@ def main(
171
184
  residue_range=residue_range,
172
185
  scfv=scfv,
173
186
  )
187
+ resolved_output_path = _resolve_output_path(
188
+ result, output_path, no_mmcif=no_mmcif
189
+ )
190
+ if resolved_output_path.exists() and not overwrite:
191
+ raise click.ClickException(
192
+ f"Output already exists: {resolved_output_path}. "
193
+ "Use --overwrite to replace it."
194
+ )
195
+ if resolved_output_path != output_path:
196
+ LOGGER.warning(
197
+ "PDB cannot represent multi-character insertion codes; "
198
+ "automatically saving mmCIF output to %s. Use --no-mmcif "
199
+ "to forbid this behavior.",
200
+ resolved_output_path,
201
+ )
202
+ temporary = resolved_output_path.with_name(
203
+ f".{resolved_output_path.stem}.{uuid.uuid4().hex}"
204
+ f".tmp{resolved_output_path.suffix}"
205
+ )
174
206
  _write_structure(result, temporary)
175
- os.replace(temporary, output_path)
207
+ os.replace(temporary, resolved_output_path)
176
208
  except click.ClickException:
177
209
  raise
178
210
  except Exception as error:
179
- if temporary.exists():
211
+ if temporary is not None and temporary.exists():
180
212
  try:
181
213
  temporary.unlink()
182
214
  except OSError as cleanup_error: