Skip to content

Commit 0b80f01

Browse files
committed
ft: Instantiate Specfile from strings or file-like objects
1 parent 01f6a7c commit 0b80f01

File tree

2 files changed

+102
-17
lines changed

2 files changed

+102
-17
lines changed

specfile/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,4 +243,4 @@
243243
"x86_64_v3",
244244
"x86_64_v4",
245245
"xtensa",
246-
}
246+
}

specfile/specfile.py

Lines changed: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
# Copyright Contributors to the Packit project.
22
# SPDX-License-Identifier: MIT
3-
3+
import copy
44
import datetime
55
import logging
66
import re
77
import types
88
from dataclasses import dataclass
9+
from io import IOBase, StringIO
910
from pathlib import Path
10-
from typing import Generator, List, Optional, Tuple, Type, Union, cast
11+
from typing import (
12+
Any,
13+
Dict,
14+
Generator,
15+
List,
16+
Optional,
17+
Tuple,
18+
Type,
19+
Union,
20+
cast,
21+
)
1122

1223
import rpm
1324

@@ -41,6 +52,7 @@
4152

4253
logger = logging.getLogger(__name__)
4354

55+
ENCODING_ARGS = {"encoding": "utf8", "errors": "surrogateescape"}
4456

4557
class Specfile:
4658
"""
@@ -52,7 +64,9 @@ class Specfile:
5264

5365
def __init__(
5466
self,
55-
path: Union[Path, str],
67+
path: Optional[Union[Path, str]] = None,
68+
file: Optional[IOBase] = None,
69+
raw_string: Optional[str] = None,
5670
sourcedir: Optional[Union[Path, str]] = None,
5771
autosave: bool = False,
5872
macros: Optional[List[Tuple[str, Optional[str]]]] = None,
@@ -63,6 +77,8 @@ def __init__(
6377
6478
Args:
6579
path: Path to the spec file.
80+
file: File object representing the spec file.
81+
raw_string: String containing the spec file content.
6682
sourcedir: Path to sources and patches.
6783
autosave: Whether to automatically save any changes made.
6884
macros: List of extra macro definitions.
@@ -71,12 +87,35 @@ def __init__(
7187
Such sources include sources referenced from shell expansions
7288
in tag values and sources included using the _%include_ directive.
7389
"""
90+
if file is not None:
91+
self._file = file
92+
try:
93+
self._file.name
94+
except AttributeError:
95+
if sourcedir is None:
96+
raise ValueError(
97+
"'sourcedir' is required when providing a file object without a name"
98+
)
99+
elif path is not None:
100+
self._file = Path(path).open("r+", **ENCODING_ARGS)
101+
elif raw_string is not None:
102+
self._file = StringIO(raw_string)
103+
if sourcedir is None:
104+
raise ValueError(
105+
"'sourcedir' is required when providing a raw string input"
106+
)
107+
else:
108+
raise ValueError(
109+
"Either 'file', 'path', or 'string_input' must be provided"
110+
)
74111
self.autosave = autosave
75-
self._path = Path(path)
76-
self._lines, self._trailing_newline = self._read_lines(self._path)
77-
self._parser = SpecParser(
78-
Path(sourcedir or self.path.parent), macros, force_parse
112+
self._lines, self._trailing_newline = self._read_lines(self._file)
113+
parser_sourcedir = (
114+
Path(sourcedir)
115+
if sourcedir is not None
116+
else (self.path.parent if self.path else None)
79117
)
118+
self._parser = SpecParser(parser_sourcedir, macros, force_parse)
80119
self._parser.parse(str(self))
81120
self._dump_debug_info("After initial parsing")
82121

@@ -85,7 +124,7 @@ def __eq__(self, other: object) -> bool:
85124
return NotImplemented
86125
return (
87126
self.autosave == other.autosave
88-
and self._path == other._path
127+
and self.path == other.path
89128
and self._lines == other._lines
90129
and self._parser == other._parser
91130
)
@@ -111,6 +150,35 @@ def __exit__(
111150
) -> None:
112151
self.save()
113152

153+
def __deepcopy__(self, memodict: Dict[int, Any]):
154+
"""Creates a deep copy of the Specfile, reopens files instantiated from a path,
155+
and returns a new StringIO instance for objects instantiated from StringIO."""
156+
specfile = self.__class__.__new__(self.__class__)
157+
memodict[id(self)] = specfile
158+
159+
for k, v in self.__dict__.items():
160+
if k == "_file":
161+
continue
162+
setattr(specfile, k, copy.deepcopy(v, memodict))
163+
try:
164+
data = self._file.getvalue()
165+
except AttributeError:
166+
try:
167+
path = Path(self._file.name)
168+
except AttributeError:
169+
raise TypeError(
170+
"Deepcopy is not supported for arbitrary file-like objects"
171+
)
172+
else:
173+
specfile._file = path.open(
174+
self._file.mode,
175+
encoding=self._file.encoding,
176+
errors=self._file.errors,
177+
)
178+
else:
179+
specfile._file = type(self._file)(data)
180+
return specfile
181+
114182
def _dump_debug_info(self, message) -> None:
115183
logger.debug(
116184
f"DBG: {message}:\n"
@@ -120,18 +188,28 @@ def _dump_debug_info(self, message) -> None:
120188
)
121189

122190
@staticmethod
123-
def _read_lines(path: Path) -> Tuple[List[str], bool]:
124-
content = path.read_text(encoding="utf8", errors="surrogateescape")
125-
return content.splitlines(), content[-1] == "\n"
191+
def _read_lines(file: IOBase) -> Tuple[List[str], bool]:
192+
file.seek(0)
193+
raw_content = file.read()
194+
if isinstance(raw_content, str):
195+
content = raw_content
196+
else:
197+
content = raw_content.decode(**ENCODING_ARGS)
198+
return content.splitlines(), content.endswith("\n")
126199

127200
@property
128-
def path(self) -> Path:
201+
def path(self) -> Optional[Path]:
129202
"""Path to the spec file."""
130-
return self._path
203+
try:
204+
return Path(self._file.name)
205+
except AttributeError:
206+
return None
131207

132208
@path.setter
133209
def path(self, value: Union[Path, str]) -> None:
134-
self._path = Path(value)
210+
self._file.close()
211+
new_path = Path(value)
212+
self._file = new_path.open("r+", **ENCODING_ARGS)
135213

136214
@property
137215
def sourcedir(self) -> Path:
@@ -179,11 +257,18 @@ def rpm_spec(self) -> rpm.spec:
179257

180258
def reload(self) -> None:
181259
"""Reloads the spec file content."""
182-
self._lines, self._trailing_newline = self._read_lines(self.path)
260+
self._lines, self._trailing_newline = self._read_lines(self._file)
183261

184262
def save(self) -> None:
185263
"""Saves the spec file content."""
186-
self.path.write_text(str(self), encoding="utf8", errors="surrogateescape")
264+
self._file.seek(0)
265+
self._file.truncate(0)
266+
content = str(self)
267+
try:
268+
self._file.write(content)
269+
except TypeError:
270+
self._file.write(content.encode(**ENCODING_ARGS))
271+
self._file.flush()
187272

188273
def expand(
189274
self,

0 commit comments

Comments
 (0)