11# Copyright Contributors to the Packit project.
22# SPDX-License-Identifier: MIT
33
4+ import copy
45import datetime
56import logging
67import re
78import types
89from dataclasses import dataclass
10+ from io import FileIO , StringIO
911from 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
1226import rpm
1327
3145from specfile .sources import Patches , Sources
3246from specfile .spec_parser import SpecParser
3347from specfile .tags import Tag , Tags
48+ from specfile .types import EncodingArgs
3449from 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 ,
0 commit comments