Skip to content

Commit 6daed02

Browse files
ft: Instantiate Specfile from strings or file-like objects (#458)
ft: Instantiate Specfile from strings or file-like objects TODO: Write new tests or update the old ones to cover new functionality. Update doc-strings where appropriate. Update or write new documentation. Fixes #206 Fixes #248 RELEASE NOTES BEGIN Added support for creating Specfile instances from file objects and strings. RELEASE NOTES END Reviewed-by: Nikola Forró Reviewed-by: Mayank Singh Reviewed-by: Maxwell G
2 parents cd1aaf0 + 23419c0 commit 6daed02

File tree

4 files changed

+254
-81
lines changed

4 files changed

+254
-81
lines changed

specfile/specfile.py

Lines changed: 117 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,27 @@ 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
"""
62-
Initializes a specfile object.
81+
Initializes a specfile object. You can specify either a path to the spec file,
82+
its content as a string or a file object representing it. `sourcedir` is optional
83+
if `path` or a named `file` is provided and will be set to the parent directory.
6384
6485
Args:
6586
path: Path to the spec file.
87+
content: String containing the spec file content.
88+
file: File object representing the spec file.
6689
sourcedir: Path to sources and patches.
6790
autosave: Whether to automatically save any changes made.
6891
macros: List of extra macro definitions.
@@ -71,12 +94,29 @@ def __init__(
7194
Such sources include sources referenced from shell expansions
7295
in tag values and sources included using the _%include_ directive.
7396
"""
97+
# count mutually exclusive arguments
98+
if sum([file is not None, path is not None, content is not None]) > 1:
99+
raise ValueError(
100+
"Only one of `file`, `path` or `content` should be provided"
101+
)
102+
if file is not None:
103+
self._file = file
104+
elif path is not None:
105+
self._file = Path(path).open("r+", **self.ENCODING_ARGS)
106+
elif content is not None:
107+
self._file = StringIO(content)
108+
else:
109+
raise ValueError("Either `file`, `path` or `content` must be provided")
110+
if sourcedir is None:
111+
try:
112+
sourcedir = Path(self._file.name).parent
113+
except AttributeError:
114+
raise ValueError(
115+
"`sourcedir` is required when providing `content` or file object without a name"
116+
)
74117
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-
)
118+
self._lines, self._trailing_newline = self._read_lines(self._file)
119+
self._parser = SpecParser(Path(sourcedir), macros, force_parse)
80120
self._parser.parse(str(self))
81121
self._dump_debug_info("After initial parsing")
82122

@@ -85,7 +125,7 @@ def __eq__(self, other: object) -> bool:
85125
return NotImplemented
86126
return (
87127
self.autosave == other.autosave
88-
and self._path == other._path
128+
and self.path == other.path
89129
and self._lines == other._lines
90130
and self._parser == other._parser
91131
)
@@ -111,6 +151,39 @@ def __exit__(
111151
) -> None:
112152
self.save()
113153

154+
def __deepcopy__(self, memodict: Dict[int, Any]):
155+
"""
156+
Deepcopies the object, handling file-like attributes.
157+
"""
158+
specfile = self.__class__.__new__(self.__class__)
159+
memodict[id(self)] = specfile
160+
161+
for k, v in self.__dict__.items():
162+
if k == "_file":
163+
continue
164+
setattr(specfile, k, copy.deepcopy(v, memodict))
165+
166+
try:
167+
path = Path(cast(FileIO, self._file).name)
168+
except AttributeError:
169+
# IO doesn't implement getvalue() so tell mypy this is StringIO
170+
# (could also be BytesIO)
171+
sio = cast(StringIO, self._file)
172+
# not a named file, try `getvalue()`
173+
specfile._file = type(sio)(sio.getvalue())
174+
else:
175+
try:
176+
# encoding and errors are only available on TextIO objects
177+
file = cast(TextIO, self._file)
178+
specfile._file = path.open(
179+
mode=file.mode, encoding=file.encoding, errors=file.errors
180+
)
181+
except AttributeError:
182+
# files open in binary mode have no `encoding`/`errors`
183+
specfile._file = path.open(self._file.mode)
184+
185+
return specfile
186+
114187
def _dump_debug_info(self, message) -> None:
115188
logger.debug(
116189
f"DBG: {message}:\n"
@@ -119,19 +192,30 @@ def _dump_debug_info(self, message) -> None:
119192
f" {self._parser.spec!r} @ 0x{id(self._parser.spec):012x}"
120193
)
121194

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

127205
@property
128-
def path(self) -> Path:
206+
def path(self) -> Optional[Path]:
129207
"""Path to the spec file."""
130-
return self._path
208+
try:
209+
return Path(cast(FileIO, self._file).name)
210+
except AttributeError:
211+
return None
131212

132213
@path.setter
133214
def path(self, value: Union[Path, str]) -> None:
134-
self._path = Path(value)
215+
path = Path(value)
216+
if path == self.path:
217+
return
218+
self._file = path.open("r+", **self.ENCODING_ARGS)
135219

136220
@property
137221
def sourcedir(self) -> Path:
@@ -179,11 +263,26 @@ def rpm_spec(self) -> rpm.spec:
179263

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

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

188287
def expand(
189288
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)