Skip to content

Commit 10904a6

Browse files
committed
ft: Instantiate Specfile from strings or file-like objects
1 parent cd1aaf0 commit 10904a6

File tree

4 files changed

+252
-81
lines changed

4 files changed

+252
-81
lines changed

specfile/specfile.py

Lines changed: 115 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
# Copyright Contributors to the Packit project.
22
# SPDX-License-Identifier: MIT
33

4+
import copy
45
import datetime
56
import logging
67
import re
78
import types
89
from dataclasses import dataclass
10+
from io import FileIO, StringIO
911
from pathlib import Path
10-
from typing import Generator, List, Optional, Tuple, Type, Union, cast
12+
from typing import (
13+
IO,
14+
Any,
15+
Dict,
16+
Generator,
17+
List,
18+
Optional,
19+
TextIO,
20+
Tuple,
21+
Type,
22+
Union,
23+
cast,
24+
)
1125

1226
import rpm
1327

@@ -31,6 +45,7 @@
3145
from specfile.sources import Patches, Sources
3246
from specfile.spec_parser import SpecParser
3347
from specfile.tags import Tag, Tags
48+
from specfile.types import EncodingArgs
3449
from specfile.value_parser import (
3550
SUBSTITUTION_GROUP_PREFIX,
3651
ConditionalMacroExpansion,
@@ -50,19 +65,25 @@ class Specfile:
5065
autosave: Whether to automatically save any changes made.
5166
"""
5267

68+
ENCODING_ARGS: EncodingArgs = {"encoding": "utf8", "errors": "surrogateescape"}
69+
5370
def __init__(
5471
self,
55-
path: Union[Path, str],
72+
path: Optional[Union[Path, str]] = None,
73+
content: Optional[str] = None,
74+
file: Optional[IO] = None,
5675
sourcedir: Optional[Union[Path, str]] = None,
5776
autosave: bool = False,
5877
macros: Optional[List[Tuple[str, Optional[str]]]] = None,
5978
force_parse: bool = False,
6079
) -> None:
6180
"""
6281
Initializes a specfile object.
63-
82+
The arguments `path`, `file` and `content` are mutually exclusive and only one can be used.
6483
Args:
6584
path: Path to the spec file.
85+
content: String containing the spec file content.
86+
file: File object representing the spec file.
6687
sourcedir: Path to sources and patches.
6788
autosave: Whether to automatically save any changes made.
6889
macros: List of extra macro definitions.
@@ -71,12 +92,29 @@ def __init__(
7192
Such sources include sources referenced from shell expansions
7293
in tag values and sources included using the _%include_ directive.
7394
"""
95+
# count mutually exclusive arguments
96+
if sum([file is not None, path is not None, content is not None]) > 1:
97+
raise ValueError(
98+
"Only one of `file`, `path` or `content` should be provided"
99+
)
100+
if file is not None:
101+
self._file = file
102+
elif path is not None:
103+
self._file = Path(path).open("r+", **self.ENCODING_ARGS)
104+
elif content is not None:
105+
self._file = StringIO(content)
106+
else:
107+
raise ValueError("Either `file`, `path` or `content` must be provided")
108+
if sourcedir is None:
109+
try:
110+
sourcedir = Path(self._file.name).parent
111+
except AttributeError:
112+
raise ValueError(
113+
"`sourcedir` is required when providing `content` or file object without a name"
114+
)
74115
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
79-
)
116+
self._lines, self._trailing_newline = self._read_lines(self._file)
117+
self._parser = SpecParser(Path(sourcedir), macros, force_parse)
80118
self._parser.parse(str(self))
81119
self._dump_debug_info("After initial parsing")
82120

@@ -85,7 +123,7 @@ def __eq__(self, other: object) -> bool:
85123
return NotImplemented
86124
return (
87125
self.autosave == other.autosave
88-
and self._path == other._path
126+
and self.path == other.path
89127
and self._lines == other._lines
90128
and self._parser == other._parser
91129
)
@@ -111,6 +149,39 @@ def __exit__(
111149
) -> None:
112150
self.save()
113151

152+
def __deepcopy__(self, memodict: Dict[int, Any]):
153+
"""
154+
Deepcopies the object, handling file-like attributes.
155+
"""
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+
164+
try:
165+
path = Path(cast(FileIO, self._file).name)
166+
except AttributeError:
167+
# IO doesn't implement getvalue() so tell mypy this is StringIO
168+
# (could also be BytesIO)
169+
sio = cast(StringIO, self._file)
170+
# not a named file, try `getvalue()`
171+
specfile._file = type(sio)(sio.getvalue())
172+
else:
173+
try:
174+
# encoding and errors are only available on TextIO objects
175+
file = cast(TextIO, self._file)
176+
specfile._file = path.open(
177+
mode=file.mode, encoding=file.encoding, errors=file.errors
178+
)
179+
except AttributeError:
180+
# files open in binary mode have no `encoding`/`errors`
181+
specfile._file = path.open(self._file.mode)
182+
183+
return specfile
184+
114185
def _dump_debug_info(self, message) -> None:
115186
logger.debug(
116187
f"DBG: {message}:\n"
@@ -119,19 +190,30 @@ def _dump_debug_info(self, message) -> None:
119190
f" {self._parser.spec!r} @ 0x{id(self._parser.spec):012x}"
120191
)
121192

122-
@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"
193+
@classmethod
194+
def _read_lines(cls, file: IO) -> Tuple[List[str], bool]:
195+
file.seek(0)
196+
raw_content = file.read()
197+
if isinstance(raw_content, str):
198+
content = raw_content
199+
else:
200+
content = raw_content.decode(**cls.ENCODING_ARGS)
201+
return content.splitlines(), content.endswith("\n")
126202

127203
@property
128-
def path(self) -> Path:
204+
def path(self) -> Optional[Path]:
129205
"""Path to the spec file."""
130-
return self._path
206+
try:
207+
return Path(cast(FileIO, self._file).name)
208+
except AttributeError:
209+
return None
131210

132211
@path.setter
133212
def path(self, value: Union[Path, str]) -> None:
134-
self._path = Path(value)
213+
path = Path(value)
214+
if path == self.path:
215+
return
216+
self._file = path.open("r+", **self.ENCODING_ARGS)
135217

136218
@property
137219
def sourcedir(self) -> Path:
@@ -179,11 +261,26 @@ def rpm_spec(self) -> rpm.spec:
179261

180262
def reload(self) -> None:
181263
"""Reloads the spec file content."""
182-
self._lines, self._trailing_newline = self._read_lines(self.path)
264+
try:
265+
path = Path(cast(FileIO, self._file).name)
266+
except AttributeError:
267+
pass
268+
else:
269+
# reopen the path in case the original file has been deleted/replaced
270+
self._file.close()
271+
self._file = path.open("r+", **self.ENCODING_ARGS)
272+
self._lines, self._trailing_newline = self._read_lines(self._file)
183273

184274
def save(self) -> None:
185275
"""Saves the spec file content."""
186-
self.path.write_text(str(self), encoding="utf8", errors="surrogateescape")
276+
self._file.seek(0)
277+
self._file.truncate(0)
278+
content = str(self)
279+
try:
280+
self._file.write(content)
281+
except TypeError:
282+
self._file.write(content.encode(**self.ENCODING_ARGS))
283+
self._file.flush()
187284

188285
def expand(
189286
self,

specfile/types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,12 @@
1212
class SupportsIndex(Protocol, metaclass=abc.ABCMeta): # type: ignore [no-redef]
1313
@abc.abstractmethod
1414
def __index__(self) -> int: ...
15+
16+
17+
try:
18+
from typing import TypedDict
19+
except ImportError:
20+
from typing_extensions import TypedDict
21+
22+
23+
EncodingArgs = TypedDict("EncodingArgs", {"encoding": str, "errors": str})

tests/integration/conftest.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# Copyright Contributors to the Packit project.
22
# SPDX-License-Identifier: MIT
3-
3+
import io
44
import shutil
55

66
import pytest
77

8+
from specfile import Specfile
89
from tests.constants import (
910
SPEC_AUTOPATCH,
1011
SPEC_AUTOSETUP,
@@ -136,3 +137,46 @@ def spec_conditionalized_version(tmp_path):
136137
specfile_path = tmp_path / SPECFILE
137138
shutil.copyfile(SPEC_CONDITIONALIZED_VERSION / SPECFILE, specfile_path)
138139
return specfile_path
140+
141+
142+
@pytest.fixture(
143+
params=[
144+
"file_path",
145+
"text_file",
146+
"binary_file",
147+
"text_io_stream",
148+
"binary_io_stream",
149+
"content_string",
150+
]
151+
)
152+
def specfile_factory(request):
153+
"""
154+
pytest fixture to create a `Specfile` instance with different input modes.
155+
156+
Returns:
157+
Function that creates a `Specfile` instance.
158+
"""
159+
mode = request.param
160+
161+
def _create_specfile(input_path, **kwargs):
162+
kwargs.setdefault("sourcedir", input_path.parent)
163+
164+
if mode == "file_path":
165+
return Specfile(path=input_path, **kwargs)
166+
elif mode == "text_file":
167+
f = open(input_path, "r+", **Specfile.ENCODING_ARGS)
168+
return Specfile(file=f, **kwargs)
169+
elif mode == "binary_file":
170+
f = open(input_path, "rb+")
171+
return Specfile(file=f, **kwargs)
172+
elif mode == "text_io_stream":
173+
content = input_path.read_text(**Specfile.ENCODING_ARGS)
174+
return Specfile(file=io.StringIO(content), **kwargs)
175+
elif mode == "binary_io_stream":
176+
content = input_path.read_bytes()
177+
return Specfile(file=io.BytesIO(content), **kwargs)
178+
elif mode == "content_string":
179+
content = input_path.read_text(**Specfile.ENCODING_ARGS)
180+
return Specfile(content=content, **kwargs)
181+
182+
return _create_specfile

0 commit comments

Comments
 (0)