Skip to content

Commit 7dfe638

Browse files
Parse only if necessary (#212)
Parse only if necessary Parsing before every macro expansion etc. is unnecessary and can cause performance issues, because parsing is a really slow operation. Parse only after a context switch (necessary because all Specfile instances share a single global macro context) or if something that affects parsing has actually changed. Related to rebase-helper/rebase-helper#910. RELEASE NOTES BEGIN Parsing the spec file by RPM is now performed only if really necessary, greatly improving performance in certain scenarios. RELEASE NOTES END Reviewed-by: Jiri Popelka
2 parents ae1219c + 9fb529c commit 7dfe638

File tree

7 files changed

+70
-29
lines changed

7 files changed

+70
-29
lines changed

specfile/context_management.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def capture_stderr() -> Generator[List[bytes], None, None]:
3939

4040
class GeneratorContextManager(contextlib._GeneratorContextManager):
4141
"""
42-
Extended contextlib._GeneratorContextManager that provides get() method.
42+
Extended contextlib._GeneratorContextManager that provides content property.
4343
"""
4444

4545
def __init__(self, function: Callable) -> None:

specfile/sections.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,7 @@ def parse(
231231

232232
def expand(s):
233233
if context:
234-
result = context.expand(
235-
s, skip_parsing=getattr(expand, "skip_parsing", False)
236-
)
237-
# parse only once
238-
expand.skip_parsing = True
239-
return result
234+
return context.expand(s)
240235
return Macros.expand(s)
241236

242237
def split_id(line):

specfile/sources.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
import re
66
import urllib.parse
77
from abc import ABC, abstractmethod
8-
from typing import Iterable, List, Optional, Tuple, Union, cast, overload
8+
from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Union, cast, overload
99

1010
from specfile.exceptions import DuplicateSourceException
1111
from specfile.formatter import formatted
1212
from specfile.sourcelist import Sourcelist, SourcelistEntry
1313
from specfile.tags import Comments, Tag, Tags
1414
from specfile.utils import get_filename_from_location
1515

16+
if TYPE_CHECKING:
17+
from specfile.specfile import Specfile
18+
1619

1720
class Source(ABC):
1821
"""Class that represents a source."""
@@ -236,6 +239,7 @@ def __init__(
236239
allow_duplicates: bool = False,
237240
default_to_implicit_numbering: bool = False,
238241
default_source_number_digits: int = 1,
242+
context: Optional["Specfile"] = None,
239243
) -> None:
240244
"""
241245
Constructs a `Sources` object.
@@ -256,6 +260,7 @@ def __init__(
256260
self._allow_duplicates = allow_duplicates
257261
self._default_to_implicit_numbering = default_to_implicit_numbering
258262
self._default_source_number_digits = default_source_number_digits
263+
self._context = context
259264

260265
def __eq__(self, other: object) -> bool:
261266
if not isinstance(other, Sources):
@@ -277,7 +282,7 @@ def __repr__(self) -> str:
277282
return (
278283
f"{self.__class__.__name__}({self._tags!r}, {self._sourcelists!r}, "
279284
f"{self._allow_duplicates!r}, {self._default_to_implicit_numbering!r}, "
280-
f"{self._default_source_number_digits!r})"
285+
f"{self._default_source_number_digits!r}, {self._context!r})"
281286
)
282287

283288
def __contains__(self, location: object) -> bool:
@@ -502,21 +507,25 @@ def insert(self, i: int, location: str) -> None:
502507
name, separator = self._get_tag_format(cast(TagSource, source), number)
503508
container.insert(
504509
index,
505-
Tag(name, location, separator, Comments()),
510+
Tag(name, location, separator, Comments(), context=self._context),
506511
)
507512
self._deduplicate_tag_names(i)
508513
else:
509514
container.insert(
510515
index,
511-
SourcelistEntry(location, Comments()), # type: ignore[arg-type]
516+
SourcelistEntry( # type: ignore[arg-type]
517+
location, Comments(), context=self._context
518+
),
512519
)
513520
elif self._sourcelists:
514-
self._sourcelists[-1].append(SourcelistEntry(location, Comments()))
521+
self._sourcelists[-1].append(
522+
SourcelistEntry(location, Comments(), context=self._context)
523+
)
515524
else:
516525
index, name, separator = self._get_initial_tag_setup()
517526
self._tags.insert(
518527
index,
519-
Tag(name, location, separator, Comments()),
528+
Tag(name, location, separator, Comments(), context=self._context),
520529
)
521530

522531
def insert_numbered(self, number: int, location: str) -> int:
@@ -549,7 +558,9 @@ def insert_numbered(self, number: int, location: str) -> int:
549558
else:
550559
i = 0
551560
index, name, separator = self._get_initial_tag_setup(number)
552-
self._tags.insert(index, Tag(name, location, separator, Comments()))
561+
self._tags.insert(
562+
index, Tag(name, location, separator, Comments(), context=self._context)
563+
)
553564
self._deduplicate_tag_names(i)
554565
return i
555566

specfile/spec_parser.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33

44
import contextlib
55
import copy
6+
import hashlib
67
import logging
78
import os
9+
import pickle
810
import re
911
import tempfile
1012
from pathlib import Path
@@ -41,6 +43,9 @@ class SpecParser:
4143
and were replaced with dummy files.
4244
"""
4345

46+
# hash of input parameters to the last parse performed
47+
_last_parse_hash = None
48+
4449
def __init__(
4550
self,
4651
sourcedir: Path,
@@ -325,11 +330,32 @@ def parse(
325330
Raises:
326331
RPMException, if parsing error occurs.
327332
"""
333+
# calculate hash of all input parameters
334+
payload = (
335+
id(self),
336+
self.sourcedir,
337+
self.macros,
338+
self.force_parse,
339+
content,
340+
extra_macros,
341+
)
342+
parse_hash = hashlib.sha256(
343+
pickle.dumps(payload, protocol=pickle.HIGHEST_PROTOCOL)
344+
).digest()
345+
if parse_hash == SpecParser._last_parse_hash:
346+
# none of the input parameters has changed, no need to parse again
347+
return
328348
if self.spec:
329349
# workaround RPM lua tables feature/bug, see above for details
330350
del self.spec
331351
try:
332-
self.spec, self.tainted = self._do_parse(content, extra_macros)
352+
try:
353+
self.spec, self.tainted = self._do_parse(content, extra_macros)
354+
except Exception:
355+
SpecParser._last_parse_hash = None
356+
raise
357+
else:
358+
SpecParser._last_parse_hash = parse_hash
333359
except RPMException:
334360
self.spec = None
335361
self.tainted = False

specfile/specfile.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ def __init__(
6464
self._parser = SpecParser(
6565
Path(sourcedir or self.path.parent), macros, force_parse
6666
)
67-
# parse here to fail early on parsing errors
6867
self._parser.parse(str(self))
6968

7069
def __eq__(self, other: object) -> bool:
@@ -161,23 +160,18 @@ def expand(
161160
self,
162161
expression: str,
163162
extra_macros: Optional[List[Tuple[str, str]]] = None,
164-
skip_parsing: bool = False,
165163
) -> str:
166164
"""
167165
Expands an expression in the context of the spec file.
168166
169167
Args:
170168
expression: Expression to expand.
171169
extra_macros: Extra macros to be defined before expansion is performed.
172-
skip_parsing: Do not parse the spec file before expansion is performed.
173-
Defaults to False. Mutually exclusive with extra_macros. Set this to True
174-
only if you are certain that the global macro context is up-to-date.
175170
176171
Returns:
177172
Expanded expression.
178173
"""
179-
if not skip_parsing:
180-
self._parser.parse(str(self), extra_macros)
174+
self._parser.parse(str(self), extra_macros)
181175
return Macros.expand(expression)
182176

183177
def get_active_macros(self) -> List[Macro]:
@@ -336,6 +330,7 @@ def sources(
336330
allow_duplicates,
337331
default_to_implicit_numbering,
338332
default_source_number_digits,
333+
context=self,
339334
)
340335
finally:
341336
for section, sourcelist in sourcelists:
@@ -372,6 +367,7 @@ def patches(
372367
allow_duplicates,
373368
default_to_implicit_numbering,
374369
default_source_number_digits,
370+
context=self,
375371
)
376372
finally:
377373
for section, patchlist in patchlists:

specfile/value_parser.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -312,12 +312,7 @@ def construct_regex(
312312

313313
def expand(s):
314314
if context:
315-
result = context.expand(
316-
s, skip_parsing=getattr(expand, "skip_parsing", False)
317-
)
318-
# parse only once
319-
expand.skip_parsing = True
320-
return result
315+
return context.expand(s)
321316
return Macros.expand(s)
322317

323318
def flatten(nodes):

tests/integration/test_specfile.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from specfile.exceptions import RPMException, SpecfileException
1313
from specfile.prep import AutopatchMacro, AutosetupMacro, PatchMacro, SetupMacro
1414
from specfile.sections import Section
15-
from specfile.specfile import Specfile
15+
from specfile.specfile import Specfile, SpecParser
1616

1717

1818
def test_parse(spec_multiple_sources):
@@ -481,3 +481,21 @@ def test_copy(spec_autosetup):
481481
assert deep_copy is not spec
482482
assert deep_copy._lines is not spec._lines
483483
assert deep_copy._parser is not spec._parser
484+
485+
486+
def test_parse_if_necessary(spec_macros):
487+
flexmock(SpecParser).should_call("_do_parse").once()
488+
spec1 = Specfile(spec_macros)
489+
spec2 = copy.deepcopy(spec1)
490+
flexmock(SpecParser).should_call("_do_parse").never()
491+
assert spec1.expanded_name == "test"
492+
flexmock(SpecParser).should_call("_do_parse").once()
493+
assert spec2.expanded_name == "test"
494+
assert spec2.expanded_version == "0.1.2~rc2"
495+
flexmock(SpecParser).should_call("_do_parse").once()
496+
assert spec1.expanded_version == "0.1.2~rc2"
497+
with spec1.macro_definitions() as md:
498+
md[0].body = "28"
499+
flexmock(SpecParser).should_call("_do_parse").once()
500+
assert spec1.expanded_name == "test"
501+
assert spec1.expanded_version == "28.1.2~rc2"

0 commit comments

Comments
 (0)