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,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 ,
0 commit comments