Skip to content

Commit f1e6309

Browse files
Merge pull request #153 from packit/context_management
Make context managers better This commit introduces a new ContextManager decorator that builds on top of contextlib.contextmanager and effectively makes context managers a singleton. Any nested context of the same kind will be the same as the outermost context of that particular kind. This makes it possible, for example, to set properties that internally use context managers inside an existing context, or in general mix and nest context managers almost freely. There are still instances of conflicting context managers, for example macro_definitions() and sections(), that, when combined, will overwrite each other's underlying data. All such instances will be documented. Now it is also possible to access data directly, avoiding the with statement, by using GeneratorContextManager.content property (I'm open to suggestions how to name it better): for tag in spec.tags().content: print(f"{tag.name}: {tag.expanded_value}") No modifications will be preserved though. RELEASE NOTES BEGIN Context managers (Specfile.sections(), Specfile.tags() etc.) can now be nested and combined together (with one exception - Specfile.macro_definitions()), and it is also possible to use tag properties (e.g. Specfile.version, Specfile.license) inside them. It is also possible to access the data directly, avoiding the with statement, by using the content property (e.g. Specfile.tags().content), but be aware that no modifications done to such data will be preserved. You must use with to make changes. RELEASE NOTES END Reviewed-by: František Nečas <[email protected]> Reviewed-by: Nikola Forró <None>
2 parents b7fc4cc + 0a8d562 commit f1e6309

File tree

6 files changed

+188
-64
lines changed

6 files changed

+188
-64
lines changed

specfile/context_management.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Copyright Contributors to the Packit project.
2+
# SPDX-License-Identifier: MIT
3+
4+
import collections
5+
import contextlib
6+
import io
7+
import os
8+
import pickle
9+
import sys
10+
import tempfile
11+
import types
12+
from typing import Any, Callable, Dict, Generator, List, Optional, overload
13+
14+
15+
@contextlib.contextmanager
16+
def capture_stderr() -> Generator[List[bytes], None, None]:
17+
"""
18+
Context manager for capturing output to stderr. A stderr output of anything run
19+
in its context will be captured in the target variable of the with statement.
20+
21+
Yields:
22+
List of captured lines.
23+
"""
24+
fileno = sys.__stderr__.fileno()
25+
with tempfile.TemporaryFile() as stderr, os.fdopen(os.dup(fileno)) as backup:
26+
sys.stderr.flush()
27+
os.dup2(stderr.fileno(), fileno)
28+
data: List[bytes] = []
29+
try:
30+
yield data
31+
finally:
32+
sys.stderr.flush()
33+
os.dup2(backup.fileno(), fileno)
34+
stderr.flush()
35+
stderr.seek(0, io.SEEK_SET)
36+
data.extend(stderr.readlines())
37+
38+
39+
class GeneratorContextManager(contextlib._GeneratorContextManager):
40+
"""
41+
Extended contextlib._GeneratorContextManager that provides get() method.
42+
"""
43+
44+
def __init__(self, function: Callable) -> None:
45+
super().__init__(function, tuple(), {})
46+
47+
def __del__(self) -> None:
48+
# make sure the generator is fully consumed, as it is possible
49+
# that neither __enter__() nor content() have been called
50+
collections.deque(self.gen, maxlen=0)
51+
52+
@property
53+
def content(self) -> Any:
54+
"""
55+
Fully consumes the underlying generator and returns the yielded value.
56+
57+
Returns:
58+
Value that would normally be the target variable of an associated with statement.
59+
60+
Raises:
61+
StopIteration if the underlying generator is already exhausted.
62+
"""
63+
result = next(self.gen)
64+
next(self.gen, None)
65+
return result
66+
67+
68+
class ContextManager:
69+
"""
70+
Class for decorating generator functions that should act as a context manager.
71+
72+
Just like with contextlib.contextmanager, the generator returned from the decorated function
73+
must yield exactly one value that will be used as the target variable of the with statement.
74+
If the same function with the same arguments is called again from within previously generated
75+
context, the generator will be ignored and the target variable will be reused.
76+
77+
Attributes:
78+
function: Decorated generator function.
79+
generators: Mapping of serialized function arguments to generators.
80+
values: Mapping of serialized function arguments to yielded values.
81+
"""
82+
83+
def __init__(self, function: Callable) -> None:
84+
self.function = function
85+
self.is_bound = False
86+
self.generators: Dict[bytes, Generator[Any, None, None]] = {}
87+
self.values: Dict[bytes, Any] = {}
88+
89+
@overload
90+
def __get__(self, obj: None, objtype: Optional[type] = None) -> "ContextManager":
91+
pass
92+
93+
@overload
94+
def __get__(self, obj: object, objtype: Optional[type] = None) -> types.MethodType:
95+
pass
96+
97+
# implementing __get__() makes the class a non-data descriptor,
98+
# so it can be used as method decorator
99+
def __get__(self, obj, objtype=None):
100+
if obj is None:
101+
return self
102+
self.is_bound = True
103+
return types.MethodType(self, obj)
104+
105+
def __call__(self, *args: Any, **kwargs: Any) -> GeneratorContextManager:
106+
# serialize the passed arguments, excluding cls/self if present
107+
key = pickle.dumps(
108+
(args[1:] if self.is_bound else args, sorted(kwargs.items())),
109+
protocol=pickle.HIGHEST_PROTOCOL,
110+
)
111+
if (
112+
key in self.generators
113+
# gi_frame is None only in case generator is exhausted
114+
and self.generators[key].gi_frame is not None # type: ignore[attr-defined]
115+
):
116+
# generator is suspended, use existing value
117+
def existing_value():
118+
try:
119+
yield self.values[key]
120+
except KeyError:
121+
# if the generator is being consumed in GeneratorContextManager destructor,
122+
# self.values[key] could have already been deleted
123+
pass
124+
125+
return GeneratorContextManager(existing_value)
126+
# create the generator
127+
self.generators[key] = self.function(*args, **kwargs)
128+
# first iteration yields the value
129+
self.values[key] = next(self.generators[key])
130+
131+
def new_value():
132+
try:
133+
yield self.values[key]
134+
finally:
135+
# second iteration wraps things up
136+
next(self.generators[key], None)
137+
# the generator is now exhausted and the value is no longer valid
138+
del self.generators[key]
139+
del self.values[key]
140+
141+
return GeneratorContextManager(new_value)

specfile/macros.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
import rpm
1010

11+
from specfile.context_management import capture_stderr
1112
from specfile.exceptions import MacroRemovalException, RPMException
12-
from specfile.utils import capture_stderr
1313

1414
MAX_REMOVAL_RETRIES = 20
1515

specfile/spec_parser.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111

1212
import rpm
1313

14+
from specfile.context_management import capture_stderr
1415
from specfile.exceptions import RPMException
1516
from specfile.macros import Macros
1617
from specfile.sections import Section
1718
from specfile.tags import Tags
18-
from specfile.utils import capture_stderr, get_filename_from_location
19+
from specfile.utils import get_filename_from_location
1920
from specfile.value_parser import ConditionalMacroExpansion, ShellExpansion, ValueParser
2021

2122
logger = logging.getLogger(__name__)

specfile/specfile.py

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

4-
import contextlib
54
import datetime
65
import re
76
import subprocess
87
import types
98
from dataclasses import dataclass
109
from pathlib import Path
11-
from typing import Iterator, List, Optional, Tuple, Type, Union
10+
from typing import Generator, List, Optional, Tuple, Type, Union
1211

1312
import rpm
1413

1514
from specfile.changelog import Changelog, ChangelogEntry
15+
from specfile.context_management import ContextManager
1616
from specfile.exceptions import SourceNumberException, SpecfileException
1717
from specfile.macro_definitions import MacroDefinition, MacroDefinitions
1818
from specfile.macros import Macro, Macros
@@ -148,8 +148,8 @@ def get_active_macros(self) -> List[Macro]:
148148
self._parser.parse(str(self))
149149
return Macros.dump()
150150

151-
@contextlib.contextmanager
152-
def lines(self) -> Iterator[List[str]]:
151+
@ContextManager
152+
def lines(self) -> Generator[List[str], None, None]:
153153
"""
154154
Context manager for accessing spec file lines.
155155
@@ -163,8 +163,8 @@ def lines(self) -> Iterator[List[str]]:
163163
if self.autosave:
164164
self.save()
165165

166-
@contextlib.contextmanager
167-
def macro_definitions(self) -> Iterator[MacroDefinitions]:
166+
@ContextManager
167+
def macro_definitions(self) -> Generator[MacroDefinitions, None, None]:
168168
"""
169169
Context manager for accessing macro definitions.
170170
@@ -178,8 +178,8 @@ def macro_definitions(self) -> Iterator[MacroDefinitions]:
178178
finally:
179179
lines[:] = macro_definitions.get_raw_data()
180180

181-
@contextlib.contextmanager
182-
def sections(self) -> Iterator[Sections]:
181+
@ContextManager
182+
def sections(self) -> Generator[Sections, None, None]:
183183
"""
184184
Context manager for accessing spec file sections.
185185
@@ -200,8 +200,10 @@ def parsed_sections(self) -> Optional[Sections]:
200200
return None
201201
return Sections.parse(self._parser.spec.parsed.splitlines())
202202

203-
@contextlib.contextmanager
204-
def tags(self, section: Union[str, Section] = "package") -> Iterator[Tags]:
203+
@ContextManager
204+
def tags(
205+
self, section: Union[str, Section] = "package"
206+
) -> Generator[Tags, None, None]:
205207
"""
206208
Context manager for accessing tags in a specified section.
207209
@@ -212,26 +214,21 @@ def tags(self, section: Union[str, Section] = "package") -> Iterator[Tags]:
212214
Yields:
213215
Tags in the section as `Tags` object.
214216
"""
215-
if isinstance(section, Section):
216-
raw_section = section
217-
parsed_section = getattr(self.parsed_sections, section.name, None)
217+
with self.sections() as sections:
218+
if isinstance(section, Section):
219+
raw_section = section
220+
parsed_section = getattr(self.parsed_sections, section.name, None)
221+
else:
222+
raw_section = getattr(sections, section)
223+
parsed_section = getattr(self.parsed_sections, section, None)
218224
tags = Tags.parse(raw_section, parsed_section)
219225
try:
220226
yield tags
221227
finally:
222228
raw_section.data = tags.get_raw_section_data()
223-
else:
224-
with self.sections() as sections:
225-
raw_section = getattr(sections, section)
226-
parsed_section = getattr(self.parsed_sections, section, None)
227-
tags = Tags.parse(raw_section, parsed_section)
228-
try:
229-
yield tags
230-
finally:
231-
raw_section.data = tags.get_raw_section_data()
232229

233-
@contextlib.contextmanager
234-
def changelog(self) -> Iterator[Optional[Changelog]]:
230+
@ContextManager
231+
def changelog(self) -> Generator[Optional[Changelog], None, None]:
235232
"""
236233
Context manager for accessing changelog.
237234
@@ -250,8 +247,8 @@ def changelog(self) -> Iterator[Optional[Changelog]]:
250247
finally:
251248
section.data = changelog.get_raw_section_data()
252249

253-
@contextlib.contextmanager
254-
def prep(self) -> Iterator[Optional[Prep]]:
250+
@ContextManager
251+
def prep(self) -> Generator[Optional[Prep], None, None]:
255252
"""
256253
Context manager for accessing %prep section.
257254
@@ -270,13 +267,13 @@ def prep(self) -> Iterator[Optional[Prep]]:
270267
finally:
271268
section.data = prep.get_raw_section_data()
272269

273-
@contextlib.contextmanager
270+
@ContextManager
274271
def sources(
275272
self,
276273
allow_duplicates: bool = False,
277274
default_to_implicit_numbering: bool = False,
278275
default_source_number_digits: int = 1,
279-
) -> Iterator[Sources]:
276+
) -> Generator[Sources, None, None]:
280277
"""
281278
Context manager for accessing sources.
282279
@@ -288,7 +285,7 @@ def sources(
288285
Yields:
289286
Spec file sources as `Sources` object.
290287
"""
291-
with self.sections() as sections, self.tags(sections.package) as tags:
288+
with self.sections() as sections, self.tags() as tags:
292289
sourcelists = [
293290
(s, Sourcelist.parse(s, context=self))
294291
for s in sections
@@ -307,13 +304,13 @@ def sources(
307304
for section, sourcelist in sourcelists:
308305
section.data = sourcelist.get_raw_section_data()
309306

310-
@contextlib.contextmanager
307+
@ContextManager
311308
def patches(
312309
self,
313310
allow_duplicates: bool = False,
314311
default_to_implicit_numbering: bool = False,
315312
default_source_number_digits: int = 1,
316-
) -> Iterator[Patches]:
313+
) -> Generator[Patches, None, None]:
317314
"""
318315
Context manager for accessing patches.
319316
@@ -325,7 +322,7 @@ def patches(
325322
Yields:
326323
Spec file patches as `Patches` object.
327324
"""
328-
with self.sections() as sections, self.tags(sections.package) as tags:
325+
with self.sections() as sections, self.tags() as tags:
329326
patchlists = [
330327
(s, Sourcelist.parse(s, context=self))
331328
for s in sections

specfile/utils.py

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,7 @@
22
# SPDX-License-Identifier: MIT
33

44
import collections
5-
import contextlib
6-
import io
7-
import os
85
import re
9-
import sys
10-
import tempfile
11-
from typing import Iterator, List
126

137
from specfile.constants import ARCH_NAMES
148
from specfile.exceptions import SpecfileException
@@ -120,30 +114,6 @@ def from_string(cls, nevra: str) -> "NEVRA":
120114
return cls(name=n, epoch=int(e) if e else 0, version=v, release=r, arch=a)
121115

122116

123-
@contextlib.contextmanager
124-
def capture_stderr() -> Iterator[List[bytes]]:
125-
"""
126-
Context manager for capturing output to stderr. A stderr output of anything run
127-
in its context will be captured in the target variable of the with statement.
128-
129-
Yields:
130-
List of captured lines.
131-
"""
132-
fileno = sys.__stderr__.fileno()
133-
with tempfile.TemporaryFile() as stderr, os.fdopen(os.dup(fileno)) as backup:
134-
sys.stderr.flush()
135-
os.dup2(stderr.fileno(), fileno)
136-
data: List[bytes] = []
137-
try:
138-
yield data
139-
finally:
140-
sys.stderr.flush()
141-
os.dup2(backup.fileno(), fileno)
142-
stderr.flush()
143-
stderr.seek(0, io.SEEK_SET)
144-
data.extend(stderr.readlines())
145-
146-
147117
def get_filename_from_location(location: str) -> str:
148118
"""
149119
Extracts filename from given source location.

tests/integration/test_specfile.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,18 @@ def test_shell_expansions(spec_shell_expansions):
391391
spec = Specfile(spec_shell_expansions)
392392
assert spec.expanded_version == "1035.4200"
393393
assert "C.UTF-8" in spec.expand("%numeric_locale")
394+
395+
396+
def test_context_management(spec_autosetup):
397+
spec = Specfile(spec_autosetup)
398+
with spec.tags() as tags:
399+
tags.license.value = "BSD"
400+
assert spec.license == "BSD"
401+
spec.license = "BSD-3-Clause"
402+
tags.patch0.value = "first_patch.patch"
403+
with spec.patches() as patches:
404+
assert patches[0].location == "first_patch.patch"
405+
patches[0].location = "patch_0.patch"
406+
assert spec.license == "BSD-3-Clause"
407+
with spec.patches() as patches:
408+
assert patches[0].location == "patch_0.patch"

0 commit comments

Comments
 (0)