Repository: rebase-helper/rebase-helper (language: python)

Issue:
Migrate to specfile library



Discussion / hints:

Fix patch:
diff --git a/.packit.yml b/.packit.yml
index 8326ef138..e12ea39d6 100644
--- a/.packit.yml
+++ b/.packit.yml
@@ -1,32 +1,43 @@
+specfile_path: rebase-helper.spec
+
+# name in upstream package repository or registry (e.g. in PyPI)
+upstream_package_name: rebasehelper
+# downstream (Fedora) RPM package name
+downstream_package_name: rebase-helper
+
+copy_upstream_release_description: true
+
+upstream_project_url: https://github.com/rebase-helper/rebase-helper
+issue_repository: https://github.com/rebase-helper/rebase-helper
+
+actions:
+  create-archive:
+    - python3 setup.py sdist --base-name rebase-helper --dist-dir .
+    - bash -c "ls -1t ./rebase-helper-*.tar.gz | head -n 1"
+  fix-spec-file:
+    - bash -c "rpmdev-bumpspec -c \"Rebuild $PACKIT_PROJECT_COMMIT\" -n $(python3 setup.py --version) ./rebase-helper.spec"
+
+srpm_build_deps:
+  - python3-pip # "python3 setup.py --version" needs it
+  - python3-setuptools_scm
+
 jobs:
   - job: copr_build
     trigger: pull_request
-    metadata:
-      targets:
-        - fedora-all
-        - epel-8-x86_64
+    targets:
+      - fedora-all
+
   - job: propose_downstream
     trigger: release
-    metadata:
-      dist_git_branches:
-        - fedora-all
-        - epel8
+    dist_git_branches:
+      - fedora-all
+
   - job: koji_build
     trigger: commit
-    metadata:
-      dist_git_branches:
-        - fedora-all
-        - epel-8
+    dist_git_branches:
+      - fedora-all
+
   - job: bodhi_update
     trigger: commit
-    metadata:
-      dist_git_branches:
-        - fedora-branched # rawhide updates are created automatically
-        - epel-8
-actions:
-  create-archive:
-  - "python3 setup.py sdist --base-name rebase-helper --dist-dir ."
-  - "bash -c 'ls -1t ./rebase-helper-*.tar.gz | head -n 1'"
-  fix-spec-file:
-  - bash -c "rpmdev-bumpspec -c \"Rebuild $PACKIT_PROJECT_COMMIT\" -n $(python3 setup.py --version) ./rebase-helper.spec"
-copy_upstream_release_description: true
+    dist_git_branches:
+      - fedora-branched # rawhide updates are created automatically
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 000000000..f48543219
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,19 @@
+version: 2
+
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.9"
+  apt_packages:
+    - libkrb5-dev
+    - libcurl4-openssl-dev
+    - rpm
+    - cython3
+
+sphinx:
+  configuration: docs/conf.py
+
+python:
+  install:
+    - requirements: doc-requirements-gssapi.txt
+    - requirements: doc-requirements.txt
diff --git a/.readthedocs.yml b/.readthedocs.yml
deleted file mode 100644
index 8e62f8b94..000000000
--- a/.readthedocs.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-version: 2
-
-formats: all
-
-sphinx:
-    configuration: docs/conf.py
-    fail_on_warning: false
-
-python:
-    version: 3
-    install:
-        - requirements: doc-requirements.txt
diff --git a/doc-requirements-gssapi.txt b/doc-requirements-gssapi.txt
new file mode 100644
index 000000000..c50cf6d67
--- /dev/null
+++ b/doc-requirements-gssapi.txt
@@ -0,0 +1,1 @@
+https://github.com/pythongssapi/python-gssapi/archive/refs/tags/v1.8.0.tar.gz
diff --git a/docs/api/helpers/macrohelper.rst b/docs/api/helpers/macrohelper.rst
deleted file mode 100644
index baea46819..000000000
--- a/docs/api/helpers/macrohelper.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-Macro helper module
-===================
-
-.. automodule:: rebasehelper.helpers.macro_helper
-   :members:
-   :undoc-members:
diff --git a/docs/api/spec_content.rst b/docs/api/spec_content.rst
deleted file mode 100644
index c5c4e17d9..000000000
--- a/docs/api/spec_content.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-Spec content module
-===================
-
-.. automodule:: rebasehelper.spec_content
-   :members:
-   :undoc-members:
diff --git a/docs/api/tags.rst b/docs/api/tags.rst
deleted file mode 100644
index 197030197..000000000
--- a/docs/api/tags.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-Tags module
-===========
-
-.. automodule:: rebasehelper.tags
-   :members:
-   :undoc-members:
diff --git a/docs/user_guide/writing_plugins.rst b/docs/user_guide/writing_plugins.rst
index fbfbcf66e..159efa135 100644
--- a/docs/user_guide/writing_plugins.rst
+++ b/docs/user_guide/writing_plugins.rst
@@ -45,7 +45,8 @@ Example
            :param spec_file: SpecFile object representing original SPEC file
            :param rebase_spec_file: SpecFile object representing rebased SPEC file
            """
-           rebase_spec_file.spec_content.section('%package').insert(0, '# processed by {}\n'.format(cls.NAME))
+           with rebase_spec_file.spec.sections() as sections:
+               sections.package.insert(0, '# processed by {}\n'.format(cls.NAME))
            rebase_spec_file.save()
 
 .. code-block:: python
diff --git a/pylintrc b/pylintrc
index 15a5acb77..8b144b917 100644
--- a/pylintrc
+++ b/pylintrc
@@ -1,314 +1,619 @@
-[MASTER]
+[MAIN]
 
-# Specify a configuration file.
-#rcfile=
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+# Load and enable all available extensions. Use --list-extensions to see a list
+# all available extensions.
+#enable-all-extensions=
+
+# In error mode, messages with a category besides ERROR or FATAL are
+# suppressed, and no reports are done by default. Error mode is compatible with
+# disabling specific errors.
+#errors-only=
+
+# Always return a 0 (non-error) status code, even if lint errors are found.
+# This is primarily useful in continuous integration scripts.
+#exit-zero=
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-allow-list=rpm
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
+# for backward compatibility.)
+extension-pkg-whitelist=
+
+# Return non-zero exit code if any of these messages/categories are detected,
+# even if score is above --fail-under value. Syntax same as enable. Messages
+# specified are enabled, while categories only check already-enabled messages.
+fail-on=
+
+# Specify a score threshold under which the program will exit with error.
+fail-under=10
+
+# Interpret the stdin as a python script, whose filename needs to be passed as
+# the module_or_package argument.
+#from-stdin=
+
+# Files or directories to be skipped. They should be base names, not paths.
+ignore=CVS
+
+# Add files or directories matching the regular expressions patterns to the
+# ignore-list. The regex matches against paths and can be in Posix or Windows
+# format. Because '\' represents the directory delimiter on Windows systems, it
+# can't be used as an escape character.
+ignore-paths=
+
+# Files or directories matching the regular expression patterns are skipped.
+# The regex matches against base names, not paths. The default value ignores
+# Emacs file locks
+ignore-patterns=^\.#
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis). It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
 
 # Python code to execute, usually for sys.path manipulation such as
 # pygtk.require().
 #init-hook=
 
-# Add files or directories to the blacklist. They should be base names, not
-# paths.
-ignore=CVS
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use, and will cap the count on Windows to
+# avoid hangs.
+jobs=1
 
-# Pickle collected data for later comparisons.
-persistent=yes
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
 
-# List of plugins (as comma separated values of python modules names) to load,
+# List of plugins (as comma separated values of python module names) to load,
 # usually to register additional checkers.
 load-plugins=
 
-# Use multiple processes to speed up Pylint.
-jobs=1
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# Minimum Python version to use for version dependent checks. Will default to
+# the version used to run pylint.
+py-version=3.10
+
+# Discover python modules and packages in the file system subtree.
+recursive=no
+
+# When enabled, pylint would attempt to guess common misconfiguration and emit
+# user-friendly hints instead of false-positive error messages.
+suggestion-mode=yes
 
 # Allow loading of arbitrary C extensions. Extensions are imported into the
 # active Python interpreter and may run arbitrary code.
 unsafe-load-any-extension=no
 
-# A comma-separated list of package or module names from where C extensions may
-# be loaded. Extensions are loading into the active Python interpreter and may
-# run arbitrary code
-extension-pkg-whitelist=rpm
-
+# In verbose mode, extra non-checker-related info will be displayed.
+#verbose=
 
-[MESSAGES CONTROL]
 
-# Only show warnings with the listed confidence levels. Leave empty to show
-# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
-confidence=
+[BASIC]
 
-# Enable the message, report, category or checker with the given id(s). You can
-# either give multiple identifier separated by comma (,) or put this option
-# multiple time. See also the "--disable" option for examples.
-#enable=
+# Naming style matching correct argument names.
+argument-naming-style=snake_case
 
-# Disable the message, report, category or checker with the given id(s). You
-# can either give multiple identifiers separated by comma (,) or put this
-# option multiple times (only on the command line, not in the configuration
-# file where it should appear only once).You can also use "--disable=all" to
-# disable everything first and then reenable specific checks. For example, if
-# you want to run only the similarities checker, you can use "--disable=all
-# --enable=similarities". If you want to run only the classes checker, but have
-# no Warning level messages displayed, use"--disable=all --enable=classes
-# --disable=W"
-#disable=
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style. If left empty, argument names will be checked with the set
+# naming style.
+#argument-rgx=
 
+# Naming style matching correct attribute names.
+attr-naming-style=snake_case
 
-[REPORTS]
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style. If left empty, attribute names will be checked with the set naming
+# style.
+#attr-rgx=
 
-# Set the output format. Available formats are text, parseable, colorized, msvs
-# (visual studio) and html. You can also give a reporter class, eg
-# mypackage.mymodule.MyReporterClass.
-output-format=parseable
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+          bar,
+          baz,
+          toto,
+          tutu,
+          tata
 
-# Tells whether to display a full report or only the messages
-reports=no
+# Bad variable names regexes, separated by a comma. If names match any regex,
+# they will always be refused
+bad-names-rgxs=
 
-# Python expression which should return a note less than 10 (10 is the highest
-# note). You have access to the variables errors warning, statement which
-# respectively contain the number of errors / warnings messages and the total
-# number of statements analyzed. This is used by the global evaluation report
-# (RP0004).
-evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=any
 
-# Template used to display messages. This is a python new-style format string
-# used to format the message information. See doc for all details
-#msg-template=
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style. If left empty, class attribute names will be checked
+# with the set naming style.
+#class-attribute-rgx=
 
+# Naming style matching correct class constant names.
+class-const-naming-style=UPPER_CASE
 
-[TYPECHECK]
+# Regular expression matching correct class constant names. Overrides class-
+# const-naming-style. If left empty, class constant names will be checked with
+# the set naming style.
+#class-const-rgx=
 
-# Tells whether missing members accessed in mixin class should be ignored. A
-# mixin class is detected if its name ends with "mixin" (case insensitive).
-ignore-mixin-members=yes
+# Naming style matching correct class names.
+class-naming-style=PascalCase
 
-# List of module names for which member attributes should not be checked
-# (useful for modules/projects where namespaces are manipulated during runtime
-# and thus existing member attributes cannot be deduced by static analysis
-ignored-modules=distutils
+# Regular expression matching correct class names. Overrides class-naming-
+# style. If left empty, class names will be checked with the set naming style.
+#class-rgx=
 
-# List of classes names for which member attributes should not be checked
-# (useful for classes with attributes dynamically set).
-ignored-classes=SQLObject
+# Naming style matching correct constant names.
+const-naming-style=UPPER_CASE
 
-# List of members which are set dynamically and missed by pylint inference
-# system, and so shouldn't trigger E0201 when accessed. Python regular
-# expressions are accepted.
-generated-members=REQUEST,acl_users,aq_parent
+# Regular expression matching correct constant names. Overrides const-naming-
+# style. If left empty, constant names will be checked with the set naming
+# style.
+#const-rgx=
 
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
 
-[SPELLING]
+# Naming style matching correct function names.
+function-naming-style=snake_case
 
-# Spelling dictionary name. Available dictionaries: en_US (myspell).
-spelling-dict=
+# Regular expression matching correct function names. Overrides function-
+# naming-style. If left empty, function names will be checked with the set
+# naming style.
+#function-rgx=
 
-# List of comma separated words that should not be checked.
-spelling-ignore-words=
+# Good variable names which should always be accepted, separated by a comma.
+good-names=i,
+           j,
+           k,
+           ex,
+           Run,
+           _
 
-# A path to a file that contains private dictionary; one word per line.
-spelling-private-dict-file=
+# Good variable names regexes, separated by a comma. If names match any regex,
+# they will always be accepted
+good-names-rgxs=
 
-# Tells whether to store unknown words to indicated private dictionary in
-# --spelling-private-dict-file option instead of raising a message.
-spelling-store-unknown-words=no
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=no
 
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
 
-[SIMILARITIES]
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style. If left empty, inline iteration names will be checked
+# with the set naming style.
+#inlinevar-rgx=
 
-# Minimum lines number of a similarity.
-min-similarity-lines=4
+# Naming style matching correct method names.
+method-naming-style=snake_case
 
-# Ignore comments when computing similarities.
-ignore-comments=yes
+# Regular expression matching correct method names. Overrides method-naming-
+# style. If left empty, method names will be checked with the set naming style.
+#method-rgx=
 
-# Ignore docstrings when computing similarities.
-ignore-docstrings=yes
+# Naming style matching correct module names.
+module-naming-style=snake_case
 
-# Ignore imports when computing similarities.
-ignore-imports=no
+# Regular expression matching correct module names. Overrides module-naming-
+# style. If left empty, module names will be checked with the set naming style.
+#module-rgx=
 
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
 
-[VARIABLES]
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
 
-# Tells whether we should check for unused import in __init__ files.
-init-import=no
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
 
-# A regular expression matching the name of dummy variables (i.e. expectedly
-# not used).
-dummy-variables-rgx=_$|dummy
+# Regular expression matching correct type variable names. If left empty, type
+# variable names will be checked with the set naming style.
+#typevar-rgx=
 
-# List of additional names supposed to be defined in builtins. Remember that
-# you should avoid to define new builtins when possible.
-additional-builtins=
+# Naming style matching correct variable names.
+variable-naming-style=snake_case
 
-# List of strings which can identify a callback function by name. A callback
-# name must start or end with one of those strings.
-callbacks=cb_,_cb
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style. If left empty, variable names will be checked with the set
+# naming style.
+#variable-rgx=
 
 
-[MISCELLANEOUS]
+[CLASSES]
 
-# List of note tags to take in consideration, separated by a comma.
-notes=FIXME,XXX,TODO
+# Warn about protected attribute access inside special methods
+check-protected-access-in-special-methods=no
 
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+                      __new__,
+                      setUp,
+                      __post_init__
 
-[BASIC]
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,
+                  _fields,
+                  _replace,
+                  _source,
+                  _make
 
-# Good variable names which should always be accepted, separated by a comma
-good-names=i,j,k,ex,Run,_
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
 
-# Bad variable names which should always be refused, separated by a comma
-bad-names=foo,bar,baz,toto,tutu,tata
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=mcs
 
-# Colon-delimited sets of names that determine each other's naming style when
-# the name regexes allow several styles.
-name-group=
 
-# Include a hint for the correct naming format with invalid-name
-include-naming-hint=no
+[DESIGN]
 
-# Regular expression matching correct function names
-function-rgx=[a-z_][a-z0-9_]{2,30}$
+# List of regular expressions of class ancestor names to ignore when counting
+# public methods (see R0903)
+exclude-too-few-public-methods=
 
-# Regular expression matching correct variable names
-variable-rgx=[a-z_][a-z0-9_]{2,30}$
+# List of qualified class names to ignore when counting class parents (see
+# R0901)
+ignored-parents=
 
-# Regular expression matching correct constant names
-const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
+# Maximum number of arguments for function / method.
+max-args=5
 
-# Regular expression matching correct attribute names
-attr-rgx=[a-z_][a-z0-9_]{2,30}$
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
 
-# Regular expression matching correct argument names
-argument-rgx=[a-z_][a-z0-9_]{2,30}$
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
 
-# Regular expression matching correct class attribute names
-class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
+# Maximum number of branch for function / method body.
+max-branches=12
 
-# Regular expression matching correct inline iteration names
-inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
+# Maximum number of locals for function / method body.
+max-locals=15
 
-# Regular expression matching correct class names
-class-rgx=[A-Z_][a-zA-Z0-9]+$
+# Maximum number of parents for a class (see R0901).
+max-parents=7
 
-# Regular expression matching correct module names
-module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
 
-# Regular expression matching correct method names
-method-rgx=[a-z_][a-z0-9_]{2,30}$
+# Maximum number of return / yield for function / method body.
+max-returns=6
 
-# Regular expression which should only match function or class names that do
-# not require a docstring.
-no-docstring-rgx=__.*__
+# Maximum number of statements in function / method body.
+max-statements=50
 
-# Minimum line length for functions/classes that require docstrings, shorter
-# ones are exempt.
-docstring-min-length=-1
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
 
 
-[LOGGING]
+[EXCEPTIONS]
 
-# Logging modules to check that the string format arguments are in logging
-# function parameter format
-logging-modules=logging
+# Exceptions that will emit a warning when caught.
+overgeneral-exceptions=builtins.BaseException,
+                       builtins.Exception
 
 
 [FORMAT]
 
-# Maximum number of characters on a single line.
-max-line-length=120
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
 
 # Regexp for a line that is allowed to be longer than the limit.
 ignore-long-lines=^\s*(# )?<?https?://\S+>?$
 
-# Allow the body of an if to be on the same line as the test if there is no
-# else.
-single-line-if-stmt=no
-
-# Maximum number of lines in a module
-max-module-lines=1000
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
 
-# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
+# String used as indentation unit. This is usually "    " (4 spaces) or "\t" (1
 # tab).
 indent-string='    '
 
-# Number of spaces of indent required inside a hanging or continued line.
-indent-after-paren=4
+# Maximum number of characters on a single line.
+max-line-length=120
 
-# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
-expected-line-ending-format=
+# Maximum number of lines in a module.
+max-module-lines=1000
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=no
 
 
 [IMPORTS]
 
-# Deprecated modules which should not be used, separated by a comma
-deprecated-modules=regsub,TERMIOS,Bastion,rexec
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
 
-# Create a graph of every (i.e. internal and external) dependencies in the
-# given file (report RP0402 must not be disabled)
-import-graph=
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
 
-# Create a graph of external dependencies in the given file (report RP0402 must
-# not be disabled)
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=
+
+# Output a graph (.gv or any supported image format) of external dependencies
+# to the given file (report RP0402 must not be disabled).
 ext-import-graph=
 
-# Create a graph of internal dependencies in the given file (report RP0402 must
-# not be disabled)
+# Output a graph (.gv or any supported image format) of all (i.e. internal and
+# external) dependencies to the given file (report RP0402 must not be
+# disabled).
+import-graph=
+
+# Output a graph (.gv or any supported image format) of internal dependencies
+# to the given file (report RP0402 must not be disabled).
 int-import-graph=
 
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
 
-[DESIGN]
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
 
-# Maximum number of arguments for function / method
-max-args=5
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
 
-# Argument names that match this expression will be ignored. Default to name
-# with leading underscore
-ignored-argument-names=_.*
 
-# Maximum number of locals for function / method body
-max-locals=15
+[LOGGING]
 
-# Maximum number of return / yield for function / method body
-max-returns=6
+# The type of string formatting that logging methods do. `old` means using %
+# formatting, `new` is for `{}` formatting.
+logging-format-style=old
 
-# Maximum number of branch for function / method body
-max-branches=12
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
 
-# Maximum number of statements in function / method body
-max-statements=50
 
-# Maximum number of parents for a class (see R0901).
-max-parents=7
+[MESSAGES CONTROL]
 
-# Maximum number of attributes for a class (see R0902).
-max-attributes=7
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
+# UNDEFINED.
+confidence=HIGH,
+           CONTROL_FLOW,
+           INFERENCE,
+           INFERENCE_FAILURE,
+           UNDEFINED
 
-# Minimum number of public methods for a class (see R0903).
-min-public-methods=2
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then re-enable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=raw-checker-failed,
+        bad-inline-option,
+        locally-disabled,
+        file-ignored,
+        suppressed-message,
+        useless-suppression,
+        deprecated-pragma,
+        use-symbolic-message-instead
 
-# Maximum number of public methods for a class (see R0904).
-max-public-methods=20
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=c-extension-no-member
 
 
-[CLASSES]
+[METHOD_ARGS]
 
-# List of method names used to declare (i.e. assign) instance attributes.
-defining-attr-methods=__init__,__new__,setUp
+# List of qualified names (i.e., library.method) which require a timeout
+# parameter e.g. 'requests.api.get,requests.api.post'
+timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
 
-# List of valid names for the first argument in a class method.
-valid-classmethod-first-arg=cls
 
-# List of valid names for the first argument in a metaclass class method.
-valid-metaclass-classmethod-first-arg=mcs
+[MISCELLANEOUS]
 
-# List of member names, which should be excluded from the protected access
-# warning.
-exclude-protected=_asdict,_fields,_replace,_source,_make
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+      XXX,
+      TODO
 
+# Regular expression of note tags to take in consideration.
+notes-rgx=
 
-[EXCEPTIONS]
 
-# Exceptions that will emit a warning when being caught. Defaults to
-# "Exception"
-overgeneral-exceptions=Exception
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit,argparse.parse_error
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'fatal', 'error', 'warning', 'refactor',
+# 'convention', and 'info' which contain the number of messages in each
+# category, as well as 'statement' which is the total number of statements
+# analyzed. This score is used by the global evaluation report (RP0004).
+evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+msg-template=
+
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+#output-format=parseable
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[SIMILARITIES]
+
+# Comments are removed from the similarity computation
+ignore-comments=yes
+
+# Docstrings are removed from the similarity computation
+ignore-docstrings=yes
+
+# Imports are removed from the similarity computation
+ignore-imports=yes
+
+# Signatures are removed from the similarity computation
+ignore-signatures=yes
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. Available dictionaries: en (aspell), en_AU
+# (aspell), en_CA (aspell), en_GB (aspell), en_US (aspell).
+spelling-dict=
+
+# List of comma separated words that should be considered directives if they
+# appear at the beginning of a comment and should not be checked.
+spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[STRING]
+
+# This flag controls whether inconsistent-quotes generates a warning when the
+# character used as a quote delimiter is used inconsistently within a module.
+check-quote-consistency=no
+
+# This flag controls whether the implicit-str-concat should generate a warning
+# on implicit string concatenation in sequences defined over several lines.
+check-str-concat-over-line-jumps=no
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager,
+                          specfile.context_management.ContextManager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=yes
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=yes
+
+# List of symbolic message names to ignore for Mixin members.
+ignored-checks-for-mixins=no-member,
+                          not-async-context-manager,
+                          not-context-manager,
+                          attribute-defined-outside-init
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The minimum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# Regex pattern to define which classes are considered mixins.
+mixin-class-rgx=.*[Mm]ixin
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=yes
+
+# List of names allowed to shadow builtins
+allowed-redefined-builtins=
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+          _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
diff --git a/rebase-helper.spec b/rebase-helper.spec
index 607257061..2c9700510 100644
--- a/rebase-helper.spec
+++ b/rebase-helper.spec
@@ -18,6 +18,7 @@ BuildRequires:  python3-devel
 BuildRequires:  python3-setuptools
 BuildRequires:  python3-setuptools_scm
 BuildRequires:  python3-setuptools_scm_git_archive
+BuildRequires:  python3-specfile
 BuildRequires:  python3-rpm
 BuildRequires:  python3-rpkg
 BuildRequires:  python3-koji
diff --git a/rebasehelper/application.py b/rebasehelper/application.py
index 95d069b7c..a94c2445a 100644
--- a/rebasehelper/application.py
+++ b/rebasehelper/application.py
@@ -26,11 +26,11 @@
 import logging
 import os
 import shutil
-import urllib.parse
 from typing import Any, Dict, List, Optional, cast
 
 import git  # type: ignore
 from pkg_resources import parse_version
+from specfile.sources import Patch
 
 from rebasehelper.archive import Archive
 from rebasehelper.specfile import SpecFile, get_rebase_name
@@ -44,7 +44,6 @@
 from rebasehelper.exceptions import SourcePackageBuildError, BinaryPackageBuildError
 from rebasehelper.results_store import results_store
 from rebasehelper.helpers.path_helper import PathHelper
-from rebasehelper.helpers.macro_helper import MacroHelper
 from rebasehelper.helpers.input_helper import InputHelper
 from rebasehelper.helpers.git_helper import GitHelper
 from rebasehelper.helpers.koji_helper import KojiHelper
@@ -104,17 +103,20 @@ def __init__(self, cli_conf: Config, start_dir: str, execution_dir: str, results
             self._check_workspace_dir()
 
             # verify all sources for the new version are present
-            missing_sources = [os.path.basename(s) for s in self.rebase_spec_file.sources
-                               if not os.path.isfile(os.path.basename(s))]
+            missing_sources = [
+                s.expanded_filename
+                for s in self.rebase_spec_file.sources
+                if not (self.rebase_spec_file.spec.sourcedir / s.expanded_filename).is_file()
+            ]
             if missing_sources:
                 raise RebaseHelperError('The following sources are missing: {}'.format(','.join(missing_sources)))
 
             if self.conf.update_sources:
-                sources = [os.path.basename(s) for s in self.spec_file.sources]
-                rebased_sources = [os.path.basename(s) for s in self.rebase_spec_file.sources]
+                sources = [s.expanded_filename for s in self.spec_file.sources]
+                rebased_sources = [s.expanded_filename for s in self.rebase_spec_file.sources]
                 uploaded = LookasideCacheHelper.update_sources(self.conf.lookaside_cache_preset,
                                                                self.rebased_sources_dir,
-                                                               self.rebase_spec_file.header.name,
+                                                               self.rebase_spec_file.spec.expanded_name,
                                                                sources, rebased_sources,
                                                                upload=not self.conf.skip_upload)
                 self._update_gitignore(uploaded, self.rebased_sources_dir)
@@ -142,7 +144,7 @@ def _prepare_spec_objects(self):
         :return:
         """
         self.spec_file = SpecFile(self.spec_file_path, self.execution_dir, self.kwargs['rpmmacros'],
-                                  self.conf.lookaside_cache_preset, self.conf.keep_comments)
+                                  self.conf.lookaside_cache_preset)
         # Check whether test suite is enabled at build time
         if not self.spec_file.is_test_suite_enabled():
             results_store.set_info_text('WARNING', 'Test suite is not enabled at build time.')
@@ -151,7 +153,7 @@ def _prepare_spec_objects(self):
 
         if not self.conf.sources:
             self.conf.sources = plugin_manager.versioneers.run(self.conf.versioneer,
-                                                               self.spec_file.header.name,
+                                                               self.spec_file.spec.expanded_name,
                                                                self.spec_file.category,
                                                                self.conf.versioneer_blacklist)
             if self.conf.sources:
@@ -167,16 +169,16 @@ def _prepare_spec_objects(self):
         if [True for ext in Archive.get_supported_archives() if self.conf.sources.endswith(ext)]:
             logger.verbose("argument passed as a new source is a file")
             version_string = self.spec_file.extract_version_from_archive_name(self.conf.sources,
-                                                                              self.spec_file.get_main_source())
+                                                                              self.spec_file.get_raw_main_source())
         else:
             logger.verbose("argument passed as a new source is a version")
             version_string = self.conf.sources
-        version, extra_version = SpecFile.split_version_string(version_string, self.spec_file.header.version)
+        version, extra_version = SpecFile.split_version_string(version_string, self.spec_file.spec.expanded_version)
         self.rebase_spec_file.set_version(version)
-        self.rebase_spec_file.set_extra_version(extra_version, version != self.spec_file.header.version)
+        self.rebase_spec_file.set_extra_version(extra_version, version != self.spec_file.spec.expanded_version)
 
-        oldver = parse_version(self.spec_file.header.version)
-        newver = parse_version(self.rebase_spec_file.header.version)
+        oldver = parse_version(self.spec_file.spec.expanded_version)
+        newver = parse_version(self.rebase_spec_file.spec.expanded_version)
         oldex = self.spec_file.parse_release()[2]
         newex = extra_version
 
@@ -209,21 +211,28 @@ def _sanitize_sources(self) -> None:
         the new name. Modifies the rebase_spec_file object to contain the correct
         paths.
         """
-        for source, index, source_type in self.rebase_spec_file.spc.sources:
-            if urllib.parse.urlparse(source).scheme or source_type == 0:
+        for source in self.rebase_spec_file.all_sources:
+            if source.remote:
                 continue
-            if os.path.exists(os.path.join(self.execution_dir, source)):
+            if os.path.exists(os.path.join(self.execution_dir, source.expanded_filename)):
                 continue
             # Find matching source in the old spec
-            sources = [n for n, i, t in self.spec_file.spc.sources if i == index and t == source_type]
-            if not sources:
-                logger.error('Failed to find the source corresponding to %s in old version spec', source)
+            old_source = next(iter(
+                s
+                for s in self.spec_file.all_sources
+                if type(s) == type(source)
+                and s.number == source.number
+            ), None)
+            if not old_source:
+                logger.error(
+                    'Failed to find the source corresponding to %s in old version spec',
+                    source.expanded_filename,
+                )
                 continue
-            source_old = sources[0]
 
             # rename the source
-            old_source_path = os.path.join(self.rebased_sources_dir, source_old)
-            new_source_path = os.path.join(self.rebased_sources_dir, source)
+            old_source_path = os.path.join(self.rebased_sources_dir, old_source.expanded_filename)
+            new_source_path = os.path.join(self.rebased_sources_dir, source.expanded_filename)
             logger.debug('Renaming %s to %s', old_source_path, new_source_path)
             try:
                 os.rename(old_source_path, new_source_path)
@@ -232,16 +241,22 @@ def _sanitize_sources(self) -> None:
 
             # prepend the Source/Path with rebased-sources directory in the specfile
             to_prepend = os.path.relpath(self.rebased_sources_dir, self.execution_dir)
-            tag = '{0}{1}'.format('Patch' if source_type == 2 else 'Source', index)
-            value = self.rebase_spec_file.get_raw_tag_value(tag)
-            self.rebase_spec_file.set_raw_tag_value(tag, os.path.join(to_prepend, value))
+            sources = (
+                self.rebase_spec_file.spec.patches
+                if isinstance(source, Patch)
+                else self.rebase_spec_file.spec.sources
+            )
+            with sources() as srcs:
+                next(
+                    s for s in srcs if s.number == source.number
+                ).location = os.path.join(to_prepend, source.location)
         self.rebase_spec_file.save()
 
     def _initialize_data(self):
         """Function fill dictionary with default data"""
         # Get all tarballs before self.kwargs initialization
-        self.old_sources = self.spec_file.get_archive()
-        new_sources = self.rebase_spec_file.get_archive()
+        self.old_sources = self.spec_file.get_main_source()
+        new_sources = self.rebase_spec_file.get_main_source()
 
         self.old_sources = os.path.abspath(self.old_sources)
         if new_sources:
@@ -252,8 +267,8 @@ def _initialize_data(self):
         else:
             self.new_sources = os.path.abspath(self.conf.sources)
         # Contains all sources except the main source
-        self.old_rest_sources = [os.path.abspath(x) for x in self.spec_file.get_sources()[1:]]
-        self.new_rest_sources = [os.path.abspath(x) for x in self.rebase_spec_file.get_sources()[1:]]
+        self.old_rest_sources = [os.path.abspath(x) for x in self.spec_file.get_other_sources()]
+        self.new_rest_sources = [os.path.abspath(x) for x in self.rebase_spec_file.get_other_sources()]
 
     def _find_spec_file(self) -> str:
         """Finds a spec file in the execution_dir directory.
@@ -450,7 +465,7 @@ def generate_patch(self):
         # Generate patch
         self.rebased_repo.git.add(all=True)
         self.rebase_spec_file.update()
-        self.rebased_repo.index.commit(MacroHelper.expand(self.conf.changelog_entry, self.conf.changelog_entry))
+        self.rebased_repo.index.commit(self.rebase_spec_file.expand(self.conf.changelog_entry, self.conf.changelog_entry))
         patch = self.rebased_repo.git.format_patch('-1', stdout=True, stdout_as_string=False)
         with open(os.path.join(self.results_dir, constants.CHANGES_PATCH), 'wb') as f:
             f.write(patch)
@@ -491,10 +506,10 @@ def _prepare_rebased_repository(self):
         Initialize git repository in the rebased directory
         :return: git.Repo instance of rebased_sources
         """
-        for source, _, source_type in self.spec_file.spc.sources:
+        for source in self.spec_file.all_sources:
             # copy only existing local sources
-            if not urllib.parse.urlparse(source).scheme and source_type == 1:
-                source_path = os.path.join(self.execution_dir, source)
+            if not source.remote:
+                source_path = os.path.join(self.execution_dir, source.filename)
                 if os.path.isfile(source_path):
                     shutil.copy(source_path, self.rebased_sources_dir)
 
@@ -538,8 +553,8 @@ def build_source_packages(self):
             koji_build_id = None
             results_dir = os.path.join(self.results_dir, '{}-build'.format(version), 'SRPM')
             spec = self.spec_file if version == 'old' else self.rebase_spec_file
-            package_name = spec.header.name
-            package_version = spec.header.version
+            package_name = spec.spec.expanded_name
+            package_version = spec.spec.expanded_version
             logger.info('Building source package for %s version %s', package_name, package_version)
 
             if version == 'old' and self.conf.get_old_build_from_koji:
@@ -600,8 +615,8 @@ def build_binary_packages(self):
 
             if self.conf.build_tasks is None:
                 spec = self.spec_file if version == 'old' else self.rebase_spec_file
-                package_name = spec.header.name
-                package_version = spec.header.version
+                package_name = spec.spec.expanded_name
+                package_version = spec.spec.expanded_version
 
                 if version == 'old' and self.conf.get_old_build_from_koji:
                     koji_build_id, ver = KojiHelper.get_old_build_info(package_name, package_version)
@@ -757,7 +772,7 @@ def prepare_next_run(self, results_dir):
         self.rebase_spec_file.save()
         if not self.conf.non_interactive and \
                 InputHelper.get_message('Do you want to try it one more time'):
-            logger.info('Now it is time to make changes to  %s if necessary.', self.rebase_spec_file.path)
+            logger.info('Now it is time to make changes to  %s if necessary.', self.rebase_spec_file.spec.path)
         elif self.conf.non_interactive and changes_made:
             logger.info('Build log hooks made some changes to the SPEC file, starting the build process again.')
         else:
diff --git a/rebasehelper/helpers/koji_helper.py b/rebasehelper/helpers/koji_helper.py
index c7fe95781..0ded80b5d 100644
--- a/rebasehelper/helpers/koji_helper.py
+++ b/rebasehelper/helpers/koji_helper.py
@@ -31,9 +31,10 @@
 
 from typing import List, cast
 
+from specfile.utils import NEVRA
+
 from rebasehelper.exceptions import RebaseHelperError
 from rebasehelper.helpers.console_helper import ConsoleHelper
-from rebasehelper.helpers.rpm_helper import RpmHelper
 from rebasehelper.helpers.download_helper import DownloadHelper
 from rebasehelper.logger import CustomLogger
 
@@ -257,9 +258,9 @@ def download_task_results(cls, session, tasklist, destination):
                         if task['state'] != koji.TASK_STATES['CLOSED']:
                             continue
                         if local_path not in rpms:
-                            nevra = RpmHelper.split_nevra(fn)
+                            nevra = NEVRA.from_string(fn)
                             # FIXME: multiple arches
-                            download = nevra['arch'] in ['noarch', 'x86_64']
+                            download = nevra.arch in ['noarch', 'x86_64']
                             if download:
                                 rpms.append(local_path)
                     else:
diff --git a/rebasehelper/helpers/macro_helper.py b/rebasehelper/helpers/macro_helper.py
deleted file mode 100644
index 2f6c9c639..000000000
--- a/rebasehelper/helpers/macro_helper.py
+++ /dev/null
@@ -1,188 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
-
-import re
-from typing import List
-
-import rpm  # type: ignore
-from pkg_resources import parse_version
-
-from rebasehelper.helpers.console_helper import ConsoleHelper
-
-
-class MacroHelper:
-
-    """Class for working with RPM macros"""
-
-    MACROS_WHITELIST: List[str] = [
-        '_bindir',
-        '_datadir',
-        '_includedir',
-        '_infodir',
-        '_initdir',
-        '_libdir',
-        '_libexecdir',
-        '_localstatedir',
-        '_mandir',
-        '_sbindir',
-        '_sharedstatedir',
-        '_sysconfdir',
-        'python2_sitelib',
-        'python3_sitelib',
-    ]
-
-    @staticmethod
-    def expand(s, default=None, suppress_errors=False):
-        try:
-            if not suppress_errors:
-                return rpm.expandMacro(s)
-            with ConsoleHelper.Capturer(stderr=True):
-                return rpm.expandMacro(s)
-        except rpm.error:
-            return default
-
-    @classmethod
-    def purge_macro(cls, macro: str) -> None:
-        m = '%{{{}}}'.format(macro)
-        while cls.expand(m, m) != m:
-            with ConsoleHelper.Capturer(stderr=True):
-                rpm.delMacro(macro)
-
-    @classmethod
-    def expand_macros(cls, macros):
-        """Expands values of multiple macros.
-
-        Args:
-            macros (list): List of macros to be expanded, macros are
-                represented as dicts.
-
-        Returns:
-            list: List of macros with expanded values.
-
-        """
-        for macro in macros:
-            macro['value'] = cls.expand(macro['value'])
-        return macros
-
-    @staticmethod
-    def substitute_path_with_macros(path, macros):
-        """Substitutes parts of a path with macros.
-
-        Args:
-            path (str): Path to be changed.
-            macros (list): Macros which can be used as a substitution.
-
-        Returns:
-            str: Path expressed using macros.
-
-        """
-        for m in macros:
-            if m['value'] and m['value'] in path:
-                path = path.replace(m['value'], '%{{{}}}'.format(m['name']))
-
-        return path
-
-    @staticmethod
-    def dump():
-        """Gets list of all defined macros.
-
-        Returns:
-            list: All defined macros.
-
-        """
-        macro_re = re.compile(
-            r'''
-            ^\s*
-            (?P<level>-?\d+)
-            (?P<used>=|:)
-            [ ]
-            (?P<name>\w+)
-            (?P<options>\(.+?\))?
-            [\t]
-            (?P<value>.*)
-            $
-            ''',
-            re.VERBOSE)
-
-        with ConsoleHelper.Capturer(stderr=True) as capturer:
-            rpm.expandMacro('%dump')
-
-        macros = []
-
-        def add_macro(properties):
-            macro = dict(properties)
-            macro['used'] = macro['used'] == '='
-            macro['level'] = int(macro['level'])
-            if parse_version(rpm.__version__) < parse_version('4.13.90'):
-                # in RPM < 4.13.90 level of some macros is decreased by 1
-                if macro['level'] == -1:
-                    # this could be macro with level -1 or level 0, we can not be sure
-                    # so just duplicate the macro for both levels
-                    macros.append(macro)
-                    macro = dict(macro)
-                    macro['level'] = 0
-                    macros.append(macro)
-                elif macro['level'] in (-14, -16):
-                    macro['level'] += 1
-                    macros.append(macro)
-                else:
-                    macros.append(macro)
-            else:
-                macros.append(macro)
-
-        lines = capturer.stderr.strip().split('\n')
-        # last line contains only summary
-        lines.pop()
-        while lines:
-            line = lines.pop(0)
-            # squash lines ending with \
-            while line.endswith('\\'):
-                line = line[:-1] + lines.pop(0)
-            match = macro_re.match(line)
-            if match:
-                add_macro(match.groupdict())
-            elif macros:
-                # part of the value of the last parsed macro
-                macros[-1]['value'] += '\n' + line
-
-        return macros
-
-    @staticmethod
-    def filter(macros, **kwargs):
-        """Finds all macros satisfying certain conditions.
-
-        Args:
-            macros (list): Macros to be filtered.
-            **kwargs: Filters to be used.
-
-        Returns:
-            list: Macros satisfying the conditions.
-
-        """
-        def _test(macro):
-            return all(macro.get(k[4:]) >= v if k.startswith('min_') else
-                       macro.get(k[4:]) <= v if k.startswith('max_') else
-                       macro.get(k) == v for k, v in kwargs.items())
-
-        return [m for m in macros if _test(m)]
diff --git a/rebasehelper/helpers/rpm_helper.py b/rebasehelper/helpers/rpm_helper.py
index a5afb326a..87314d53c 100644
--- a/rebasehelper/helpers/rpm_helper.py
+++ b/rebasehelper/helpers/rpm_helper.py
@@ -24,18 +24,13 @@
 
 import logging
 import os
-import re
-import tempfile
-from typing import Any, Dict, List, cast
+from typing import Any, List, cast
 
 import rpm  # type: ignore
 
 from rebasehelper.constants import ENCODING
-from rebasehelper.exceptions import RebaseHelperError
 from rebasehelper.logger import CustomLogger
-from rebasehelper.helpers.macro_helper import MacroHelper
 from rebasehelper.helpers.process_helper import ProcessHelper
-from rebasehelper.helpers.console_helper import ConsoleHelper
 
 
 logger: CustomLogger = cast(CustomLogger, logging.getLogger(__name__))
@@ -130,77 +125,3 @@ def get_header_from_rpm(rpm_name):
         with open(rpm_name, "r", encoding=ENCODING) as f:
             hdr = ts.hdrFromFdno(f)
         return RpmHeader(hdr)
-
-    @staticmethod
-    def get_arches():
-        """Gets list of all known architectures"""
-        arches = ['aarch64', 'noarch', 'ppc', 'riscv64', 's390', 's390x', 'src', 'x86_64']
-        macros = MacroHelper.dump()
-        macros = [m for m in macros if m['name'] in ('ix86', 'arm', 'mips', 'sparc', 'alpha', 'power64')]
-        for m in macros:
-            arches.extend(MacroHelper.expand(m['value'], '').split())
-        return arches
-
-    @classmethod
-    def split_nevra(cls, s):
-        """Splits string into name, epoch, version, release and arch components"""
-        regexps = [
-            ('NEVRA', re.compile(r'^([^:]+)-(([0-9]+):)?([^-:]+)-(.+)\.([^.]+)$')),
-            ('NEVR', re.compile(r'^([^:]+)-(([0-9]+):)?([^-:]+)-(.+)()$')),
-            ('NA', re.compile(r'^([^:]+)()()()()\.([^.]+)$')),
-            ('N', re.compile(r'^([^:]+)()()()()()$')),
-        ]
-        if not cls.ARCHES:
-            cls.ARCHES = cls.get_arches()
-        for pattern, regexp in regexps:
-            match = regexp.match(s)
-            if not match:
-                continue
-            name = match.group(1) or None
-            epoch = match.group(3) or None
-            if epoch:
-                epoch = int(epoch)
-            version = match.group(4) or None
-            release = match.group(5) or None
-            arch = match.group(6) or None
-            if pattern == 'NEVRA' and arch not in cls.ARCHES:
-                # unknown arch, let's assume it's actually dist
-                continue
-            return dict(name=name, epoch=epoch, version=version, release=release, arch=arch)
-        raise RebaseHelperError('Unable to split string into NEVRA.')
-
-    @classmethod
-    def parse_spec(cls, path, flags=None):
-        with open(path, 'rb') as orig:
-            with tempfile.NamedTemporaryFile() as tmp:
-                # remove BuildArch to workaround rpm bug
-                tmp.write(b''.join(l for l in orig.readlines() if not l.startswith(b'BuildArch')))
-                tmp.flush()
-                capturer = None
-                try:
-                    with ConsoleHelper.Capturer(stderr=True) as capturer:
-                        result = rpm.spec(tmp.name, flags) if flags is not None else rpm.spec(tmp.name)
-                except ValueError as e:
-                    output = capturer.stderr.strip().split('\n') if capturer else []
-                    if len(output) == 1:
-                        output = output[0]
-                    raise RebaseHelperError('Failed to parse SPEC file{0}'.format(
-                        ': ' + str(output) if output else '')) from e
-                return result
-
-    @classmethod
-    def get_rpm_spec(cls, path: str, sourcedir: str, predefined_macros: Dict[str, str]) -> rpm.spec:
-        # reset all macros and settings
-        rpm.reloadConfig()
-        # ensure that %{_sourcedir} macro is set to proper location
-        MacroHelper.purge_macro('_sourcedir')
-        rpm.addMacro('_sourcedir', sourcedir)
-        # add predefined macros
-        for macro, value in predefined_macros.items():
-            rpm.addMacro(macro, value)
-        try:
-            spec = cls.parse_spec(path, flags=rpm.RPMSPEC_ANYARCH)
-        except RebaseHelperError:
-            # try again with RPMSPEC_FORCE flag (the default)
-            spec = cls.parse_spec(path)
-        return spec
diff --git a/rebasehelper/options.py b/rebasehelper/options.py
index f944eb7f9..8a3e9b627 100644
--- a/rebasehelper/options.py
+++ b/rebasehelper/options.py
@@ -181,13 +181,6 @@
         "dest": "disable_inapplicable_patches",
         "help": "disable inapplicable patches in rebased SPEC file",
     },
-    {
-        "name": ["--keep-comments"],
-        "default": False,
-        "switch": True,
-        "dest": "keep_comments",
-        "help": "do not remove associated comments when removing patches from spec file",
-    },
     {
         "name": ["--skip-version-check"],
         "default": False,
diff --git a/rebasehelper/plugins/build_log_hooks/files.py b/rebasehelper/plugins/build_log_hooks/files.py
index 09c1b4996..f964e6e6c 100644
--- a/rebasehelper/plugins/build_log_hooks/files.py
+++ b/rebasehelper/plugins/build_log_hooks/files.py
@@ -30,12 +30,14 @@
 import re
 from typing import Any, Dict, List, Optional, Tuple, Union, cast
 
+from specfile.macros import MacroLevel
+from specfile.sections import Section
+
 from rebasehelper.logger import CustomLogger
 from rebasehelper.plugins.build_log_hooks import BaseBuildLogHook
 from rebasehelper.types import PackageCategories
-from rebasehelper.helpers.macro_helper import MacroHelper
 from rebasehelper.constants import NEW_BUILD_DIR, ENCODING
-from rebasehelper.specfile import SpecFile
+from rebasehelper.specfile import SpecFile, MACROS_WHITELIST
 
 
 logger: CustomLogger = cast(CustomLogger, logging.getLogger(__name__))
@@ -202,16 +204,16 @@ def _get_best_matching_files_section(cls, rebase_spec_file, file):
         best_match = ''
         best_match_section = ''
         files = []
-        for sec_name, sec_content in rebase_spec_file.spec_content.sections:
-            if sec_name.startswith('%files'):
-                files.append(sec_name)
-                for line in sec_content:
-                    new_best_match = difflib.get_close_matches(file, [best_match, MacroHelper.expand(line)])
+        for section in rebase_spec_file.spec.sections().content: # pylint: disable=no-member
+            if section.normalized_id.startswith('files'):
+                files.append(section.id)
+                for line in section:
+                    new_best_match = difflib.get_close_matches(file, [best_match, rebase_spec_file.spec.expand(line)])
                     if new_best_match:
                         # the new match is a closer match
                         if new_best_match[0] != best_match:
                             best_match = str(new_best_match[0])
-                            best_match_section = sec_name
+                            best_match_section = section.id
 
         return best_match_section or rebase_spec_file.get_main_files_section() or (files[0] if files else None)
 
@@ -232,28 +234,30 @@ def _correct_missing_files(cls, rebase_spec_file, files):
         with the closest matching path.
 
         """
-        # get %{name} macro
-        macros = [m for m in MacroHelper.filter(rebase_spec_file.macros, level=-3) if m['name'] == 'name']
-        macros.extend(m for m in rebase_spec_file.macros if m['name'] in MacroHelper.MACROS_WHITELIST)
-        macros = MacroHelper.expand_macros(macros)
-        # ensure maximal greediness
-        macros.sort(key=lambda k: len(k['value']), reverse=True)
-
         result: Dict[str, AddedFiles] = collections.defaultdict(lambda: collections.defaultdict(list))
         for file in files:
-            section = cls._get_best_matching_files_section(rebase_spec_file, file)
-            if section is None:
+            section_name = cls._get_best_matching_files_section(rebase_spec_file, file)
+            if section_name is None:
                 logger.error('The specfile does not contain any %files section, cannot add the missing files')
                 break
-            substituted_path = cls._sanitize_path(MacroHelper.substitute_path_with_macros(file, macros))
-            try:
-                index = [i for i, l in enumerate(rebase_spec_file.spec_content.section(section)) if l][-1] + 1
-            except IndexError:
-                # section is empty
-                index = 0
-            rebase_spec_file.spec_content.section(section).insert(index, substituted_path)
-            result['added'][section].append(substituted_path)
-            logger.info("Added %s to '%s' section", substituted_path, section)
+            substituted_path = cls._sanitize_path(
+                rebase_spec_file.substitute_path_with_macros(
+                    file,
+                    lambda m: m.level == MacroLevel.SPEC
+                    and m.name == "name"
+                    or m.name in MACROS_WHITELIST,
+                )
+            )
+            with rebase_spec_file.spec.sections() as sections:
+                section = getattr(sections, section_name)
+                try:
+                    index = [i for i, l in enumerate(section) if l][-1] + 1
+                except IndexError:
+                    # section is empty
+                    index = 0
+                section.insert(index, substituted_path)
+            result['added']['%' + section_name].append(substituted_path)
+            logger.info("Added %s to '%s' section", substituted_path, '%' + section_name)
 
         return result
 
@@ -281,11 +285,19 @@ def _get_line_directives(cls, split_line) -> Tuple[List[str], Optional[str]]:
         return directives, prepended_directive
 
     @classmethod
-    def _correct_one_section(cls, subpackage: str, sec_name: str, sec_content: List[str], files: List[str],
-                             result: Dict[str, RemovedFromSections]) -> None:
+    def _correct_one_section(
+        cls,
+        spec: SpecFile,
+        subpackage: str,
+        sec_name: str,
+        sec_content: Section,
+        files: List[str],
+        result: Dict[str, RemovedFromSections],
+    ) -> None:
         """Removes deleted files from one %files section.
 
         Args:
+            spec: SPEC file to remove the files from.
             subpackage: Name of the subpackage which the section relates to.
             sec_name: Name of the %files section
             sec_content: Content of the %files section
@@ -299,7 +311,7 @@ def _correct_one_section(cls, subpackage: str, sec_name: str, sec_content: List[
             # Expand the whole line to check for occurrences of special
             # keywords, such as %global and %if blocks. Macro definitions
             # expand to empty string.
-            expanded = MacroHelper.expand(sec_content[i])
+            expanded = spec.spec.expand(sec_content[i])
             if not original_line or not expanded or any(k in expanded for k in cls.PROHIBITED_KEYWORDS):
                 i += 1
                 continue
@@ -318,7 +330,7 @@ def _correct_one_section(cls, subpackage: str, sec_name: str, sec_content: List[
                         prepend_macro = cls.FILES_DIRECTIVES[prepended_directive] or ''
                         split_line[j] = os.path.join(prepend_macro, subpackage, os.path.basename(path))
                         possible_rename[j] = True
-            split_line = [MacroHelper.expand(p) for p in split_line]
+            split_line = [spec.spec.expand(p) for p in split_line]
 
             j = 0
             while j < len(split_line) and files:
@@ -334,8 +346,8 @@ def _correct_one_section(cls, subpackage: str, sec_name: str, sec_content: List[
                     del split_line[j]
                     del original_line[len(directives) + j]
                     files.remove(deleted_file)
-                    result['removed'][sec_name].append(original_file)
-                    logger.info("Removed %s from '%s' section", original_file, sec_name)
+                    result['removed']['%' + sec_name].append(original_file)
+                    logger.info("Removed %s from '%s' section", original_file, '%' + sec_name)
                     if warn_about_rename:
                         logger.warning("The installation of %s was handled by %s directive and the file has now been "
                                        "removed. The file may have been renamed and rebase-helper cannot automatically "
@@ -367,13 +379,14 @@ def _correct_deleted_files(cls, rebase_spec_file: SpecFile, files: List[str]) ->
 
         """
         result: Dict[str, RemovedFromSections] = collections.defaultdict(lambda: collections.defaultdict(list))
-        for sec_name, sec_content in rebase_spec_file.spec_content.sections:
-            if sec_name.startswith('%files'):
-                subpackage = rebase_spec_file.get_subpackage_name(sec_name)
-                cls._correct_one_section(subpackage, sec_name, sec_content, files, result)
-                if not files:
-                    # Nothing more to be done
-                    return cast(Dict[str, RemovedFiles], result)
+        with rebase_spec_file.spec.sections() as sections:
+            for section in sections:
+                if section.normalized_id.startswith('files'):
+                    subpackage = rebase_spec_file.get_subpackage_name(section.id)
+                    cls._correct_one_section(rebase_spec_file, subpackage, section.id, section, files, result)
+                    if not files:
+                        # Nothing more to be done
+                        return cast(Dict[str, RemovedFiles], result)
 
         result_with_irremovable: Dict[str, RemovedFiles] = cast(Dict[str, RemovedFiles], result)
         logger.info('Could not remove the following files:')
diff --git a/rebasehelper/plugins/build_tools/rpm/mock.py b/rebasehelper/plugins/build_tools/rpm/mock.py
index 489186160..9c0c0c6b8 100644
--- a/rebasehelper/plugins/build_tools/rpm/mock.py
+++ b/rebasehelper/plugins/build_tools/rpm/mock.py
@@ -105,7 +105,7 @@ def build(cls, spec, results_dir, srpm, **kwargs):
         """
         sources = spec.get_sources()
         patches = [p.path for p in spec.get_patches()]
-        with MockTemporaryEnvironment(sources, patches, spec.path, results_dir) as tmp_env:
+        with MockTemporaryEnvironment(sources, patches, spec.spec.path, results_dir) as tmp_env:
             env = tmp_env.env()
             tmp_results_dir = env.get(MockTemporaryEnvironment.TEMPDIR_RESULTS)
             rpms, logs = cls._build_rpm(srpm, tmp_results_dir, results_dir,
diff --git a/rebasehelper/plugins/build_tools/rpm/rpmbuild.py b/rebasehelper/plugins/build_tools/rpm/rpmbuild.py
index c50efe8b5..9aebee45e 100644
--- a/rebasehelper/plugins/build_tools/rpm/rpmbuild.py
+++ b/rebasehelper/plugins/build_tools/rpm/rpmbuild.py
@@ -29,7 +29,7 @@
 from rebasehelper.helpers.process_helper import ProcessHelper
 from rebasehelper.helpers.input_helper import InputHelper
 from rebasehelper.helpers.path_helper import PathHelper
-from rebasehelper.helpers.rpm_helper import RpmHelper
+from rebasehelper.helpers.rpm_helper import RpmHelper, RpmHeader
 from rebasehelper.logger import CustomLogger
 from rebasehelper.plugins.build_tools import RpmbuildTemporaryEnvironment
 from rebasehelper.plugins.build_tools.rpm import BuildToolBase
@@ -92,10 +92,10 @@ def prepare(cls, spec, conf):
 
         :param spec: SpecFile object
         """
-        if not RpmHelper.all_packages_installed(spec.header.requires):
+        if not RpmHelper.all_packages_installed(RpmHeader(spec.spec.rpm_spec.sourceHeader).requires):
             question = '\nSome build dependencies are missing. Do you want to install them now'
             if conf.non_interactive or InputHelper.get_message(question):
-                if RpmHelper.install_build_dependencies(spec.path, assume_yes=conf.non_interactive) != 0:
+                if RpmHelper.install_build_dependencies(spec.spec.path, assume_yes=conf.non_interactive) != 0:
                     raise RebaseHelperError('Failed to install build dependencies')
 
     @classmethod
@@ -112,7 +112,7 @@ def build(cls, spec, results_dir, srpm, **kwargs):
         """
         sources = spec.get_sources()
         patches = [p.path for p in spec.get_patches()]
-        with RpmbuildTemporaryEnvironment(sources, patches, spec.path, results_dir) as tmp_env:
+        with RpmbuildTemporaryEnvironment(sources, patches, spec.spec.path, results_dir) as tmp_env:
             env = tmp_env.env()
             tmp_dir = tmp_env.path()
             tmp_results_dir = env.get(RpmbuildTemporaryEnvironment.TEMPDIR_RESULTS)
diff --git a/rebasehelper/plugins/build_tools/srpm/mock.py b/rebasehelper/plugins/build_tools/srpm/mock.py
index 9278cf96c..de289a4b4 100644
--- a/rebasehelper/plugins/build_tools/srpm/mock.py
+++ b/rebasehelper/plugins/build_tools/srpm/mock.py
@@ -107,7 +107,7 @@ def build(cls, spec, results_dir, **kwargs):
         """
         sources = spec.get_sources()
         patches = [p.path for p in spec.get_patches()]
-        with MockTemporaryEnvironment(sources, patches, spec.path, results_dir) as tmp_env:
+        with MockTemporaryEnvironment(sources, patches, spec.spec.path, results_dir) as tmp_env:
             srpm_builder_options = cls.get_srpm_builder_options(**kwargs)
 
             env = tmp_env.env()
diff --git a/rebasehelper/plugins/build_tools/srpm/rpmbuild.py b/rebasehelper/plugins/build_tools/srpm/rpmbuild.py
index c8c8dd422..e09ef7594 100644
--- a/rebasehelper/plugins/build_tools/srpm/rpmbuild.py
+++ b/rebasehelper/plugins/build_tools/srpm/rpmbuild.py
@@ -94,7 +94,7 @@ def build(cls, spec, results_dir, **kwargs):
         """
         sources = spec.get_sources()
         patches = [p.path for p in spec.get_patches()]
-        with RpmbuildTemporaryEnvironment(sources, patches, spec.path, results_dir) as tmp_env:
+        with RpmbuildTemporaryEnvironment(sources, patches, spec.spec.path, results_dir) as tmp_env:
             srpm_builder_options = cls.get_srpm_builder_options(**kwargs)
 
             env = tmp_env.env()
diff --git a/rebasehelper/plugins/checkers/abipkgdiff.py b/rebasehelper/plugins/checkers/abipkgdiff.py
index 83804f5b8..7ade7fc52 100644
--- a/rebasehelper/plugins/checkers/abipkgdiff.py
+++ b/rebasehelper/plugins/checkers/abipkgdiff.py
@@ -27,6 +27,8 @@
 import re
 from typing import Dict, Optional, cast
 
+from specfile.utils import NEVR
+
 from rebasehelper.constants import ENCODING
 from rebasehelper.exceptions import RebaseHelperError, CheckerNotFoundError
 from rebasehelper.logger import CustomLogger
@@ -78,14 +80,14 @@ def _get_packages_for_abipkgdiff(cls, input_structure=None):
 
     @classmethod
     def _find_debuginfo(cls, debug, pkg):
-        name = RpmHelper.split_nevra(os.path.basename(pkg))['name']
+        name = NEVR.from_string(os.path.basename(pkg)).name
         debuginfo = '{}-debuginfo'.format(name)
-        find = [x for x in debug if RpmHelper.split_nevra(os.path.basename(x))['name'] == debuginfo]
+        find = [x for x in debug if NEVR.from_string(os.path.basename(x)).name == debuginfo]
         if find:
             return find[0]
         srpm = RpmHelper.get_header_from_rpm(pkg).sourcerpm
-        debuginfo = '{}-debuginfo'.format(RpmHelper.split_nevra(srpm)['name'])
-        find = [x for x in debug if RpmHelper.split_nevra(os.path.basename(x))['name'] == debuginfo]
+        debuginfo = '{}-debuginfo'.format(NEVR.from_string(srpm).name)
+        find = [x for x in debug if NEVR.from_string(os.path.basename(x)).name == debuginfo]
         if find:
             return find[0]
         return None
@@ -106,8 +108,8 @@ def run_check(cls, results_dir, **kwargs):
             if debug:
                 command.append('--d1')
                 command.append(debug)
-            old_name = RpmHelper.split_nevra(os.path.basename(pkg))['name']
-            find = [x for x in rest_pkgs_new if RpmHelper.split_nevra(os.path.basename(x))['name'] == old_name]
+            old_name = NEVR.from_string(os.path.basename(pkg)).name
+            find = [x for x in rest_pkgs_new if NEVR.from_string(os.path.basename(x)).name == old_name]
             if not find:
                 logger.warning('New version of package %s was not found!', old_name)
                 continue
diff --git a/rebasehelper/plugins/checkers/rpminspect.py b/rebasehelper/plugins/checkers/rpminspect.py
index 0ff2fb410..9f93d46f4 100644
--- a/rebasehelper/plugins/checkers/rpminspect.py
+++ b/rebasehelper/plugins/checkers/rpminspect.py
@@ -27,9 +27,10 @@
 
 from typing import Any, Dict, Tuple
 
+from specfile.utils import NEVR
+
 from rebasehelper.constants import ENCODING
 from rebasehelper.exceptions import CheckerNotFoundError, RebaseHelperError
-from rebasehelper.helpers.rpm_helper import RpmHelper
 from rebasehelper.helpers.process_helper import ProcessHelper
 from rebasehelper.plugins.checkers import BaseChecker
 
@@ -123,7 +124,7 @@ def run_rpminspect(cls, checker_dir: str, old_pkg: str, new_pkg: str) -> Tuple[s
 
         """
         cmd = [cls.CMD, '-F', 'json']
-        pkg_name = RpmHelper.split_nevra(os.path.basename(new_pkg))['name']
+        pkg_name = NEVR.from_string(os.path.basename(new_pkg)).name
         cmd.append(old_pkg)
         cmd.append(new_pkg)
 
diff --git a/rebasehelper/plugins/checkers/rpminspect_rpm.py b/rebasehelper/plugins/checkers/rpminspect_rpm.py
index d87f90019..53fdddf37 100644
--- a/rebasehelper/plugins/checkers/rpminspect_rpm.py
+++ b/rebasehelper/plugins/checkers/rpminspect_rpm.py
@@ -27,9 +27,10 @@
 
 from typing import Optional, cast
 
+from specfile.utils import NEVR
+
 from rebasehelper.logger import CustomLogger
 from rebasehelper.results_store import results_store
-from rebasehelper.helpers.rpm_helper import RpmHelper
 from rebasehelper.plugins.checkers import CheckerCategory
 from rebasehelper.plugins.checkers.rpminspect import Rpminspect
 
@@ -53,8 +54,8 @@ def run_check(cls, results_dir, **kwargs):
         for old_pkg in old_pkgs:
             if 'debuginfo' in old_pkg:
                 continue
-            name = RpmHelper.split_nevra(os.path.basename(old_pkg))['name']
-            found = [x for x in new_pkgs if RpmHelper.split_nevra(os.path.basename(x))['name'] == name]
+            name = NEVR.from_string(os.path.basename(old_pkg)).name
+            found = [x for x in new_pkgs if NEVR.from_string(os.path.basename(x)).name == name]
             if not found:
                 logger.warning('New version of package %s was not found!', name)
                 continue
diff --git a/rebasehelper/plugins/checkers/rpminspect_srpm.py b/rebasehelper/plugins/checkers/rpminspect_srpm.py
index a417c3045..416bf927c 100644
--- a/rebasehelper/plugins/checkers/rpminspect_srpm.py
+++ b/rebasehelper/plugins/checkers/rpminspect_srpm.py
@@ -27,9 +27,10 @@
 
 from typing import Optional, cast
 
+from specfile.utils import NEVR
+
 from rebasehelper.logger import CustomLogger
 from rebasehelper.results_store import results_store
-from rebasehelper.helpers.rpm_helper import RpmHelper
 from rebasehelper.plugins.checkers import CheckerCategory
 from rebasehelper.plugins.checkers.rpminspect import Rpminspect
 
@@ -50,7 +51,7 @@ def run_check(cls, results_dir, **kwargs):
         result = {'path': cls.get_checker_output_dir_short(), 'files': [], 'checks': {}}
         old_pkg = results_store.get_old_build()['srpm']
         new_pkg = results_store.get_new_build()['srpm']
-        name = RpmHelper.split_nevra(os.path.basename(old_pkg))['name']
+        name = NEVR.from_string(os.path.basename(old_pkg)).name
         outfile, pkg_data = cls.run_rpminspect(cls.results_dir, old_pkg, new_pkg)
         result['files'].append(os.path.basename(outfile))
         result['checks'][name] = pkg_data
diff --git a/rebasehelper/plugins/spec_hooks/commit_hash_updater.py b/rebasehelper/plugins/spec_hooks/commit_hash_updater.py
index 1cd870473..4fbede394 100644
--- a/rebasehelper/plugins/spec_hooks/commit_hash_updater.py
+++ b/rebasehelper/plugins/spec_hooks/commit_hash_updater.py
@@ -45,7 +45,10 @@ def _get_commit_hash_from_github(cls, spec_file):
         :param spec_file: SPEC file to base the search on
         :return: SHA of a commit, or None
         """
-        m = re.match(r'^https?://github\.com/(?P<owner>[\w-]+)/(?P<project>[\w-]+)/.*$', spec_file.sources[0])
+        m = re.match(
+            r'^https?://github\.com/(?P<owner>[\w-]+)/(?P<project>[\w-]+)/.*$',
+            spec_file.get_raw_main_source(),
+        )
         if not m:
             return None
         baseurl = 'https://api.github.com/repos/{owner}/{project}'.format(**m.groupdict())
@@ -90,31 +93,33 @@ def _get_commit_hash_from_github(cls, spec_file):
 
     @classmethod
     def _get_commit_hash(cls, spec_file):
-        if 'github.com' in spec_file.sources[0]:
+        if 'github.com' in spec_file.get_raw_main_source():
             return cls._get_commit_hash_from_github(spec_file)
         return None
 
     @classmethod
     def run(cls, spec_file, rebase_spec_file, **kwargs):
-        if rebase_spec_file.sources[0] != spec_file.sources[0]:
+        if rebase_spec_file.get_raw_main_source() != spec_file.get_raw_main_source():
             # nothing to do
             return
         # try to determine commit hash matching the new version
         new_commit = cls._get_commit_hash(rebase_spec_file)
         if not new_commit:
             return
-        source = rebase_spec_file.sources[0]
+        value = original_value = rebase_spec_file.get_raw_main_source()
         # try to determine commit hash matching the old version
         old_commit = cls._get_commit_hash(spec_file)
         if old_commit:
             # replace old commit hash with the new one
-            source = source.replace(old_commit, new_commit)
+            value = value.replace(old_commit, new_commit)
         else:
             # try to find anything resembling SHA1 hash
-            hashes = re.findall(r'[0-9a-f]{40}', source)
+            hashes = re.findall(r'[0-9a-f]{40}', value)
             if len(set(hashes)) != 1:
                 # multiple different hashes (or none), cannot continue
                 return
-            source = source.replace(hashes[0], new_commit)
-        tag = 'Source{0}'.format(rebase_spec_file.main_source_index)
-        rebase_spec_file.set_tag(tag, source, preserve_macros=True)
+            value = value.replace(hashes[0], new_commit)
+        updated_value = rebase_spec_file.spec.update_value(original_value, value, protected_entities=".*name")
+        with rebase_spec_file.spec.sources() as sources:
+            main_source = next(s for s in sources if s.number == rebase_spec_file.main_source_number)
+            main_source.location = updated_value
diff --git a/rebasehelper/plugins/spec_hooks/escape_macros.py b/rebasehelper/plugins/spec_hooks/escape_macros.py
index fcbd40e4c..e49f4e921 100644
--- a/rebasehelper/plugins/spec_hooks/escape_macros.py
+++ b/rebasehelper/plugins/spec_hooks/escape_macros.py
@@ -32,8 +32,9 @@ class EscapeMacros(BaseSpecHook):
 
     @classmethod
     def run(cls, spec_file, rebase_spec_file, **kwargs):
-        for sec_name, section in rebase_spec_file.spec_content.sections:
-            for index, line in enumerate(section):
-                start, end = spec_file.spec_content.get_comment_span(line, sec_name)
-                new_comment = re.sub(r'(?<!%)(%(?P<brace>{\??)?\w+(?(brace)}))', r'%\1', line[start:end])
-                section[index] = line[:start] + new_comment
+        with rebase_spec_file.spec.sections() as sections:
+            for section in sections:
+                for index, line in enumerate(section):
+                    start, end = spec_file.get_comment_span(line, section.is_script)
+                    new_comment = re.sub(r'(?<!%)(%(?P<brace>{\??)?\w+(?(brace)}))', r'%\1', line[start:end])
+                    section[index] = line[:start] + new_comment
diff --git a/rebasehelper/plugins/spec_hooks/paths_to_rpm_macros.py b/rebasehelper/plugins/spec_hooks/paths_to_rpm_macros.py
index 565ee2346..b0fe99dfe 100644
--- a/rebasehelper/plugins/spec_hooks/paths_to_rpm_macros.py
+++ b/rebasehelper/plugins/spec_hooks/paths_to_rpm_macros.py
@@ -23,7 +23,6 @@
 #          František Nečas <fifinecas@seznam.cz>
 
 from rebasehelper.plugins.spec_hooks import BaseSpecHook
-from rebasehelper.helpers.macro_helper import MacroHelper
 
 
 class PathsToRPMMacros(BaseSpecHook):
@@ -31,14 +30,11 @@ class PathsToRPMMacros(BaseSpecHook):
 
     @classmethod
     def run(cls, spec_file, rebase_spec_file, **kwargs):
-        macros = [m for m in rebase_spec_file.macros if m['name'] in MacroHelper.MACROS_WHITELIST]
-        macros = MacroHelper.expand_macros(macros)
-        # ensure maximal greediness
-        macros.sort(key=lambda k: len(k['value']), reverse=True)
-
-        for sec_name, sec_content in rebase_spec_file.spec_content.sections:
-            if sec_name.startswith('%files'):
-                for index, line in enumerate(sec_content):
-                    new_path = MacroHelper.substitute_path_with_macros(line, macros)
-                    sec_content[index] = new_path
+        with rebase_spec_file.spec.sections() as sections:
+            for section in sections:
+                if not section.normalized_id.startswith('files'):
+                    continue
+                for index, line in enumerate(section):
+                    new_path = rebase_spec_file.substitute_path_with_macros(line)
+                    section[index] = new_path
         rebase_spec_file.save()
diff --git a/rebasehelper/plugins/spec_hooks/pypi_url_fix.py b/rebasehelper/plugins/spec_hooks/pypi_url_fix.py
index 4ef0e20e9..5a106f158 100644
--- a/rebasehelper/plugins/spec_hooks/pypi_url_fix.py
+++ b/rebasehelper/plugins/spec_hooks/pypi_url_fix.py
@@ -49,14 +49,10 @@ class PyPIURLFix(BaseSpecHook):
 
     @classmethod
     def run(cls, spec_file, rebase_spec_file, **kwargs):
-        for tag in rebase_spec_file.tags.filter(name='URL'):
-            value = rebase_spec_file.get_raw_tag_value(tag.name)
-            if value:
-                rebase_spec_file.set_raw_tag_value(tag.name, cls._transform_url(value))
-        for tag in rebase_spec_file.tags.filter(name='Source*'):
-            value = rebase_spec_file.get_raw_tag_value(tag.name)
-            if value:
-                rebase_spec_file.set_raw_tag_value(tag.name, cls._transform_sources_url(value))
+        rebase_spec_file.spec.url = cls._transform_url(rebase_spec_file.spec.url)
+        with rebase_spec_file.spec.sources() as sources:
+            for source in sources:
+                source.location = cls._transform_sources_url(source.location)
         rebase_spec_file.save()
 
     @classmethod
diff --git a/rebasehelper/plugins/spec_hooks/replace_old_version.py b/rebasehelper/plugins/spec_hooks/replace_old_version.py
index 0884004b7..364434e23 100644
--- a/rebasehelper/plugins/spec_hooks/replace_old_version.py
+++ b/rebasehelper/plugins/spec_hooks/replace_old_version.py
@@ -22,16 +22,15 @@
 #          Nikola Forró <nforro@redhat.com>
 #          František Nečas <fifinecas@seznam.cz>
 
-import collections
 import re
-import urllib.parse
-from typing import Any, Dict, List, Pattern, Tuple, Set
+from typing import Any, List, Pattern, Tuple
+
+from specfile.sources import TagSource
 
 from rebasehelper.exceptions import RebaseHelperError
 from rebasehelper.types import Options
 from rebasehelper.plugins.spec_hooks import BaseSpecHook
 from rebasehelper.specfile import SpecFile
-from rebasehelper.helpers.macro_helper import MacroHelper
 
 
 class ReplaceOldVersion(BaseSpecHook):
@@ -81,8 +80,8 @@ def _create_possible_replacements(cls, spec_file: SpecFile, rebase_spec_file: Sp
             Subversions 1.2, 1.2.3, 1.2.3.4 would be created from version 1.2.3.4.
 
         """
-        old = spec_file.get_version()
-        new = rebase_spec_file.get_version()
+        old = spec_file.spec.expanded_version
+        new = rebase_spec_file.spec.expanded_version
         version_re = r'([\ /\-\s]){}([/.\-\s]|$)'
         # Allow any character after whole version to replace strings such as
         # 1.0.1bc1
@@ -116,34 +115,35 @@ def run(cls, spec_file: SpecFile, rebase_spec_file: SpecFile, **kwargs: Any):
         replace_with_macro = bool(kwargs.get('replace_old_version_with_macro'))
 
         subversion_patterns = cls._create_possible_replacements(spec_file, rebase_spec_file, replace_with_macro)
-        examined_lines: Dict[int, Set[int]] = collections.defaultdict(set)
-        for tag in rebase_spec_file.tags.filter():
-            examined_lines[tag.section_index].add(tag.line)
-            value = rebase_spec_file.get_raw_tag_value(tag.name, tag.section_index)
-            if not value or tag.name in cls.IGNORED_TAGS:
-                continue
-            scheme = urllib.parse.urlparse(value).scheme
-            if (tag.name.startswith('Patch') or tag.name.startswith('Source')) and not scheme:
-                # skip local sources
-                continue
-
-            # replace the whole version first
-            updated_value = subversion_patterns[0][0].sub(subversion_patterns[0][1], value)
-            # replace subversions only for remote sources/patches
-            if tag.name.startswith('Patch') or tag.name.startswith('Source'):
-                for sub_pattern, repl in subversion_patterns[1:]:
-                    updated_value = sub_pattern.sub(repl, updated_value)
-            rebase_spec_file.set_raw_tag_value(tag.name, updated_value, tag.section_index)
-
-        for sec_index, (sec_name, section) in enumerate(rebase_spec_file.spec_content.sections):
-            if sec_name.startswith('%changelog'):
-                continue
-            for index, line in enumerate(section):
-                tag_ignored = any(MacroHelper.expand(line, line).startswith(tag) for tag in cls.IGNORED_TAGS)
-                if index in examined_lines[sec_index] or tag_ignored:
+        with rebase_spec_file.spec.sections() as sections:
+            for section in sections:
+                if section.normalized_id.startswith('changelog'):
                     continue
-                start, end = spec_file.spec_content.get_comment_span(line, sec_name)
-                updated_line = subversion_patterns[0][0].sub(subversion_patterns[0][1], line[:start])
-                section[index] = updated_line + line[start:end]
-
-        rebase_spec_file.save()
+                elif section.normalized_id.startswith('package'):
+                    with rebase_spec_file.spec.tags(section) as tags:
+                        for tag in tags:
+                            for index, line in enumerate(tag.comments._preceding_lines): # pylint: disable=protected-access
+                                start, end = spec_file.get_comment_span(line, section.is_script)
+                                updated_line = subversion_patterns[0][0].sub(subversion_patterns[0][1], line[:start])
+                                tag.comments._preceding_lines[index] = updated_line + line[start:end] # pylint: disable=protected-access
+                            if not tag.value or tag.normalized_name in cls.IGNORED_TAGS:
+                                continue
+                            if (
+                                tag.normalized_name.startswith("Source")
+                                or tag.normalized_name.startswith("Patch")
+                            ) and not TagSource(tag).remote:
+                                # skip local sources
+                                continue
+                            # replace the whole version first
+                            updated_value = subversion_patterns[0][0].sub(subversion_patterns[0][1], tag.value)
+                            # replace subversions only for remote sources/patches
+                            if tag.normalized_name.startswith('Source') or tag.normalized_name.startswith('Patch'):
+                                for sub_pattern, repl in subversion_patterns[1:]:
+                                    updated_value = sub_pattern.sub(repl, updated_value)
+                            tag.value = updated_value
+                    continue
+                for index, line in enumerate(section):
+                    start, end = spec_file.get_comment_span(line, section.is_script)
+                    updated_line = subversion_patterns[0][0].sub(subversion_patterns[0][1], line[:start])
+                    section[index] = updated_line + line[start:end]
+            rebase_spec_file.save()
diff --git a/rebasehelper/plugins/spec_hooks/ruby_helper.py b/rebasehelper/plugins/spec_hooks/ruby_helper.py
index 0b999e96f..c57d8d291 100644
--- a/rebasehelper/plugins/spec_hooks/ruby_helper.py
+++ b/rebasehelper/plugins/spec_hooks/ruby_helper.py
@@ -25,7 +25,6 @@
 import logging
 import os
 import re
-import urllib.parse
 from typing import cast
 
 from rebasehelper.constants import ENCODING
@@ -35,7 +34,6 @@
 from rebasehelper.types import PackageCategories
 from rebasehelper.specfile import PackageCategory
 from rebasehelper.helpers.process_helper import ProcessHelper
-from rebasehelper.helpers.macro_helper import MacroHelper
 
 
 logger: CustomLogger = cast(CustomLogger, logging.getLogger(__name__))
@@ -46,12 +44,11 @@ class RubyHelper(BaseSpecHook):
     CATEGORIES: PackageCategories = [PackageCategory.ruby]
 
     @classmethod
-    def _get_instructions(cls, comments, old_version, new_version):
+    def _get_instructions(cls, spec, comments, old_version, new_version):
         """Extract instructions from comments, update version if necessary"""
         instructions = []
         for comment in comments:
-            comment = MacroHelper.expand(comment, comment)
-            comment = MacroHelper.expand(comment, comment)
+            comment = spec.expand(comment, comment)
             comment = re.sub(r'^#\s*', '', comment)
             comment = comment.replace(old_version, new_version)
             instructions.append(comment)
@@ -77,30 +74,29 @@ def _build_source_from_instructions(cls, instructions, source, logfile):
     @classmethod
     def run(cls, spec_file, rebase_spec_file, **kwargs):
         # find non-existent local sources
-        sources = [idx for idx, src in enumerate(rebase_spec_file.sources)
-                   if not urllib.parse.urlparse(src).scheme and not os.path.isfile(src)]
-        for idx in sources:
-            if spec_file.sources[idx] == rebase_spec_file.sources[idx]:
+        sources = [
+            s
+            for s in rebase_spec_file.all_sources
+            if not s.remote
+            and not (rebase_spec_file.spec.sourcedir / s.expanded_filename).is_file()
+        ]
+        for source in sources:
+            if next(s for s in spec_file.all_sources if s.number == source.number).location == source.location:
                 # skip sources that stayed unchanged
                 continue
-            tag = rebase_spec_file.tag('Source{0}'.format(idx))
-            if not tag:
-                continue
-            source = rebase_spec_file.sources[idx]
-            logger.info("Found non-existent source '%s'", source)
-            comments = None
-            preamble = rebase_spec_file.spec_content.section('%package')
-            for i in range(tag.line - 1, 0, -1):
-                if not preamble[i].startswith('#'):
-                    comments = preamble[i+1:i]
-                    break
-            if not comments:
+            logger.info("Found non-existent source '%s'", source.expanded_filename)
+            if not source.comments:
                 # nothing to do
                 continue
-            # update data so that RPM macros are populated correctly
-            rebase_spec_file.update()
-            instructions = cls._get_instructions(comments,
-                                                 spec_file.header.version,
-                                                 rebase_spec_file.header.version)
-            logfile = os.path.join(kwargs['workspace_dir'], '{}.log'.format(source))
-            cls._build_source_from_instructions(instructions, source, logfile)
+            instructions = cls._get_instructions(
+                rebase_spec_file,
+                source.comments,
+                spec_file.spec.expanded_version,
+                rebase_spec_file.spec.expanded_version,
+            )
+            logfile = os.path.join(kwargs['workspace_dir'], '{}.log'.format(source.expanded_filename))
+            cls._build_source_from_instructions(
+                instructions,
+                rebase_spec_file.spec.sourcedir / source.expanded_filename,
+                logfile,
+            )
diff --git a/rebasehelper/plugins/spec_hooks/typo_fix.py b/rebasehelper/plugins/spec_hooks/typo_fix.py
index 6974604f9..8992b425a 100644
--- a/rebasehelper/plugins/spec_hooks/typo_fix.py
+++ b/rebasehelper/plugins/spec_hooks/typo_fix.py
@@ -41,9 +41,10 @@ class TypoFix(BaseSpecHook):
 
     @classmethod
     def run(cls, spec_file, rebase_spec_file, **kwargs):
-        for _, section in rebase_spec_file.spec_content.sections:
-            for index, line in enumerate(section):
-                for replacement in cls.REPLACEMENTS:
-                    line = re.sub(replacement[0], replacement[1], line)
-                section[index] = line
+        with rebase_spec_file.spec.sections() as sections:
+            for section in sections:
+                for index, line in enumerate(section):
+                    for replacement in cls.REPLACEMENTS:
+                        line = re.sub(replacement[0], replacement[1], line)
+                    section[index] = line
         rebase_spec_file.save()
diff --git a/rebasehelper/spec_content.py b/rebasehelper/spec_content.py
deleted file mode 100644
index 78ed06f81..000000000
--- a/rebasehelper/spec_content.py
+++ /dev/null
@@ -1,180 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
-
-import re
-from typing import List, Optional, Tuple
-
-
-class SpecContent:
-    """Class representing content of a SPEC file."""
-
-    # taken from build/parseSpec.c in rpm source code
-    SECTION_HEADERS: List[str] = [
-        '%package',
-        '%prep',
-        '%generate_buildrequires',
-        '%build',
-        '%install',
-        '%check',
-        '%clean',
-        '%preun',
-        '%postun',
-        '%pretrans',
-        '%posttrans',
-        '%pre',
-        '%post',
-        '%files',
-        '%changelog',
-        '%description',
-        '%triggerpostun',
-        '%triggerprein',
-        '%triggerun',
-        '%triggerin',
-        '%trigger',
-        '%verifyscript',
-        '%sepolicy',
-        '%filetriggerin',
-        '%filetrigger',
-        '%filetriggerun',
-        '%filetriggerpostun',
-        '%transfiletriggerin',
-        '%transfiletrigger',
-        '%transfiletriggerun',
-        '%transfiletriggerpostun',
-        '%end',
-        '%patchlist',
-        '%sourcelist',
-    ]
-
-    # Comments in these sections can only be on a separate line.
-    DISALLOW_INLINE_COMMENTS: List[str] = [
-        '%package',
-        '%patchlist',
-        '%sourcelist',
-        '%description',
-        '%files',
-        '%changelog',
-    ]
-
-    def __init__(self, content: str) -> None:
-        self.sections: List[Tuple[str, List[str]]] = self._split_sections(content)
-
-    def __str__(self) -> str:
-        """Join SPEC file sections back together."""
-        content = []
-        for header, section in self.sections:
-            if header != '%package':
-                content.append(header + '\n')
-            for line in section:
-                content.append(line + '\n')
-        return ''.join(content)
-
-    def __getitem__(self, index: int) -> List[str]:
-        return self.sections[index][1]
-
-    @classmethod
-    def get_comment_span(cls, line: str, section: str) -> Tuple[int, int]:
-        """Gets span of a comment depending on the section.
-
-        Args:
-            line: Line to find the comment in.
-            section: Section the line is in.
-
-        Returns:
-            Span of the comment. If no comment is found, both tuple elements
-            are equal to the length of the line for convenient use in a slice.
-
-        """
-        inline_comment_allowed = not any(section.startswith(s) for s in cls.DISALLOW_INLINE_COMMENTS)
-        comment = re.search(r" #.*" if inline_comment_allowed else r"^\s*#.*", line)
-        return comment.span() if comment else (len(line), len(line))
-
-    def section(self, name: str) -> Optional[List[str]]:
-        """Gets content of a section.
-
-        In case there are multiple sections with the same name, the first one is returned.
-
-        Args:
-            name: Section name.
-
-        Returns:
-            Section content as a list of lines.
-
-        """
-        for header, section in self.sections:
-            if header.lower() == name.lower():
-                return section
-        return None
-
-    def replace_section(self, name: str, content: List[str]) -> bool:
-        """Replaces content of a section.
-
-        In case there are multiple sections with the same name, the first one is replaced.
-
-        Args:
-            name: Section name.
-            content: Section content as a list of lines.
-
-        Returns:
-            False if section was not found else True.
-
-        """
-        for i, (header, _) in enumerate(self.sections):
-            if header.lower() == name.lower():
-                self.sections[i] = (header, content)
-                return True
-        return False
-
-    @classmethod
-    def _split_sections(cls, content: str) -> List[Tuple[str, List[str]]]:
-        """Splits content of a SPEC file into sections.
-
-        Args:
-            content: Content of the SPEC file
-
-        Returns:
-            The split sections represented as a list of tuples (the first element
-            is the section name, the second is the content of the section).
-
-        """
-        lines = content.splitlines()
-        section_headers_re = [re.compile(r'^{0}\b.*'.format(re.escape(x)), re.IGNORECASE) for x in cls.SECTION_HEADERS]
-
-        section_beginnings: List[Optional[int]] = []
-        for i, line in enumerate(lines):
-            if line.startswith('%'):
-                for header in section_headers_re:
-                    if header.match(line):
-                        section_beginnings.append(i)
-        section_beginnings.append(None)
-
-        sections = [('%package', lines[:section_beginnings[0]])]
-
-        for i in range(len(section_beginnings) - 1):
-            # Only the last element of section_beginnings is None, section_beginnings[i] can never be None
-            # (the last element isn't iterated over), so section_beginnings[i] + 1 is always valid. Ignore the type.
-            start = section_beginnings[i] + 1  # type: ignore
-            end = section_beginnings[i + 1]
-            sections.append((lines[start - 1], lines[start:end]))
-        return sections
diff --git a/rebasehelper/specfile.py b/rebasehelper/specfile.py
index aef0cd00e..37a22f09d 100644
--- a/rebasehelper/specfile.py
+++ b/rebasehelper/specfile.py
@@ -23,35 +23,48 @@
 #          František Nečas <fifinecas@seznam.cz>
 
 import argparse
-import collections
 import enum
 import itertools
-import locale
 import logging
 import os
 import re
 import shlex
 import shutil
-import urllib.parse
-from datetime import date
-from difflib import SequenceMatcher
-from operator import itemgetter
-from typing import List, Optional, Pattern, Tuple, Dict, Union, cast
+from typing import Callable, List, Optional, Pattern, Tuple, Dict, cast
 
-import rpm  # type: ignore
+from specfile import Specfile
+from specfile.exceptions import RPMException
+from specfile.macros import MacroLevel
+from specfile.sections import Section
+from specfile.sources import Source, ListSource, TagSource
 
 from rebasehelper import constants
 from rebasehelper.archive import Archive
-from rebasehelper.spec_content import SpecContent
-from rebasehelper.tags import Tag, Tags
 from rebasehelper.exceptions import RebaseHelperError, DownloadError, ParseError, LookasideCacheError
 from rebasehelper.argument_parser import SilentArgumentParser
 from rebasehelper.logger import CustomLogger
 from rebasehelper.helpers.download_helper import DownloadHelper
-from rebasehelper.helpers.macro_helper import MacroHelper
-from rebasehelper.helpers.rpm_helper import RpmHelper, RpmHeader
 from rebasehelper.helpers.git_helper import GitHelper
 from rebasehelper.helpers.lookaside_cache_helper import LookasideCacheHelper
+from rebasehelper.helpers.rpm_helper import RpmHeader
+
+
+MACROS_WHITELIST: List[str] = [
+    '_bindir',
+    '_datadir',
+    '_includedir',
+    '_infodir',
+    '_initdir',
+    '_libdir',
+    '_libexecdir',
+    '_localstatedir',
+    '_mandir',
+    '_sbindir',
+    '_sharedstatedir',
+    '_sysconfdir',
+    'python2_sitelib',
+    'python3_sitelib',
+]
 
 
 logger: CustomLogger = cast(CustomLogger, logging.getLogger(__name__))
@@ -69,23 +82,13 @@ def get_rebase_name(dir_name, name):
     return os.path.join(dir_name, file_name)
 
 
-class PatchList(list):
-    def _get_index_list(self, item):
-        for x in self:
-            if x.index == item.index:
-                return x
-
-    def __getitem__(self, item):
-        return super().__getitem__(self._get_index_list(item))
-
-
 class PatchObject:
 
     """Class represents set of information about patches"""
 
-    def __init__(self, path, index, strip):
+    def __init__(self, path, number, strip):
         self.path = path
-        self.index = index
+        self.number = number
         self.strip = strip
 
     def get_patch_name(self):
@@ -113,70 +116,50 @@ def wrapper(spec, *args, **kwargs):
 
 
 class SpecFile:
-
-    """Class representing a SPEC file. Be aware that using SpecFile
-    modifies RPM macros in global context."""
+    """Class representing a spec file."""
 
     def __init__(self, path: str, sources_location: str = '', predefined_macros: Optional[Dict[str, str]] = None,
-                 lookaside_cache_preset: str = 'fedpkg', keep_comments: bool = False):
+                 lookaside_cache_preset: str = 'fedpkg'):
         # Initialize attributes
-        self.path: str = path
-        self.sources_location: str = sources_location
-        self.predefined_macros: Dict[str, str] = predefined_macros or {}
         self.lookaside_cache_preset: str = lookaside_cache_preset
-        self.keep_comments: bool = keep_comments
         self.prep_section: str = ''
-        self.sources: List[str] = []
+        self.all_sources: List[Source] = []
+        self.sources: List[Source] = []
         self.patches: Dict[str, List[PatchObject]] = {}
         self.removed_patches: List[str] = []
         self.category: Optional[PackageCategory] = None
-        self.spc: rpm.spec = RpmHelper.get_rpm_spec(self.path, self.sources_location, self.predefined_macros)
-        self.header: RpmHeader = RpmHeader(self.spc.sourceHeader)
-        self.spec_content: SpecContent = self._read_spec_content()
-        self.tags: Tags = Tags(self.spec_content, SpecContent(self.spc.parsed))
-
-        # Load rpm information
+        self.spec = Specfile(path, sources_location, macros=list((predefined_macros or {}).items()))
         self._update_data()
 
-    def __del__(self):
-        # make sure there are no leftover macros
-        rpm.reloadConfig()
-
-    def download_remote_sources(self):
-        """
-        Method that iterates over all sources and downloads ones, which contain URL instead of just a file.
-
-        :return: None
-        """
+    def download_remote_sources(self) -> None:
+        """Downloads sources specified as URL."""
         try:
             # try to download old sources from Fedora lookaside cache
-            LookasideCacheHelper.download(self.lookaside_cache_preset, os.path.dirname(self.path), self.header.name,
-                                          self.sources_location)
+            LookasideCacheHelper.download(self.lookaside_cache_preset,
+                                          os.path.dirname(self.spec.path),
+                                          self.spec.expanded_name,
+                                          str(self.spec.sourcedir))
         except LookasideCacheError as e:
             logger.verbose("Downloading sources from lookaside cache failed. "
                            "Reason: %s.", str(e))
 
-        # filter out only sources with URL
-        remote_files = [source for source in self.sources if bool(urllib.parse.urlparse(source).scheme)]
-        # download any sources that are not yet downloaded
-        for remote_file in remote_files:
-            local_file = os.path.join(self.sources_location, os.path.basename(remote_file))
-            if not os.path.isfile(local_file):
-                logger.verbose("File '%s' doesn't exist locally, downloading it.", local_file)
+        for source in self.all_sources:
+            if not source.remote:
+                continue
+            if not source.expanded_filename:
+                continue
+            logger.verbose("Source '%s' is remote, downloading it.", source.expanded_filename)
+            target = self.spec.sourcedir / source.expanded_filename
+            if not target.is_file():
+                logger.verbose("File '%s' doesn't exist locally, downloading it.", str(target))
                 try:
-                    DownloadHelper.download_file(remote_file, local_file)
+                    DownloadHelper.download_file(source.expanded_location, target)
                 except DownloadError as e:
                     raise RebaseHelperError("Failed to download file from URL {}. "
-                                            "Reason: '{}'. ".format(remote_file, str(e))) from e
+                                            "Reason: '{}'. ".format(source.expanded_location, str(e))) from e
 
     def update(self) -> None:
-        # explicitly discard old instance to prevent rpm from destroying
-        # "sources" and "patches" lua tables after new instance is created
-        self.spc = None
-        self.spc = RpmHelper.get_rpm_spec(self.path, self.sources_location, self.predefined_macros)
-        self.header = RpmHeader(self.spc.sourceHeader)
-        self.spec_content = self._read_spec_content()
-        self.tags = Tags(self.spec_content, SpecContent(self.spc.parsed))
+        self.spec.reload()
         self._update_data()
 
     def _update_data(self):
@@ -186,7 +169,7 @@ def _update_data(self):
         :return:
         """
         def guess_category():
-            for pkg in self.spc.packages:
+            for pkg in self.spec.rpm_spec.packages:
                 header = RpmHeader(pkg.header)
                 for category in PackageCategory:
                     if category.value.match(header.name):
@@ -196,92 +179,61 @@ def guess_category():
                             return category
             return None
         self.category = guess_category()
-        self.sources = self._get_spec_sources_list(self.spc)
-        self.prep_section = self.spc.prep
-        self.main_source_index = self._identify_main_source(self.spc)
+        self.prep_section = self.spec.rpm_spec.prep
+        self.main_source_number = self._identify_main_source(self.spec)
+        self.all_sources = [
+            s
+            for s in self.spec.sources().content + self.spec.patches().content # pylint: disable=no-member
+            if s.expanded_location is not None
+        ]
+        self.sources = [
+            s
+            for s in self.spec.sources().content # pylint: disable=no-member
+            if s.expanded_location is not None
+        ]
         self.patches = self._get_initial_patches()
-        self.macros = MacroHelper.dump()
-
-    ######################
-    # TAG HELPER METHODS #
-    ######################
-
-    def tag(self, name: str, section: Optional[Union[str, int]] = None) -> Optional[Tag]:
-        """Returns the first non-unique tag."""
-        if isinstance(section, str):
-            tags = self.tags.filter(section_name=section, name=name)
-        else:
-            tags = self.tags.filter(section_index=section, name=name)
-        return next(tags, None)
-
-    def get_raw_tag_value(self, tag_name: str, section: Optional[Union[str, int]] = None) -> Optional[str]:
-        tag = self.tag(tag_name, section)
-        if not tag:
-            return None
-        return self.spec_content[tag.section_index][tag.line][slice(*tag.value_span)]
-
-    def set_raw_tag_value(self, tag_name: str, value: str, section: Optional[Union[str, int]] = None) -> None:
-        tag = self.tag(tag_name, section)
-        if not tag:
-            return
-        sec = self.spec_content[tag.section_index]
-        line = sec[tag.line]
-        sec[tag.line] = line[:tag.value_span[0]] + value + line[tag.value_span[1]:]
-        # update span
-        tag.value_span = (tag.value_span[0], tag.value_span[0] + len(value))
 
     ###########################
     # SOURCES RELATED METHODS #
     ###########################
 
     @staticmethod
-    def _identify_main_source(spec: rpm.spec) -> Optional[int]:
-        # a spec file does not need to have sources
-        if not spec.sources:
-            return None
-        # lowest index is the main source
-        return min([s[1] for s in spec.sources if s[2] == 1])
+    def _identify_main_source(spec: Specfile) -> Optional[int]:
+        # the lowest number is the main source
+        return min((s.number for s in spec.sources().content), default=None) # pylint: disable=no-member
 
-    @staticmethod
-    def _get_spec_sources_list(spec_object):
-        """
-        Method uses RPM API to get list of Sources from the SPEC file and returns the list of sources. If the Source
-        contains URL, the URL will be included in the list. This means no modifications of Sources are done at this
-        point.
-
-        :param spec_object: instance of rpm.spec object
-        :type spec_object: rpm.spec
-        :return: list of Sources in SPEC file in the exact order as they are listed in SPEC file.
-        :rtype: list
-        """
-        # the sources list returned by RPM API contains list of items (path, index, source_type).
-        # source type "1" is a regular source
-        regular_sources = [source[:2] for source in spec_object.sources if source[2] == 1]
-        regular_sources = [source[0] for source in sorted(regular_sources, key=itemgetter(1))]
-        return regular_sources
+    def _get_raw_source_string(self, source_num: Optional[int]) -> Optional[str]:
+        source = next((s for s in self.spec.sources().content if s.number == source_num), None) # pylint: disable=no-member
+        if not source:
+            return None
+        return source.location
 
-    def get_sources(self) -> List[str]:
-        """Gets a list of local sources."""
-        return [os.path.join(self.sources_location, os.path.basename(source)) for source in self.sources]
+    def _get_source_filename(self, source_num: Optional[int]) -> Optional[str]:
+        source = next((s for s in self.spec.sources().content if s.number == source_num), None) # pylint: disable=no-member
+        if not source:
+            return None
+        return source.expanded_filename
 
-    def get_archive(self):
-        """
-        Method returns the basename of first Source in SPEC file a.k.a. Source0
+    def get_raw_main_source(self) -> str:
+        return self._get_raw_source_string(self.main_source_number) or ''
 
-        :return: basename of first Source in SPEC file
-        :rtype: str
-        """
-        return os.path.basename(self.get_sources()[0])
+    def get_main_source(self) -> str:
+        return self._get_source_filename(self.main_source_number) or ''
 
-    def _get_raw_source_string(self, source_num: Optional[int]) -> Optional[str]:
-        if source_num is None:
-            return None
-        tag = 'Source{0}'.format(source_num)
-        return self.get_raw_tag_value(tag)
+    def get_other_sources(self) -> List[str]:
+        result = sorted((
+            s
+            for s in self.spec.sources().content # pylint: disable=no-member
+            if s.number != self.main_source_number
+        ), key=lambda s: s.number)
+        return [s.expanded_filename for s in result]
 
-    def get_main_source(self) -> str:
-        """Provide the main source, returns empty string if there are no sources"""
-        return self._get_raw_source_string(self.main_source_index) or ''
+    def get_sources(self) -> List[str]:
+        result = sorted((
+            s
+            for s in self.spec.sources().content # pylint: disable=no-member
+        ), key=lambda s: s.number)
+        return [str(self.spec.sourcedir / s.expanded_filename) for s in result]
 
     ###########################
     # PATCHES RELATED METHODS #
@@ -291,33 +243,34 @@ def _get_initial_patches(self) -> Dict[str, List[PatchObject]]:
         """Returns a dict of patches from a spec file"""
         patches_applied = []
         patches_not_used = []
-        patches_list = [p for p in self.spc.sources if p[2] == 2]
-        strip_options = self._get_patch_strip_options(patches_list)
-
-        for patch, num, _ in patches_list:
-            is_url = bool(urllib.parse.urlparse(patch).scheme)
-            filename = os.path.basename(patch) if is_url else patch
-            patch_path = os.path.join(self.sources_location, filename)
-            if not os.path.exists(patch_path):
-                if is_url:
-                    logger.info('Patch%s is remote, trying to download the patch', num)
+        strip_options = self._get_patch_strip_options()
+        for patch in self.spec.patches().content: # pylint: disable=no-member
+            if not patch.expanded_filename:
+                continue
+            patch_path = self.spec.sourcedir / (
+                patch.expanded_filename
+                if patch.remote
+                else patch.expanded_location
+            )
+            if not patch_path.exists():
+                if patch.remote:
+                    logger.info('Patch%s is remote, trying to download it', patch.number)
                     try:
-                        DownloadHelper.download_file(patch, filename)
+                        DownloadHelper.download_file(patch.expanded_location, patch_path)
                     except DownloadError:
-                        logger.error('Could not download remote patch %s', patch)
+                        logger.error('Could not download remote patch %s', patch.expanded_location)
                         continue
                 else:
-                    logger.error('Patch %s does not exist', filename)
+                    logger.error('Patch %s does not exist', patch.expanded_filename)
                     continue
-            patch_num = num
-            if patch_num in strip_options:
-                patches_applied.append(PatchObject(patch_path, patch_num, strip_options[patch_num]))
+            if patch.number in strip_options:
+                patches_applied.append(PatchObject(str(patch_path), patch.number, strip_options[patch.number]))
             else:
-                patches_not_used.append(PatchObject(patch_path, patch_num, None))
-        patches_applied = sorted(patches_applied, key=lambda x: x.index)
+                patches_not_used.append(PatchObject(str(patch_path), patch.number, None))
+        patches_applied = sorted(patches_applied, key=lambda x: x.number)
         return {"applied": patches_applied, "not_applied": patches_not_used}
 
-    def _get_patch_strip_options(self, patches: List[Tuple[str, int, int]]):
+    def _get_patch_strip_options(self) -> Dict[int, int]:
         """
         Gets value of strip option of each used patch
 
@@ -340,10 +293,14 @@ def _get_patch_strip_options(self, patches: List[Tuple[str, int, int]]):
             except ParseError:
                 continue
             rest = [os.path.basename(a) for a in rest]
-            indexes = [p[1] for p in patches if os.path.basename(p[0]) in rest]
-            for idx in indexes:
-                if idx not in result or result[idx] < ns.p:
-                    result[idx] = ns.p
+            numbers = [
+                p.number
+                for p in self.spec.patches().content # pylint: disable=no-member
+                if p.expanded_filename in rest
+            ]
+            for num in numbers:
+                if num not in result or result[num] < ns.p:
+                    result[num] = ns.p
         return result
 
     def get_patches(self):
@@ -384,142 +341,122 @@ def process_patch_macros(self, comment_out: Optional[List[int]] = None, remove:
         remove = list(remove or [])
         annotate = list(annotate or [])
 
-        prep = self.spec_content.section('%prep')
-        if not prep:
-            return
-
-        patch_re = re.compile(r'^%patch(?P<index>\d+)(.*)')
-
-        i = 0
-        removed = 0
-        while i < len(prep):
-            line = prep[i]
-            match = patch_re.match(line)
-            if match:
-                index = int(match.group('index'))
-                if note and index in annotate and index not in remove:
-                    prep.insert(i, '# {}'.format(note))
-                    annotate.remove(index)
-                    i += 1
-                    continue
-                if index in comment_out:
-                    prep[i] = '#%{}'.format(line)
-                    comment_out.remove(index)
-                    removed += 1
-                elif index in remove:
-                    del prep[i]
-                    remove.remove(index)
-                    removed += 1
-                    i -= 1
-                # When combining Patch tags and %patchlist, if a Patch is removed, the indexes
-                # of %patchlist patches change and %patch macros need to be modified.
-                else:
-                    tag = self.tag('Patch{}'.format(index))
-                    if tag and tag.section_name.startswith('%patchlist'):
-                        prep[i] = patch_re.sub(r'%patch{}\2'.format(index - removed), prep[i])
-            i += 1
+        with self.spec.sections() as sections:
+            if not 'prep' in sections:
+                return
+            prep = sections.prep
+
+            patch_re = re.compile(r'^%patch(?P<number>\d+)(.*)')
+
+            i = 0
+            removed = 0
+            while i < len(prep):
+                line = prep[i]
+                match = patch_re.match(line)
+                if match:
+                    number = int(match.group('number'))
+                    if note and number in annotate and number not in remove:
+                        prep.insert(i, '# {}'.format(note))
+                        annotate.remove(number)
+                        i += 1
+                        continue
+                    if number in comment_out:
+                        prep[i] = '#%{}'.format(line)
+                        comment_out.remove(number)
+                        removed += 1
+                    elif number in remove:
+                        del prep[i]
+                        remove.remove(number)
+                        removed += 1
+                        i -= 1
+                    # When combining Patch tags and %patchlist, if a Patch is removed, the numbers
+                    # of %patchlist patches change and %patch macros need to be modified.
+                    elif number in [
+                        p.number
+                        for p in self.spec.patches().content # pylint: disable=no-member
+                        if isinstance(p, ListSource)
+                    ]:
+                        prep[i] = patch_re.sub(r'%patch{}\2'.format(number - removed), prep[i])
+                i += 1
 
     @saves
     def update_paths_to_sources_and_patches(self) -> None:
         """Fixes paths of patches and sources to make them usable in SPEC file location"""
         rebased_sources_path = os.path.join(constants.RESULTS_DIR, constants.REBASED_SOURCES_DIR)
-        for tag_type in ('Patch', 'Source'):
-            for tag in self.tags.filter(name='{}*'.format(tag_type)):
-                value = self.get_raw_tag_value(tag.name)
-                if value and not urllib.parse.urlparse(value).scheme:
-                    self.set_raw_tag_value(tag.name, value.replace(rebased_sources_path + os.path.sep, ''))
+        with self.spec.sources() as sources:
+            for source in sources:
+                if not source.remote:
+                    source.location = source.location.replace(rebased_sources_path + os.path.sep, '')
+        with self.spec.patches() as patches:
+            for patch in patches:
+                if not patch.remote:
+                    patch.location = patch.location.replace(rebased_sources_path + os.path.sep, '')
 
     @saves
-    def write_updated_patches(self, patches: Dict[str, List[str]], disable_inapplicable: bool) -> None:
+    def write_updated_patches(self, patches_dict: Dict[str, List[str]], disable_inapplicable: bool) -> None:
         """Updates SPEC file according to rebased patches.
 
         Args:
             patches: Dict of lists of modified, deleted or inapplicable patches.
             disable_inapplicable: Whether to comment out inapplicable patches.
         """
-        def is_comment(line):
-            if re.match(r'^#(Patch|Source)[0-9]*\s*:(?!//)', line, re.IGNORECASE):
-                # ignore commented-out tag
-                return False
-            return line.startswith('#')
-
-        def is_empty(line):
-            return not line or line.isspace()
-        if not patches:
+        if not patches_dict:
             return None
         removed_patches = []
         inapplicable_patches = []
         modified_patches = []
-        remove_lines: Dict[int, List[Tuple[int, int]]] = collections.defaultdict(list)
-        for tag in self.tags.filter(name='Patch*'):
-            section = self.spec_content[tag.section_index]
-            if section is None:
-                continue
-            patch_name = os.path.basename(self.get_raw_tag_value(tag.name) or '')
-            if 'deleted' in patches:
-                patch_removed = [x for x in patches['deleted'] if patch_name in x]
+        for patch in self.spec.patches().content: # pylint: disable=no-member
+            if 'deleted' in patches_dict:
+                patch_removed = [x for x in patches_dict['deleted'] if patch.expanded_filename in x]
             else:
                 patch_removed = []
-            if 'inapplicable' in patches:
-                patch_inapplicable = [x for x in patches['inapplicable'] if patch_name in x]
+            if 'inapplicable' in patches_dict:
+                patch_inapplicable = [x for x in patches_dict['inapplicable'] if patch.expanded_filename in x]
             else:
                 patch_inapplicable = []
             if patch_removed:
-                # remove the line of the patch that was removed
-                self.removed_patches.append(patch_name)
-                if tag.index:
-                    removed_patches.append(tag.index)
-                # find associated comments
-                i = tag.line
-                if not self.keep_comments:
-                    # if the tag is followed by an empty line remove empty lines
-                    # in front of the tag to avoid unnecessary blank lines in the spec.
-                    blank_follows = i + 1 < len(section) and is_empty(section[i + 1])
-                    while i > 0 and (is_comment(section[i - 1]) or blank_follows and is_empty(section[i - 1])):
-                        i -= 1
-                remove_lines[tag.section_index].append((i, tag.line + 1))
+                self.removed_patches.append(patch.expanded_filename)
+                removed_patches.append(patch.number)
                 continue
             if patch_inapplicable:
-                if disable_inapplicable:
-                    # comment out line if the patch was not applied
-                    section[tag.line] = '#' + section[tag.line]
-                if tag.index:
-                    inapplicable_patches.append(tag.index)
-            if 'modified' in patches:
-                patch = [x for x in patches['modified'] if patch_name in x]
+                inapplicable_patches.append(patch.number)
+            if 'modified' in patches_dict:
+                patch_modified = [x for x in patches_dict['modified'] if patch.expanded_filename in x]
             else:
-                patch = []
-            if patch:
-                name = os.path.join(constants.RESULTS_DIR, constants.REBASED_SOURCES_DIR, patch_name)
-                self.set_raw_tag_value(tag.name, name)
-                if tag.index:
-                    modified_patches.append(tag.index)
-        for section_index, remove in remove_lines.items():
-            content = self.spec_content[section_index]
-            for span in sorted(remove, key=lambda s: s[0], reverse=True):
-                del content[slice(*span)]
+                patch_modified = []
+            if patch_modified:
+                patch.location = os.path.join(constants.RESULTS_DIR,
+                                              constants.REBASED_SOURCES_DIR,
+                                              patch.expanded_filename)
+                modified_patches.append(patch.number)
         self.process_patch_macros(comment_out=inapplicable_patches if disable_inapplicable else None,
                                   remove=removed_patches, annotate=inapplicable_patches,
                                   note='The following patch contains conflicts')
+        with self.spec.patches() as patches:
+            for number in removed_patches:
+                patches.remove_numbered(number)
+            if disable_inapplicable:
+                for number in inapplicable_patches:
+                    patch = next(p for p in patches if p.number == number)
+                    # comment out line if the patch was not applied
+                    # XXX: this is rather hackish
+                    if isinstance(patch, TagSource):
+                        patch._tag.name = '#' + patch._tag.name # pylint: disable=protected-access
+                    else:
+                        patch._source.location = '#' + patch._source.location # pylint: disable=protected-access
 
     ###################################
     # PACKAGE VERSION RELATED METHODS #
     ###################################
 
     def get_NVR(self) -> str:
-        return '{0.name}-{0.version}-{0.release}'.format(self.header)
-
-    def get_version(self) -> str:
-        # deprecated, kept for backward compatibility
-        return self.header.version
+        # there seems to be a bug in astroid 2.12.13 inference
+        # pylint: disable=not-callable
+        return self.spec.expand('%{name}-%{version}-%{release}')
 
     def get_release(self) -> str:
         """Returns release string without %dist"""
-        release = self.header.release
-        dist = MacroHelper.expand('%{dist}')
-        if dist and release.endswith(dist):
-            release = release[:-len(dist)]
-        return release
+        return self.spec.expanded_release
 
     def parse_release(self) -> Tuple[bool, int, Optional[str]]:
         """Parses release string.
@@ -538,16 +475,22 @@ def parse_release(self) -> Tuple[bool, int, Optional[str]]:
         return bool(m.group(1)), int(m.group(2)), m.group(3)
 
     def set_version(self, version: str, preserve_macros: bool = True) -> None:
-        logger.verbose('Updating version in SPEC from %s to %s', self.header.version, version)
-        self.set_tag('Version', version, preserve_macros=preserve_macros)
+        # there seems to be a bug in astroid 2.12.13 inference
+        # pylint: disable=not-callable
+        logger.verbose('Updating version in SPEC from %s to %s', self.spec.expand('%{version}'), version)
+        if preserve_macros:
+            self.spec.update_tag('Version', version)
+        else:
+            self.spec.version = version
+        self.spec.save()
 
     def set_release(self, release: str, preserve_macros: bool = True) -> None:
         logger.verbose('Changing release to %s', release)
-        self.set_tag('Release', '{}%{{?dist}}'.format(release), preserve_macros=preserve_macros)
-
-    def set_release_number(self, release: str) -> None:
-        # deprecated, kept for backward compatibility
-        self.set_release(release)
+        if preserve_macros:
+            self.spec.update_tag('Release', '{}%{{?dist}}'.format(release))
+        else:
+            self.spec.release = release
+        self.spec.save()
 
     def set_extra_version(self, extra_version: Optional[str], version_changed: bool) -> None:
         """Updates SPEC file with the specified extra version.
@@ -568,297 +511,7 @@ def set_extra_version(self, extra_version: Optional[str], version_changed: bool)
         self.set_release(release)
         # TODO: in some cases it might be necessary to modify Source0
 
-    @saves
-    def set_tag(self, tag: str, value: str, preserve_macros: bool = False) -> None:
-        """Sets value of a tag while trying to preserve macros if requested.
-
-        Note that this method is not intended to be used with non-unique tags, it will only affect the first instance.
-
-        Args:
-            tag: Tag name.
-            value: Tag value.
-            preserve_macros: Whether to attempt to preserve macros in the current tag value.
-
-        """
-        macro_def_re = re.compile(
-            r'''
-            ^
-            (?P<cond>%{!?\?\w+:\s*)?          # conditional macro, keep track of the opening brace
-            (?(cond)%global|%(global|define)) # only global can be conditional, not define
-            \s+
-            (?P<name>\w+)                     # macro name
-            (?P<options>\(.+?\))?             # optional macro arguments
-            \s+
-            (?P<value>
-                (                             # the value consists of multiple macros and/or other text
-                    %(?![{(]) |               # match % but we don't want to consume macros or shell, ignore %( and %{
-                    [^%] |                    # other regular text, do not match %, force the line above to match it
-                    (%((?P<b>{)|(?P
\()))?  # macro or shell, track the parenthesis type
-                    [\s\S]+?                  # macro and shell may be multiline, match ASAP using non-greedy matching
-                    (?(b)})(?(s)\))           # parenthesis must be closed if it was used
-                )+?
-            )
-            (?(cond)})                        # conditional macros must have closing brace
-            $
-            ''',
-            re.VERBOSE | re.MULTILINE)
-
-        def _get_macro_value(macro):
-            """Returns raw value of a macro"""
-            for match in macro_def_re.finditer('\n'.join(self.spec_content.section('%package'))):
-                if match.group('name') == macro:
-                    return match.group('value')
-            return None
-
-        def _redefine_macro(macro, value):
-            """Replaces value of an existing macro"""
-            content = '\n'.join(self.spec_content.section('%package'))
-            for match in macro_def_re.finditer(content):
-                if match.group('name') != macro:
-                    continue
-                content = content[:match.start('value')] + value + content[match.end('value'):]
-                if match.group('options'):
-                    content = content[:match.start('options')] + content[match.end('options'):]
-                break
-            self.spec_content.replace_section('%package', content.split('\n'))
-            self.save()
-
-        def _find_macros(s):
-            """Returns all redefinable macros present in a string"""
-            macro_re = re.compile(r'%(?P<brace>{\??)?(?P<name>\w+)(?(brace)})')
-            macros = []
-            for match in macro_def_re.finditer('\n'.join(self.spec_content.section('%package'))):
-                macros.append(match.group('name'))
-            result = []
-            for match in macro_re.finditer(s):
-                if not match:
-                    continue
-                if match.group('name') not in macros:
-                    continue
-                result.append((match.group('name'), match.span()))
-            return result
-
-        def _expand_macros(s):
-            """Expands all redefinable macros containing redefinable macros.
-
-            Keeps track of all expanded macros. Returns the expanded string and a set of expanded
-            macro names.
-            """
-            replace = []
-            macros = set()
-            for macro, span in _find_macros(s):
-                value = _get_macro_value(macro)
-                if not value:
-                    continue
-                macros.add(macro)
-                rep, new_macros = _expand_macros(value)
-                macros |= new_macros
-                if _find_macros(rep):
-                    replace.append((rep, span))
-            for rep, span in reversed(replace):
-                s = s[:span[0]] + rep + s[span[1]:]
-            return s, macros
-
-        def _tokenize(s):
-            """Removes conditional macros and splits string on macro boundaries"""
-            def parse(inp):
-                tree = []
-                text = ''
-                macro = ''
-                buf = ''
-                escape = False
-                while inp:
-                    c = inp.pop(0)
-                    if c == '%':
-                        c = inp.pop(0)
-                        if c == '%':
-                            text += c
-                        elif c == '{':
-                            if text:
-                                tree.append(('t', text))
-                                text = ''
-                            while inp and c not in ':}':
-                                c = inp.pop(0)
-                                buf += c
-                            if c == ':':
-                                tree.append(('c', buf[:-1], parse(inp)))
-                                buf = ''
-                            elif c == '}':
-                                tree.append(('m', buf[:-1]))
-                                buf = ''
-                        elif c == '(':
-                            if text:
-                                tree.append(('t', text))
-                                text = ''
-                            tree.append(('s', None, parse(inp)))
-                        else:
-                            if text:
-                                tree.append(('t', text))
-                                text = ''
-                            while inp and (c.isalnum() or c == '_'):
-                                c = inp.pop(0)
-                                macro += c
-                            tree.append(('m', macro))
-                            macro = ''
-                    elif c == '$':
-                        text += c
-                        c = inp.pop(0)
-                        if c == '{':
-                            text += c
-                            escape = True
-                    elif c == '}':
-                        if escape:
-                            text += c
-                            escape = False
-                        else:
-                            if text:
-                                tree.append(('t', text))
-                            inp.append(c)
-                            return tree
-                    elif c == ')':
-                        if text:
-                            tree.append(('t', text))
-                        inp.append(c)
-                        return tree
-                    else:
-                        text += c
-                if text:
-                    tree.append(('t', text))
-                return tree
-
-            def traverse(tree):
-                result = []
-                for node in tree:
-                    if node[0] == 't':
-                        # split text nodes on usual separators
-                        result.extend([t for t in re.split(r'(\.|-|_)', node[1]) if t])
-                    elif node[0] == 'm':
-                        m = '%{{{}}}'.format(node[1])
-                        if MacroHelper.expand(m):
-                            result.append(m)
-                    elif node[0] == 'c':
-                        if MacroHelper.expand('%{{{}:1}}'.format(node[1])):
-                            result.extend(traverse(node[2]))
-                    elif node[0] == 's':
-                        # ignore shell expansions, push nonsensical value
-                        result.append('@')
-                return result
-
-            inp = list(s)
-            tree = parse(inp)
-            return traverse(tree)
-
-        def _sync_macros(s):
-            """Makes all macros present in a string up-to-date in rpm context"""
-            _, macros = _expand_macros(s)
-            for macro in macros:
-                MacroHelper.purge_macro(macro)
-                value = _get_macro_value(macro)
-                if value and MacroHelper.expand(value):
-                    rpm.addMacro(macro, value)
-
-        def _process_value(curval, newval):
-            """
-            Replaces non-redefinable-macro parts of curval with matching parts from newval
-            and redefines values of macros accordingly
-            """
-            value, _ = _expand_macros(curval)
-            _sync_macros(curval + newval)
-            tokens = _tokenize(value)
-            values = [None] * len(tokens)
-            sm = SequenceMatcher(a=newval)
-            i = 0
-            # split newval to match tokens
-            for index, token in enumerate(tokens):
-                if token[0] == '%':
-                    # for macros, try both literal and expanded value
-                    for v in [token, MacroHelper.expand(token, token)]:
-                        sm.set_seq2(v)
-                        m = sm.find_longest_match(i, len(newval), 0, len(v))
-                        valid = m.size == len(v)  # only full match is valid
-                        if valid:
-                            break
-                else:
-                    sm.set_seq2(token)
-                    m = sm.find_longest_match(i, len(newval), 0, len(token))
-                    valid = m.size > 0
-                if not valid:
-                    continue
-                if token == sm.b:
-                    tokens[index] = token[m.b:m.b+m.size]
-                if index > 0:
-                    values[index] = newval[m.a:m.a+m.size]
-                    if not values[index - 1]:
-                        values[index - 1] = newval[i:m.a]
-                    else:
-                        values[index - 1] += newval[i:m.a]
-                else:
-                    values[index] = newval[i:m.a+m.size]
-                i = m.a + m.size
-            if newval[i:] and values:
-                if not values[-1]:
-                    values[-1] = newval[i:]
-                else:
-                    values[-1] += newval[i:]
-            # try to fill empty macros
-            for index, token in enumerate(tokens):
-                if token[0] == '%':
-                    continue
-                if token == values[index]:
-                    continue
-                for i in range(index - 1, 0, -1):
-                    if tokens[i][0] == '%' and not values[i]:
-                        values[i] = values[index]
-                        values[index] = None
-                        break
-            # try to make values of identical macros equal
-            for index, token in enumerate(tokens):
-                if token[0] != '%':
-                    continue
-                for i in range(index - 1, 0, -1):
-                    if tokens[i] == token:
-                        idx = values[index].find(values[i])
-                        if idx >= 0:
-                            prefix = values[index][:idx]
-                            for j in range(index - 1, i + 1, -1):
-                                # first non-macro token
-                                if tokens[j][0] != '%':
-                                    if prefix.endswith(values[j]):
-                                        # move token from the end of prefix to the beginning
-                                        prefix = values[j] + prefix[:prefix.find(values[j])]
-                                    else:
-                                        # no match with prefix, cannot continue
-                                        break
-                                else:
-                                    # remove prefix from the original value and append it to the value of this macro
-                                    values[index] = values[index][idx:]
-                                    values[j] += prefix
-                                    break
-                        break
-            # redefine macros and update tokens
-            for index, token in enumerate(tokens):
-                if token == values[index]:
-                    continue
-                if not values[index]:
-                    values[index] = '%{nil}' if token[0] == '%' else ''
-                macros = _find_macros(token)
-                if macros:
-                    _redefine_macro(macros[0][0], values[index])
-                else:
-                    tokens[index] = values[index]
-            result = ''.join(tokens)
-            _sync_macros(curval + result)
-            # only change value if necessary
-            if MacroHelper.expand(curval) == MacroHelper.expand(result):
-                return curval
-            return result
-
-        if preserve_macros:
-            value = _process_value(self.get_raw_tag_value(tag) or '', value)
-        self.set_raw_tag_value(tag, value)
-
-    @staticmethod
-    def extract_version_from_archive_name(archive_path: str, main_source: str) -> str:
+    def extract_version_from_archive_name(self, archive_path: str, main_source: str) -> str:
         """Extracts version string from source archive name.
 
         Args:
@@ -876,9 +529,9 @@ def extract_version_from_archive_name(archive_path: str, main_source: str) -> st
             '|'.join([re.escape(a) for a in Archive.get_supported_archives()]))
         source = os.path.basename(main_source)
         regex = re.sub(r'%({)?version(?(1)})(.*%(\w+|{.+}))?', 'PLACEHOLDER', source, flags=re.IGNORECASE)
-        regex = MacroHelper.expand(regex, regex)
+        regex = self.expand(regex, regex)
         regex = re.escape(regex).replace('PLACEHOLDER', r'(.+)')
-        if regex == re.escape(MacroHelper.expand(source, source)):
+        if regex == re.escape(self.expand(source, source)):
             # no substitution was made, use the fallback regex
             regex = fallback_regex
         logger.debug('Extracting version from archive name using %s', regex)
@@ -972,41 +625,20 @@ def get_main_files_section(self):
             str: Name of the main files section.
 
         """
-        for sec_name, _ in self.spec_content.sections:
-            if sec_name.startswith('%files'):
-                if self.get_subpackage_name(sec_name) == '%{name}':
-                    return sec_name
+        return next(
+            (
+                s.id
+                for s in self.spec.sections().content # pylint: disable=no-member
+                if s.normalized_id.startswith("files")
+                and self.get_subpackage_name(s.id) == "%{name}"
+            ),
+            None,
+        )
 
     #############################################
     # SPEC CONTENT MANIPULATION RELATED METHODS #
     #############################################
 
-    def _read_spec_content(self) -> SpecContent:
-        """Reads the content of the Spec file.
-
-        Returns:
-            The created SpecContent instance.
-
-        Raises:
-            RebaseHelperError: If the Spec file cannot be read.
-
-        """
-        try:
-            with open(self.path, encoding=constants.ENCODING) as f:
-                content = f.read()
-        except IOError as e:
-            raise RebaseHelperError("Unable to open and read SPEC file '{}'".format(self.path)) from e
-        return SpecContent(content)
-
-    def _write_spec_content(self):
-        """Writes the current state of SpecContent into a file."""
-        logger.verbose("Writing SPEC file '%s' to the disc", self.path)
-        try:
-            with open(self.path, "w", encoding=constants.ENCODING) as f:
-                f.write(str(self.spec_content))
-        except IOError as e:
-            raise RebaseHelperError("Unable to write updated data to SPEC file '{}'".format(self.path)) from e
-
     def copy(self, new_path):
         """Creates a copy of the current object and copies the SPEC file
         to a new location.
@@ -1018,9 +650,9 @@ def copy(self, new_path):
             SpecFile: The created SpecFile instance.
 
         """
-        shutil.copy(self.path, new_path)
-        new_object = SpecFile(new_path, self.sources_location, self.predefined_macros,
-                              self.lookaside_cache_preset, self.keep_comments)
+        shutil.copy(self.spec.path, new_path)
+        new_object = SpecFile(new_path, self.spec.sourcedir, self.spec.macros,
+                              self.lookaside_cache_preset)
         return new_object
 
     def reload(self):
@@ -1029,7 +661,7 @@ def reload(self):
 
     def save(self) -> None:
         """Saves changes made to SpecContent and updates the internal state."""
-        self._write_spec_content()
+        self.spec.save()
         #  Update internal variables
         self.update()
 
@@ -1043,11 +675,11 @@ def is_test_suite_enabled(self):
 
         :return: True if enabled or False if not
         """
-        check_section = self.spec_content.section('%check')
-        if not check_section:
+        sections = self.spec.sections().content # pylint: disable=no-member
+        if 'check' not in sections:
             return False
         # Remove commented lines
-        check_section = [x.strip() for x in check_section if not x.strip().startswith('#')]
+        check_section = [x.strip() for x in sections.check if not x.strip().startswith('#')]
         # If there is at least one line with some command in %check we assume test suite is run
         if check_section:
             return True
@@ -1062,70 +694,16 @@ def update_changelog(self, changelog_entry: str) -> None:
             changelog_entry: Message to use in the entry.
 
         """
-        new_entry = self.get_new_log(changelog_entry)
-        changelog = self.spec_content.section('%changelog')
-        if changelog is None:
-            changelog = []
-            self.spec_content.replace_section('%changelog', changelog)
-        changelog[0:0] = new_entry
-
-    def get_new_log(self, changelog_entry):
-        """Constructs a new changelog entry.
-
-        Args:
-            changelog_entry (str): Message to use in the entry.
-
-        Returns:
-            list: List of lines of the new entry.
-
-        """
-        new_record = []
-        today = date.today()
-        evr = '{epoch}:{ver}-{rel}'.format(epoch=self.header.epochnum,
-                                           ver=self.header.version,
-                                           rel=self.get_release())
-        evr = evr[2:] if evr.startswith('0:') else evr
-        # %changelog entries are always in the C locale
-        old_locale = locale.getlocale(locale.LC_TIME)
-        try:
-            locale.setlocale(locale.LC_TIME, "C")
-            day=today.strftime('%a %b %d %Y')
-        finally:
-            try:
-                locale.setlocale(locale.LC_TIME, old_locale)
-            except locale.Error:
-                # we can't really do anything reasonable here, just keep the C locale
-                pass
-        new_record.append('* {day} {name} <{email}> - {evr}'.format(day=day,
-                                                                    name=GitHelper.get_user(),
-                                                                    email=GitHelper.get_email(),
-                                                                    evr=evr))
-        self.update()
-        # FIXME: ugly workaround for mysterious rpm bug causing macros to disappear
-        self.update()
-        new_record.append(MacroHelper.expand(changelog_entry, changelog_entry))
-        new_record.append('')
-        return new_record
-
-    def _get_setup_parser(self):
-        """
-        Construct ArgumentParser for parsing %(auto)setup macro arguments
-
-        :return: constructed ArgumentParser
-        """
-        parser = SilentArgumentParser()
-        parser.add_argument('-n', default=MacroHelper.expand('%{name}-%{version}', '%{name}-%{version}'))
-        parser.add_argument('-a', type=int, default=-1)
-        parser.add_argument('-b', type=int, default=-1)
-        parser.add_argument('-T', action='store_true')
-        parser.add_argument('-q', action='store_true')
-        parser.add_argument('-c', action='store_true')
-        parser.add_argument('-D', action='store_true')
-        parser.add_argument('-v', action='store_true')
-        parser.add_argument('-N', action='store_true')
-        parser.add_argument('-p', type=int, default=-1)
-        parser.add_argument('-S', default='')
-        return parser
+        # there seems to be a bug in astroid 2.12.13 inference
+        # pylint: disable=not-callable
+        with self.spec.sections() as sections:
+            if 'changelog' not in sections:
+                sections.append(Section('changelog'))
+        self.spec.add_changelog_entry(
+            self.spec.expand(changelog_entry),
+            author=GitHelper.get_user(),
+            email=GitHelper.get_email(),
+        )
 
     def get_setup_dirname(self):
         """
@@ -1133,27 +711,11 @@ def get_setup_dirname(self):
 
         :return: dirname
         """
-        parser = self._get_setup_parser()
-
-        prep = self.spec_content.section('%prep')
-        if not prep:
-            return None
-
-        for line in prep:
-            if line.startswith('%setup') or line.startswith('%autosetup'):
-                args = shlex.split(line)
-                args = [MacroHelper.expand(a, '') for a in args[1:]]
-
-                # parse macro arguments
-                try:
-                    ns, _ = parser.parse_known_args(args)
-                except ParseError:
-                    continue
-
-                # check if this macro instance is extracting Source0
-                if not ns.T or ns.a == 0 or ns.b == 0:
-                    return ns.n
-
+        for macro in self.spec.prep().content.macros: # pylint: disable=no-member
+            if not macro.name.endswith('setup'):
+                continue
+            if not macro.options.T or macro.options.a == 0 or macro.options.b == 0:
+                return macro.options.n
         return None
 
     @saves
@@ -1163,71 +725,27 @@ def update_setup_dirname(self, dirname):
 
         :param dirname: new dirname to be used
         """
-        parser = self._get_setup_parser()
-
-        prep = self.spec_content.section('%prep')
-        if not prep:
-            return
-
-        for index, line in enumerate(prep):
-            if line.startswith('%setup') or line.startswith('%autosetup'):
-                args = shlex.split(line)
-                macro = args[0]
-                args = [MacroHelper.expand(a, '') for a in args[1:]]
-
-                # parse macro arguments
-                try:
-                    ns, unknown = parser.parse_known_args(args)
-                except ParseError:
+        # there seems to be a bug in astroid 2.12.13 inference
+        # pylint: disable=not-callable
+        with self.spec.prep() as prep:
+            for macro in prep.macros:
+                if not macro.name.endswith('setup'):
                     continue
-
                 # check if this macro instance is extracting Source0
-                if ns.T and ns.a != 0 and ns.b != 0:
+                if macro.options.T and macro.options.a != 0 and macro.options.b != 0:
                     continue
-
                 # check if modification is really necessary
-                if dirname != ns.n:
-                    new_dirname = dirname
-
-                    # get %{name} and %{version} macros
-                    macros = [m for m in MacroHelper.filter(self.macros, level=-3) if m['name'] in ('name', 'version')]
-                    # add all macros from spec file scope
-                    macros.extend(MacroHelper.filter(self.macros, level=0))
-                    # omit short macros
-                    macros = [m for m in macros if len(m['value']) > 1]
-                    # ensure maximal greediness
-                    macros.sort(key=lambda k: len(k['value']), reverse=True)
-
-                    # substitute tokens with macros
-                    for m in macros:
-                        if m['value'] and m['value'] in dirname:
-                            new_dirname = new_dirname.replace(m['value'], '%{{{}}}'.format(m['name']))
-
-                    args = [macro]
-                    args.extend(['-n', new_dirname])
-                    if ns.a != -1:
-                        args.extend(['-a', str(ns.a)])
-                    if ns.b != -1:
-                        args.extend(['-b', str(ns.b)])
-                    if ns.T:
-                        args.append('-T')
-                    if ns.q:
-                        args.append('-q')
-                    if ns.c:
-                        args.append('-c')
-                    if ns.D:
-                        args.append('-D')
-                    if ns.v:
-                        args.append('-v')
-                    if ns.N:
-                        args.append('-N')
-                    if ns.p != -1:
-                        args.extend(['-p', str(ns.p)])
-                    if ns.S != '':
-                        args.extend(['-S', ns.S])
-                    args.extend(unknown)
-
-                    prep[index] = ' '.join(args)
+                if dirname != self.spec.expand(macro.options.n):
+                    macro.options.n = self.substitute_path_with_macros(
+                        dirname,
+                        lambda m: not m.options
+                            and len(m.body) > 1
+                            and (
+                                m.level == MacroLevel.SPEC
+                                and m.name in ("name", "version")
+                                or m.level == MacroLevel.GLOBAL
+                            ),
+                    )
 
     def find_archive_target_in_prep(self, archive):
         """
@@ -1245,7 +763,7 @@ def find_archive_target_in_prep(self, archive):
         unzip_parser = argparse.ArgumentParser()
         unzip_parser.add_argument('-d', default='.', dest='target')
         archive = os.path.basename(archive)
-        builddir = MacroHelper.expand('%{_builddir}', '')
+        builddir = self.expand('%{_builddir}', '')
         basedir = builddir
         for line in self.get_prep_section():
             tokens = shlex.split(line, comments=True)
@@ -1282,3 +800,52 @@ def find_archive_target_in_prep(self, archive):
                     basedir = os.path.relpath(basedir, builddir)
                     return os.path.normpath(os.path.join(basedir, target))
         return None
+
+    def expand(self, s: str, default: str = '') -> str:
+        try:
+            # there seems to be a bug in astroid 2.12.13 inference
+            # pylint: disable=not-callable
+            return self.spec.expand(s)
+        except RPMException:
+            return default
+
+    def substitute_path_with_macros(self, path: str, condition: Optional[Callable] = None):
+        """Substitutes parts of a path with macros.
+
+        Args:
+            path: Path to be changed.
+            condition: Condition determining if that particular active macro
+              is a suitable substitution.
+
+        Returns:
+            Path expressed using macros.
+        """
+        # there seems to be a bug in astroid 2.12.13 inference
+        # pylint: disable=not-callable
+        if condition is None:
+            condition = lambda m: m.name in MACROS_WHITELIST
+        macros = [m for m in self.spec.get_active_macros() if condition(m)]
+        for macro in macros:
+            macro.body = self.spec.expand(macro.body)
+        # ensure maximal greediness
+        macros.sort(key=lambda m: len(m.body), reverse=True)
+        for macro in macros:
+            if macro.body and macro.body in path:
+                path = path.replace(macro.body, '%{{{}}}'.format(macro.name))
+        return path
+
+    @classmethod
+    def get_comment_span(cls, line: str, script_section: bool) -> Tuple[int, int]:
+        """Gets span of a comment depending on the section.
+
+        Args:
+            line: Line to find the comment in.
+            script_section: Whether the section the line is in is a shell script.
+
+        Returns:
+            Span of the comment. If no comment is found, both tuple elements
+            are equal to the length of the line for convenient use in a slice.
+
+        """
+        comment = re.search(r" #.*" if script_section else r"^\s*#.*", line)
+        return comment.span() if comment else (len(line), len(line))
diff --git a/rebasehelper/tags.py b/rebasehelper/tags.py
deleted file mode 100644
index 73d35daeb..000000000
--- a/rebasehelper/tags.py
+++ /dev/null
@@ -1,244 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
-
-import collections.abc
-import fnmatch
-import re
-from typing import Dict, Iterator, List, Optional, Tuple, cast
-
-from rebasehelper.spec_content import SpecContent
-from rebasehelper.helpers.macro_helper import MacroHelper
-
-
-class Tag:
-    def __init__(self, section_index: int, section_name: str, line: int, name: str, value_span: Tuple[int, int],
-                 valid: bool, index: Optional[int] = None) -> None:
-        self.section_index: int = section_index
-        self.section_name: str = section_name
-        self.line: int = line
-        self.name: str = name
-        self.value_span: Tuple[int, int] = value_span
-        self.valid: bool = valid
-        self.index: Optional[int] = index
-
-    def __eq__(self, other):
-        return (self.section_index == other.section_index and
-                self.section_name == other.section_name and
-                self.line == other.line and
-                self.name == other.name and
-                self.value_span == other.value_span and
-                self.valid == other.valid and
-                self.index == other.index)
-
-    def __str__(self) -> str:
-        return (
-            "Tag("
-            f"section_index='{self.section_index}', "
-            f"section_name='{self.section_name}', "
-            f"line='{self.line}', "
-            f"name='{self.name}', "
-            f"value_span='{self.value_span}', "
-            f"valid='{self.valid}', "
-            f"index='{self.index}')"
-        )
-
-
-class Tags(collections.abc.Sequence):
-    def __init__(self, raw_content: SpecContent, parsed_content: SpecContent) -> None:
-        self.items: Tuple[Tag] = self._parse(raw_content, parsed_content)
-
-    def __getitem__(self, index):
-        return self.items[index]
-
-    def __len__(self):
-        return len(self.items)
-
-    @classmethod
-    def _map_sections_to_parsed(cls, raw_content: SpecContent, parsed_content: SpecContent) -> Dict[int, int]:
-        """Creates a mapping of raw sections to parsed sections.
-
-        Returns:
-            A dict where the keys are indexes of sections in raw content and values are their
-            counterparts in parsed content. Value is set to -1 if the section is not in
-            parsed SpecContent.
-        """
-        result: Dict[int, int] = {}
-        parsed = 0
-        for index, (section, _) in enumerate(raw_content.sections):
-            if parsed < len(parsed_content.sections) and parsed_content.sections[parsed][0].lower() == section.lower():
-                result[index] = parsed
-                parsed += 1
-            else:
-                result[index] = -1
-
-        return result
-
-    @classmethod
-    def _parse(cls, raw_content: SpecContent, parsed_content: SpecContent) -> Tuple[Tag]:
-        result: List[Tag] = []
-        parsed_mapping = cls._map_sections_to_parsed(raw_content, parsed_content)
-        next_source_index = 0
-        next_patch_index = 0
-        # counts number of occurrences of one particular section
-        for section_index, (section, section_content) in enumerate(raw_content.sections):
-            section = section.lower()
-            parsed_section_index = parsed_mapping[section_index]
-            parsed: List[str] = [] if parsed_section_index == -1 else parsed_content.sections[parsed_section_index][1]
-            if section.startswith('%package'):
-                tags, next_source_index, next_patch_index = cls._parse_package_tags(section, section_content, parsed,
-                                                                                    section_index, next_source_index,
-                                                                                    next_patch_index)
-                result.extend(tags)
-            elif section.startswith('%sourcelist') or section.startswith('%patchlist'):
-                tags, next_source_index, next_patch_index = cls._parse_list_tags(section, section_content, parsed,
-                                                                                 section_index, next_source_index,
-                                                                                 next_patch_index)
-                result.extend(tags)
-        return cast(Tuple[Tag], tuple(result))
-
-    @classmethod
-    def _sanitize_tag(cls, name: str, next_source_index: int,
-                      next_patch_index: int) -> Tuple[str, Optional[int], int, int]:
-        """Sanitizes a tag name.
-
-        Capitalizes the tag. If the tag is numbered, strips padded zeroes.
-        Implements automatic numbering of Patches and Sources (adds the number
-        to their name).
-        """
-        name, *rest = re.split(r'(\d+)', name.lower(), 1)
-        sanitized_name = name.capitalize()
-        next_index = next_source_index if name == 'source' else next_patch_index
-        index = int(next(iter(rest), next_index))
-        if name == 'source':
-            return '{}{}'.format(sanitized_name, index), index, index + 1, next_patch_index
-        if name == 'patch':
-            return '{}{}'.format(sanitized_name, index), index, next_source_index, index + 1
-        return sanitized_name, None, next_source_index, next_patch_index
-
-    @classmethod
-    def _parse_package_tags(cls, section: str, section_content: List[str], parsed: List[str], section_index: int,
-                            next_source_index: int, next_patch_index: int) -> Tuple[List[Tag], int, int]:
-        """Parses all tags in a %package section and determines if they are valid.
-
-        A tag is considered valid if it is still present after evaluating all conditions.
-
-        Note that this is not perfect - if the same tag appears in both %if and %else blocks,
-        and has the same value in both, it's impossible to tell them apart, so only the latter
-        is considered valid, disregarding the actual condition.
-
-        Returns:
-              A tuple containing: a tuple of all Tags object, new next source index, new next patch index.
-
-              Indexed tag names are sanitized, for example 'Source' is replaced with 'Source0'
-              and 'Patch007' with 'Patch7'.
-
-              Tag names are capitalized, section names are lowercase.
-
-        """
-        result = []
-        tag_re = re.compile(r'^(?P<prefix>(?P<name>\w+)\s*:\s*)(?P<value>.+)$')
-        for line_index, line in enumerate(section_content):
-            expanded = MacroHelper.expand(line, suppress_errors=True)
-            if not line or not expanded:
-                continue
-            valid = bool(parsed and [p for p in parsed if p == expanded.rstrip()])
-            m = tag_re.match(line)
-            if m:
-                tag_name, tag_index, next_source_index, next_patch_index = cls._sanitize_tag(m.group('name'),
-                                                                                             next_source_index,
-                                                                                             next_patch_index)
-                result.append(Tag(section_index, section, line_index, tag_name, m.span('value'), valid, tag_index))
-                continue
-            m = tag_re.match(expanded)
-            if m:
-                start = line.find(m.group('prefix'))
-                if start < 0:
-                    # tag is probably defined by a macro, just ignore it
-                    continue
-                # conditionalized tag
-                line = line[start:].rstrip('}')  # FIXME: removing trailing braces is not very robust
-                m = tag_re.match(line)
-                if m:
-                    span = cast(Tuple[int, int], tuple(x + start for x in m.span('value')))
-                    tag_name, tag_index, next_source_index, next_patch_index = cls._sanitize_tag(m.group('name'),
-                                                                                                 next_source_index,
-                                                                                                 next_patch_index)
-                    result.append(Tag(section_index, section, line_index, tag_name, span, valid, tag_index))
-
-        return result, next_source_index, next_patch_index
-
-    @classmethod
-    def _parse_list_tags(cls, section: str, section_content: List[str], parsed: List[str], section_index: int,
-                         next_source_index: int, next_patch_index: int) -> Tuple[List[Tag], int, int]:
-        """Parses all tags in a %sourcelist or %patchlist section.
-
-        Only parses tags that are valid (that is - are in parsed), nothing more can
-        consistently be detected.
-
-        Follows how rpm works, the new Source/Patch tags are indexed starting from
-        the last parsed Source/Patch tag.
-
-        """
-        tag = 'Source' if section == '%sourcelist' else 'Patch'
-        result = []
-        for i, line in enumerate(section_content):
-            expanded = MacroHelper.expand(line, suppress_errors=True)
-            is_comment = SpecContent.get_comment_span(line, section)[0] != len(line)
-            if not expanded or not line or is_comment or not [p for p in parsed if p == expanded.rstrip()]:
-                continue
-            tag_name, tag_index, next_source_index, next_patch_index = cls._sanitize_tag(tag, next_source_index,
-                                                                                         next_patch_index)
-            result.append(Tag(section_index, section, i, tag_name, (0, len(line)), True, tag_index))
-
-        return result, next_source_index, next_patch_index
-
-    @classmethod
-    def _filter(cls, tags: Tuple[Tag], section_index: Optional[int] = None, section_name: Optional[str] = None,
-                name: Optional[str] = None, valid: Optional[bool] = True) -> Iterator[Tag]:
-        result = iter(tags)
-        if section_index is not None:
-            result = filter(lambda t: t.section_index == section_index, result)
-        if section_name is not None:
-            result = filter(lambda t: t.section_name == section_name.lower(), result)  # type: ignore
-        if name is not None:
-            result = filter(lambda t: fnmatch.fnmatchcase(t.name, name.capitalize()), result)  # type: ignore
-        if valid is not None:
-            result = filter(lambda t: t.valid == valid, result)
-        return result
-
-    def filter(self, section_index: Optional[int] = None, section_name: Optional[str] = None,
-               name: Optional[str] = None, valid: Optional[bool] = True) -> Iterator[Tag]:
-        """Filters tags based on section, name or validity. Defaults to all valid tags in all sections.
-
-        Args:
-            section_index: If specified, includes tags only from section of this index.
-            section_name: If specified, includes tags only from sections of this name.
-            name: If specified, includes tags matching this name. Wildcards are supported.
-            valid: If specified, includes tags of this validity.
-
-        Returns:
-            Iterator of matching Tag objects.
-
-        """
-        return self._filter(self.items, section_index, section_name, name, valid)
diff --git a/setup.py b/setup.py
index 9dfbc2916..988f4631d 100755
--- a/setup.py
+++ b/setup.py
@@ -54,6 +54,7 @@ def get_requirements():
         'rpkg',
         'unidiff',
         'GitPython',
+        'specfile',
     ]
     # there is no rpm nor gssapi inside RTD build environment
     if not os.getenv('READTHEDOCS'):
@@ -166,9 +167,6 @@ def make_distribution(self):
         'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)',
         'Operating System :: POSIX :: Linux',
         'Programming Language :: Python :: 3 :: Only',
-        'Programming Language :: Python :: 3.6',
-        'Programming Language :: Python :: 3.7',
-        'Programming Language :: Python :: 3.8',
         'Programming Language :: Python :: 3.9',
         'Programming Language :: Python :: 3.10',
         'Programming Language :: Python :: 3.11',
diff --git a/tox.ini b/tox.ini
index 41d29da32..89f9722bf 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist=lint, py{36,37,38,39,310,311}
+envlist=lint, py{39,310,311}
 skipsdist=True
 
 [pytest]


Test patch:
diff --git a/rebasehelper/tests/conftest.py b/rebasehelper/tests/conftest.py
index fa905bf4f..d5916606e 100644
--- a/rebasehelper/tests/conftest.py
+++ b/rebasehelper/tests/conftest.py
@@ -24,15 +24,15 @@
 
 import copy
 import os
+import re
 import shutil
+import types
 
 import pytest  # type: ignore
-import rpm  # type: ignore
+
+from specfile import Specfile
 
 from rebasehelper.specfile import SpecFile
-from rebasehelper.spec_content import SpecContent
-from rebasehelper.tags import Tags
-from rebasehelper.helpers.macro_helper import MacroHelper
 
 
 TESTS_DIR: str = os.path.dirname(__file__)
@@ -40,7 +40,7 @@
 SPEC_FILE: str = 'test.spec'
 
 
-@pytest.yield_fixture(autouse=True)
+@pytest.fixture(autouse=True)
 def workdir(request, tmpdir_factory):
     with tmpdir_factory.mktemp('workdir').as_cwd():
         wd = os.getcwd()
@@ -56,27 +56,49 @@ def spec_object(workdir):  # pylint: disable=redefined-outer-name
     return SpecFile(SPEC_FILE, workdir)
 
 
+class MockedRpmSpec:
+    def __init__(self, parsed):
+        self.parsed = parsed
+
+
+class MockedParser:
+    def __init__(self, content):
+        self.spec = MockedRpmSpec(content)
+
+    def parse(self, content, *_):
+        self.spec.parsed = content
+
+
 @pytest.fixture
 def mocked_spec_object(spec_attributes):
     spec = SpecFile.__new__(SpecFile)
     spec.save = lambda: None
+    spec_content = ''
+    active_macros = []
     for attribute, value in spec_attributes.items():
-        if attribute == 'macros':
-            for macro, properties in value.items():
-                rpm.addMacro(macro, properties.get('value', ''))
-            macros = MacroHelper.dump()
-            for macro, properties in value.items():
-                for m in macros:
-                    if m['name'] == macro:
-                        for prop, v in properties.items():
-                            if prop != 'value':
-                                m[prop] = v
-            value = macros
-        if attribute == 'spec_content' and isinstance(value, str):
-            value = SpecContent(value)
+        if attribute == 'spec_content':
+            spec_content = value
+            continue
+        elif attribute == 'macros':
+            active_macros = value.copy()
         setattr(spec, attribute, value)
-    if hasattr(spec, 'spec_content') and not hasattr(spec, 'tags'):
-        spec.tags = Tags(spec.spec_content, spec.spec_content)
+    spec.spec = Specfile.__new__(Specfile)
+    spec.spec.autosave = False
+    spec.spec.save = lambda: None
+    spec.spec._lines = spec_content.splitlines()  # pylint: disable=protected-access
+    spec.spec._parser = MockedParser(spec_content)  # pylint: disable=protected-access
+    spec.spec.get_active_macros = lambda: active_macros
+    def expand(self, expression, **_):
+        macros = self.get_active_macros()
+        def replace(match):
+            if match.group(2).count("!") % 2 > 0:
+                return ''
+            return next((m.body for m in macros if m.name == match.group(3)), '')
+        macro_re = re.compile(r'%({([!?]*))?(\w+)(?(1)})')
+        while macro_re.search(expression):
+            expression = macro_re.sub(replace, expression)
+        return expression
+    spec.spec.expand = types.MethodType(expand, spec.spec)
     return spec
 
 
diff --git a/rebasehelper/tests/functional/conftest.py b/rebasehelper/tests/functional/conftest.py
index 1e9450b61..6fe8463b1 100644
--- a/rebasehelper/tests/functional/conftest.py
+++ b/rebasehelper/tests/functional/conftest.py
@@ -55,7 +55,7 @@ def make_artifacts_report():
     return '\n'.join(report)
 
 
-@pytest.mark.hookwrapper
+@pytest.hookimpl(hookwrapper=True)
 def pytest_runtest_makereport():
     outcome = yield
     report = outcome.get_result()
diff --git a/rebasehelper/tests/helpers/test_macro_helper.py b/rebasehelper/tests/helpers/test_macro_helper.py
deleted file mode 100644
index a05ceee14..000000000
--- a/rebasehelper/tests/helpers/test_macro_helper.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
-
-import rpm  # type: ignore
-
-from rebasehelper.helpers.macro_helper import MacroHelper
-
-
-class TestMacroHelper:
-
-    def test_get_macros(self):
-        rpm.addMacro('test_macro', 'test_macro value')
-        macros = MacroHelper.dump()
-        macros = MacroHelper.filter(macros, name='test_macro', level=-1)
-        assert len(macros) == 1
-        assert macros[0]['name'] == 'test_macro'
-        assert macros[0]['value'] == 'test_macro value'
-        assert macros[0]['level'] == -1
diff --git a/rebasehelper/tests/helpers/test_rpm_helper.py b/rebasehelper/tests/helpers/test_rpm_helper.py
index 692546c79..d3c1ed93b 100644
--- a/rebasehelper/tests/helpers/test_rpm_helper.py
+++ b/rebasehelper/tests/helpers/test_rpm_helper.py
@@ -22,8 +22,6 @@
 #          Nikola Forró <nforro@redhat.com>
 #          František Nečas <fifinecas@seznam.cz>
 
-import pytest  # type: ignore
-
 from rebasehelper.helpers.rpm_helper import RpmHelper
 
 
@@ -43,38 +41,3 @@ def test_all_packages_installed_existing(self):
 
     def test_all_packages_installed_one_non_existing(self):
         assert RpmHelper.all_packages_installed(['glibc', 'coreutils', 'non-existing-package']) is False
-
-    @pytest.mark.parametrize('nevra, name, epoch, version, release, arch', [
-        ('libtiff-static-4.0.7-5.fc26.x86_64', 'libtiff-static', None, '4.0.7', '5.fc26', 'x86_64'),
-        ('libtiff-debuginfo-4.0.8-1.fc25', 'libtiff-debuginfo', None, '4.0.8', '1.fc25', None),
-        ('libtiff-tools.i686', 'libtiff-tools', None, None, None, 'i686'),
-        ('libtiff-devel', 'libtiff-devel', None, None, None, None),
-        ('libpng-devel-debuginfo-2:1.6.31-1.fc27.aarch64', 'libpng-devel-debuginfo', 2, '1.6.31', '1.fc27', 'aarch64'),
-        (
-                'python-genmsg-0.3.10-14.20130617git95ca00d.fc28',
-                'python-genmsg',
-                None,
-                '0.3.10',
-                '14.20130617git95ca00d.fc28',
-                None,
-        ),
-        (
-                'golang-github-MakeNowJust-heredoc-devel-0-0.9.gitbb23615.fc28.noarch',
-                'golang-github-MakeNowJust-heredoc-devel',
-                None,
-                '0',
-                '0.9.gitbb23615.fc28',
-                'noarch',
-        ),
-    ], ids=[
-        'libtiff-static-4.0.7-5.fc26.x86_64',
-        'libtiff-debuginfo-4.0.8-1.fc25',
-        'libtiff-tools.i686',
-        'libtiff-devel',
-        'libpng-devel-debuginfo-2:1.6.31-1.fc27.aarch64',
-        'python-genmsg-0.3.10-14.20130617git95ca00d.fc28',
-        'golang-github-MakeNowJust-heredoc-devel-0-0.9.gitbb23615.fc28.noarch',
-    ])
-    def test_split_nevra(self, nevra, name, epoch, version, release, arch):
-        assert RpmHelper.split_nevra(nevra) == dict(name=name, epoch=epoch, version=version,
-                                                    release=release, arch=arch)
diff --git a/rebasehelper/tests/plugins/test_spec_hooks.py b/rebasehelper/tests/plugins/test_spec_hooks.py
index 047f11d3a..d79bae642 100644
--- a/rebasehelper/tests/plugins/test_spec_hooks.py
+++ b/rebasehelper/tests/plugins/test_spec_hooks.py
@@ -23,11 +23,10 @@
 #          František Nečas <fifinecas@seznam.cz>
 
 from textwrap import dedent
-from types import SimpleNamespace
 
 import pytest  # type: ignore
+from specfile.macros import Macro, MacroLevel
 
-from rebasehelper.tags import Tags
 from rebasehelper.plugins.spec_hooks.typo_fix import TypoFix
 from rebasehelper.plugins.spec_hooks.pypi_url_fix import PyPIURLFix
 from rebasehelper.plugins.spec_hooks.escape_macros import EscapeMacros
@@ -43,11 +42,15 @@ class TestSpecHook:
         }
     ])
     def test_typo_fix_spec_hook(self, mocked_spec_object):
-        assert '- This is chnagelog entry with some indentional typos' in \
-               mocked_spec_object.spec_content.section('%changelog')
+        assert (
+            '- This is chnagelog entry with some indentional typos'
+            in mocked_spec_object.spec.sections().content.changelog # pylint: disable=no-member
+        )
         TypoFix.run(mocked_spec_object, mocked_spec_object)
-        assert '- This is changelog entry with some intentional typos' in \
-               mocked_spec_object.spec_content.section('%changelog')
+        assert (
+            '- This is changelog entry with some intentional typos'
+            in mocked_spec_object.spec.sections().content.changelog # pylint: disable=no-member
+        )
 
     @pytest.mark.parametrize('spec_attributes', [
         {
@@ -63,7 +66,14 @@ def test_typo_fix_spec_hook(self, mocked_spec_object):
                 /usr/share/test1.txt
                 /no/macros/here
                 """),
-            'macros': {},
+            'macros':
+                [
+                    Macro('_bindir', None, '/usr/bin', MacroLevel.MACROFILES, False),
+                    Macro('_libdir', None, '/usr/lib', MacroLevel.MACROFILES, False),
+                    Macro('_datadir', None, '/usr/share', MacroLevel.MACROFILES, False),
+                    Macro('_mandir', None, '%{_datadir}/man', MacroLevel.MACROFILES, False),
+                    Macro('_sysconfdir', None, '/etc', MacroLevel.MACROFILES, False),
+                ]
         }
     ])
     def test_paths_to_rpm_macros_spec_hook(self, mocked_spec_object):
@@ -80,8 +90,9 @@ def test_paths_to_rpm_macros_spec_hook(self, mocked_spec_object):
             '/no/macros/here',
         ]
         PathsToRPMMacros.run(mocked_spec_object, mocked_spec_object)
-        assert files == mocked_spec_object.spec_content.section('%files')
-        assert files_devel == mocked_spec_object.spec_content.section('%files devel')
+        sections = mocked_spec_object.spec.sections().content # pylint: disable=no-member
+        assert files == list(sections.files)
+        assert files_devel == list(getattr(sections, 'files devel'))
 
     @pytest.mark.parametrize('spec_attributes', [
         {
@@ -95,11 +106,15 @@ def test_paths_to_rpm_macros_spec_hook(self, mocked_spec_object):
     ])
     def test_escape_macros_spec_hook(self, mocked_spec_object):
         EscapeMacros.run(mocked_spec_object, mocked_spec_object)
-        build = mocked_spec_object.spec_content.section('%build')
-        assert build[0] == "autoreconf -vi # Unescaped macros %%name %%{name}"
+        assert (
+            mocked_spec_object.spec.sections().content.build[0] # pylint: disable=no-member
+            == "autoreconf -vi # Unescaped macros %%name %%{name}"
+        )
         # Test that the string after `#` wasn't recognized as a comment.
-        source9 = mocked_spec_object.get_raw_tag_value('Source9')
-        assert source9 == "https://test.com/#/1.0/%{name}-hardcoded-version-1.0.2.tar.gz"
+        assert (
+            mocked_spec_object.spec.tags().content.source9.value # pylint: disable=no-member
+            == "https://test.com/#/1.0/%{name}-hardcoded-version-1.0.2.tar.gz"
+        )
 
     @pytest.mark.parametrize('replace_with_macro', [
         True,
@@ -112,39 +127,36 @@ def test_escape_macros_spec_hook(self, mocked_spec_object):
         {
             'spec_content': dedent("""\
                 Version: 1.0.2
+                Release: 0.1.b1
                 Source9: https://test.com/#/1.0/%{name}-hardcoded-version-1.0.2b1.tar.gz
                 Recommends: test > 1.0.2
 
                 %changelog
                 * Wed Apr 26 2017 Nikola Forró <nforro@redhat.com> - 1.0.2-34
                 - Update to 1.0.2
-                """),
-            'header': SimpleNamespace(version='1.0.2', release='0.1.b1'),
+                """)
         }
     ])
     def test_replace_old_version_spec_hook(self, mocked_spec_object, mocked_spec_object_copy, replace_with_macro):
-        mocked_spec_object_copy.header.version = '1.1.0'
-        mocked_spec_object_copy.header.release = '1'
-        mocked_spec_object_copy.set_raw_tag_value('Version', '1.1.0')
+        mocked_spec_object_copy.spec.version = '1.1.0'
+        mocked_spec_object_copy.spec.release = '1'
         ReplaceOldVersion.run(mocked_spec_object, mocked_spec_object_copy,
                               replace_old_version_with_macro=replace_with_macro)
-        # The spec is not saved due to mocking, refresh tags for the assertions
-        mocked_spec_object_copy.tags = Tags(mocked_spec_object_copy.spec_content, mocked_spec_object_copy.spec_content)
 
         # Check if the version has been updated
-        test_source = mocked_spec_object_copy.get_raw_tag_value('Source9')
+        test_source = mocked_spec_object_copy.spec.sources().content[0].location # pylint: disable=no-member
         if replace_with_macro:
             assert test_source == 'https://test.com/#/1.1/%{name}-hardcoded-version-%{version}.tar.gz'
         else:
             assert test_source == 'https://test.com/#/1.1/%{name}-hardcoded-version-1.1.0.tar.gz'
 
         # Check if dependency and Version tags are ignored
-        assert mocked_spec_object_copy.get_raw_tag_value('Recommends') == 'test > 1.0.2'
-        assert mocked_spec_object_copy.get_raw_tag_value('Version') == '1.1.0'
+        tags = mocked_spec_object_copy.spec.tags().content # pylint: disable=no-member
+        assert tags.recommends.value == 'test > 1.0.2'
+        assert tags.version.value == '1.1.0'
 
         # Check if version in changelog hasn't been changed
-        changelog = mocked_spec_object_copy.spec_content.section('%changelog')
-        assert '1.0.2' in changelog[0]
+        assert '1.0.2' in mocked_spec_object_copy.spec.sections().content.changelog[0] # pylint: disable=no-member
 
     @pytest.mark.parametrize('spec_attributes', [
         {
@@ -160,5 +172,6 @@ def test_pypi_to_python_hosted_url_trans(self, mocked_spec_object):
             'URL': 'https://pypi.org/project/%{name}',
             'Source0': 'https://files.pythonhosted.org/.../%{name}.%{version}.tar.gz',
         }
+        tags = mocked_spec_object.spec.tags().content # pylint: disable=no-member
         for tag, value in expected.items():
-            assert value == mocked_spec_object.get_raw_tag_value(tag)
+            assert value == getattr(tags, tag).value
diff --git a/rebasehelper/tests/public_api/__init__.py b/rebasehelper/tests/public_api/__init__.py
deleted file mode 100644
index 79e5d55b3..000000000
--- a/rebasehelper/tests/public_api/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
diff --git a/rebasehelper/tests/public_api/test_spec_content.py b/rebasehelper/tests/public_api/test_spec_content.py
deleted file mode 100644
index 2bea58014..000000000
--- a/rebasehelper/tests/public_api/test_spec_content.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
-
-import pytest  # type: ignore
-
-
-@pytest.mark.public_api
-class TestSpecContent:
-    def test_section(self, spec_object):
-        assert isinstance(spec_object.spec_content.section('%install'), list)
-        assert spec_object.spec_content.section(name='%nonexistent') is None
-
-    def test_sections(self, spec_object):
-        assert isinstance(spec_object.spec_content.sections, list)
-        for section in spec_object.spec_content.sections:
-            assert isinstance(section, tuple)
-            assert isinstance(section[1], list)
-
-    def test_replace_section(self, spec_object):
-        section = '%install'
-        replacement = ['test', '']
-        assert spec_object.spec_content.replace_section(section, replacement) is True
-        assert spec_object.spec_content.replace_section(name=section, content=replacement) is True
-        assert spec_object.spec_content.replace_section(name='%notasection', content=[]) is False
diff --git a/rebasehelper/tests/public_api/test_specfile.py b/rebasehelper/tests/public_api/test_specfile.py
deleted file mode 100644
index 245d541e5..000000000
--- a/rebasehelper/tests/public_api/test_specfile.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
-
-import os
-import shutil
-
-import pytest  # type: ignore
-
-from rebasehelper.specfile import SpecFile
-from rebasehelper.tags import Tag
-from rebasehelper.tests.conftest import SPEC_FILE, TEST_FILES_DIR
-
-
-@pytest.mark.public_api
-class TestSpecFile:
-    def test_constructor(self, workdir):
-        shutil.copy(os.path.join(TEST_FILES_DIR, SPEC_FILE), workdir)
-        # check that no mandatory argument was added
-        spec = SpecFile(SPEC_FILE)
-        assert isinstance(spec, SpecFile)
-        # check that arguments were not renamed or removed
-        spec = SpecFile(path=SPEC_FILE, sources_location=workdir, predefined_macros=None,
-                        lookaside_cache_preset='fedpkg', keep_comments=False)
-        assert isinstance(spec, SpecFile)
-
-    def test_update_changelog(self, spec_object):
-        assert spec_object.update_changelog('test2') is None
-        assert spec_object.update_changelog(changelog_entry='test') is None
-
-    def test_set_version(self, spec_object):
-        assert spec_object.set_version('1.2.3.4') is None
-        assert spec_object.set_version(version='1.2.3') is None
-
-    def test_get_version(self, spec_object):
-        assert isinstance(spec_object.get_version(), str)
-
-    def test_set_release_number(self, spec_object):
-        assert spec_object.set_release_number('2') is None
-        assert spec_object.set_release_number(release='1') is None
-
-    def test_set_release(self, spec_object):
-        assert spec_object.set_release('2') is None
-        assert spec_object.set_release(release='1') is None
-
-    def test_get_sources(self, spec_object):
-        assert isinstance(spec_object.get_sources(), list)
-
-    def test_tag(self, spec_object):
-        assert isinstance(spec_object.tag(name='Source*', section='%package'), Tag)
-        assert spec_object.tag(name='NotATag') is None
-
-    def test_set_tag(self, spec_object):
-        assert spec_object.set_tag('Version', '1.3.5') is None
-        assert spec_object.set_tag(tag='Version', value='1.3.5', preserve_macros=True) is None
-
-    def test_save(self, spec_object):
-        assert spec_object.save() is None
-
-    def test_udpate(self, spec_object):
-        assert spec_object.update() is None
-
-    def test_process_patch_macros(self, spec_object):
-        assert spec_object.process_patch_macros() is None
-        assert spec_object.process_patch_macros(comment_out=[0], remove=[1], annotate=[2], note='test') is None
diff --git a/rebasehelper/tests/public_api/test_tags.py b/rebasehelper/tests/public_api/test_tags.py
deleted file mode 100644
index 464fd0215..000000000
--- a/rebasehelper/tests/public_api/test_tags.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
-
-import collections.abc
-
-import pytest  # type: ignore
-
-from rebasehelper.spec_content import SpecContent
-from rebasehelper.tags import Tags, Tag
-
-
-@pytest.mark.public_api
-class TestTags:
-    def test_contructor(self):
-        spec = SpecContent('')
-        tags = Tags(spec, spec)
-        assert isinstance(tags, Tags)
-
-    def test_filter(self, spec_object):
-        result = spec_object.tags.filter()
-        assert isinstance(result, collections.abc.Iterator)
-        assert isinstance(next(result), Tag)
-        result = spec_object.tags.filter(section_index=0, section_name='%package', name='Source*', valid=True)
-        assert isinstance(result, collections.abc.Iterator)
-        assert isinstance(next(result), Tag)
diff --git a/rebasehelper/tests/test_spec_content.py b/rebasehelper/tests/test_spec_content.py
deleted file mode 100644
index 485a975e4..000000000
--- a/rebasehelper/tests/test_spec_content.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
-
-import pytest  # type: ignore
-
-from rebasehelper.constants import ENCODING
-from rebasehelper.spec_content import SpecContent
-from rebasehelper.tests.conftest import SPEC_FILE
-
-
-class TestSpecContent:
-    TEST_FILES = [
-        SPEC_FILE
-    ]
-
-    @pytest.fixture
-    def spec_content(self):
-        with open(SPEC_FILE, 'r', encoding=ENCODING) as infile:
-            return SpecContent(infile.read())
-
-    def test_string_representation(self, spec_content):
-        with open(SPEC_FILE, 'r', encoding=ENCODING) as infile:
-            assert str(spec_content) == infile.read()
-
-    @pytest.mark.parametrize('line, section, expected', [
-        ('#test comment', '%package', (0, 13)),
-        ('make install', '%install', (12, 12)),
-        ('make install # install', '%install', (12, 22)),
-        ('Name: test #invalid comment', '%package', (27, 27)),
-    ], ids=[
-        'whole_line',
-        'no_comment',
-        'inline_allowed',
-        'inline_prohibited',
-    ])
-    def test_get_comment_span(self, line, section, expected):
-        assert SpecContent.get_comment_span(line, section) == expected
-
-    @pytest.mark.parametrize('section, expected', [
-        ('%install', ['make DESTDIR=$RPM_BUILD_ROOT install', '']),
-        ('%package test', None),
-
-    ], ids=[
-        'existent',
-        'nonexistent',
-    ])
-    def test_section(self, spec_content, section, expected):
-        assert spec_content.section(section) == expected
-
-    def test_replace_section(self, spec_content):
-        spec_content.replace_section('%install', ['#removed'])
-        assert spec_content.section('%install') == ['#removed']
diff --git a/rebasehelper/tests/test_specfile.py b/rebasehelper/tests/test_specfile.py
index 911552937..9a3c8cac6 100644
--- a/rebasehelper/tests/test_specfile.py
+++ b/rebasehelper/tests/test_specfile.py
@@ -22,25 +22,16 @@
 #          Nikola Forró <nforro@redhat.com>
 #          František Nečas <fifinecas@seznam.cz>
 
-import locale
 import os
 from typing import List
 from textwrap import dedent
 
 import pytest  # type: ignore
-import rpm  # type: ignore
+from specfile.macros import Macro, MacroLevel
 
-from rebasehelper.tags import Tag
 from rebasehelper.specfile import SpecFile
 
 
-@pytest.fixture(autouse=True)
-def rpm_cleanup():
-    # Some tests change macro values. This can influence other tests.
-    # Prevent it by resetting the macros before each test.
-    rpm.reloadConfig()
-
-
 class TestSpecFile:
     NAME: str = 'test'
     VERSION: str = '1.0.2'
@@ -78,7 +69,7 @@ def test_set_version(self, spec_object):
         NEW_VERSION = '1.2.3.4.5'
         spec_object.set_version(NEW_VERSION)
         spec_object.save()
-        assert spec_object.header.version == NEW_VERSION
+        assert spec_object.spec.expanded_version == NEW_VERSION
 
     @pytest.mark.parametrize('spec_attributes, sources', [
         (
@@ -105,14 +96,14 @@ def test__get_raw_source_string(self, mocked_spec_object, sources):
         assert mocked_spec_object._get_raw_source_string(None) == None
 
     def test_old_tarball(self, spec_object):
-        assert spec_object.get_archive() == self.OLD_ARCHIVE
+        assert spec_object.get_main_source() == self.OLD_ARCHIVE
 
     def test_get_sources(self, workdir, spec_object):
         sources = [self.SOURCE_1, self.SOURCE_5, self.SOURCE_6, self.OLD_ARCHIVE]
         sources = [os.path.join(workdir, f) for f in sources]
         assert len(set(sources).intersection(set(spec_object.get_sources()))) == 4
         # The Source0 has to be always in the beginning
-        assert spec_object.get_archive() == 'test-1.0.2.tar.xz'
+        assert spec_object.get_main_source() == 'test-1.0.2.tar.xz'
 
     def test_get_patches(self, workdir, spec_object):
         expected_patches = {0: [os.path.join(workdir, self.PATCH_1), 1],
@@ -121,7 +112,7 @@ def test_get_patches(self, workdir, spec_object):
                             3: [os.path.join(workdir, self.PATCH_4), 4]}
         patches = {}
         for index, p in enumerate(spec_object.get_patches()):
-            patches[index] = [p.path, p.index]
+            patches[index] = [p.path, p.number]
         assert patches == expected_patches
 
     def test_split_version_string(self):
@@ -133,33 +124,33 @@ def test_split_version_string(self):
         assert SpecFile.split_version_string('1.1.3~rc6', '1.1.3') == ('1.1.3', 'rc6')
         assert SpecFile.split_version_string('1.1.1d', '1.1.1c') == ('1.1.1d', None)
 
-    def test_extract_version_from_archive_name(self):
+    def test_extract_version_from_archive_name(self, spec_object):
         # Basic tests
-        assert SpecFile.extract_version_from_archive_name('test-1.0.1.tar.gz', '') == '1.0.1'
-        assert SpecFile.extract_version_from_archive_name('/home/user/test-1.0.1.tar.gz', '') == '1.0.1'
-        assert SpecFile.extract_version_from_archive_name('test-1.0.1.tar.gz',
-                                                          'ftp://ftp.test.org/test-%{version}.tar.gz') == '1.0.1'
-        assert SpecFile.extract_version_from_archive_name('/home/user/test-1.0.1.tar.gz',
-                                                          'ftp://ftp.test.org/test-%{version}.tar.gz') == '1.0.1'
+        assert spec_object.extract_version_from_archive_name('test-1.0.1.tar.gz', '') == '1.0.1'
+        assert spec_object.extract_version_from_archive_name('/home/user/test-1.0.1.tar.gz', '') == '1.0.1'
+        assert spec_object.extract_version_from_archive_name('test-1.0.1.tar.gz',
+                                                             'ftp://ftp.test.org/test-%{version}.tar.gz') == '1.0.1'
+        assert spec_object.extract_version_from_archive_name('/home/user/test-1.0.1.tar.gz',
+                                                             'ftp://ftp.test.org/test-%{version}.tar.gz') == '1.0.1'
         # Real world tests
         name = 'http://www.cups.org/software/%{version}/cups-%{version}-source.tar.bz2'
-        assert SpecFile.extract_version_from_archive_name('cups-1.7.5-source.tar.bz2',
-                                                          name) == '1.7.5'
+        assert spec_object.extract_version_from_archive_name('cups-1.7.5-source.tar.bz2',
+                                                             name) == '1.7.5'
         name = 'ftp://ftp.isc.org/isc/bind9/%{VERSION}/bind-%{VERSION}.tar.gz'
-        assert SpecFile.extract_version_from_archive_name('bind-9.9.5rc2.tar.gz',
-                                                          name) == '9.9.5rc2'
+        assert spec_object.extract_version_from_archive_name('bind-9.9.5rc2.tar.gz',
+                                                             name) == '9.9.5rc2'
         name = 'http://www.thekelleys.org.uk/dnsmasq/%{?extrapath}%{name}-%{version}%{?extraversion}.tar.xz'
-        assert SpecFile.extract_version_from_archive_name('dnsmasq-2.69rc1.tar.xz',
-                                                          name) == '2.69rc1'
+        assert spec_object.extract_version_from_archive_name('dnsmasq-2.69rc1.tar.xz',
+                                                             name) == '2.69rc1'
         name = 'http://downloads.sourceforge.net/%{name}/%{name}-%{version}%{?prever:-%{prever}}.tar.xz'
-        assert SpecFile.extract_version_from_archive_name('log4cplus-1.1.3-rc3.tar.xz',
-                                                          name) == '1.1.3-rc3'
+        assert spec_object.extract_version_from_archive_name('log4cplus-1.1.3-rc3.tar.xz',
+                                                             name) == '1.1.3-rc3'
         name = 'http://downloads.sourceforge.net/%{name}/%{name}-%{version}%{?prever:_%{prever}}.tar.xz'
-        assert SpecFile.extract_version_from_archive_name('log4cplus-1.1.3_rc3.tar.xz',
-                                                          name) == '1.1.3_rc3'
+        assert spec_object.extract_version_from_archive_name('log4cplus-1.1.3_rc3.tar.xz',
+                                                             name) == '1.1.3_rc3'
         name = 'http://download.gnome.org/sources/libsigc++/%{release_version}/libsigc++-%{version}.tar.xz'
-        assert SpecFile.extract_version_from_archive_name('libsigc++-2.10.0.tar.xz',
-                                                          name) == '2.10.0'
+        assert spec_object.extract_version_from_archive_name('libsigc++-2.10.0.tar.xz',
+                                                             name) == '2.10.0'
 
     @pytest.mark.parametrize('spec_attributes, main_files', [
         (
@@ -170,7 +161,7 @@ def test_extract_version_from_archive_name(self):
                     %files test
                     """)
             },
-            '%files'
+            'files'
         ),
         (
             {
@@ -241,30 +232,33 @@ def test_set_extra_version(self, spec_object):
                 %prep
                 %setup -q -c -a 5
                 """),
-            'get_release': lambda: '34',
             'macros':
-                {
-                    'name': {'value': 'test', 'level': -3},
-                    'version': {'value': '1.0.2', 'level': -3},
-                },
+                [
+                    Macro('release', None, '34', MacroLevel.SPEC, True),
+                    Macro('release_str', None, '%{release}%{?dist}', MacroLevel.SPEC, True),
+                    Macro('name', None, 'test', MacroLevel.SPEC, False),
+                    Macro('version', None, '1.0.2', MacroLevel.SPEC, False),
+                ],
         }
     ])
     def test_update_setup_dirname(self, mocked_spec_object):
         mocked_spec_object.set_extra_version('rc1', False)
 
-        prep = mocked_spec_object.spec_content.section('%prep')
+        prep = mocked_spec_object.spec.sections().content.prep # pylint: disable=no-member
         mocked_spec_object.update_setup_dirname('test-1.0.2')
-        assert mocked_spec_object.spec_content.section('%prep') == prep
+        assert mocked_spec_object.spec.sections().content.prep == prep # pylint: disable=no-member
 
         mocked_spec_object.update_setup_dirname('test-1.0.2rc1')
-        prep = mocked_spec_object.spec_content.section('%prep')
-        setup = [l for l in prep if l.startswith('%setup')][0]
-        assert '-n %{name}-%{version}rc1' in setup
+        assert (
+            mocked_spec_object.spec.prep().content.setup.options.n # pylint: disable=no-member
+            == '%{name}-%{version}rc1'
+        )
 
         mocked_spec_object.update_setup_dirname('test-1.0.2-rc1')
-        prep = mocked_spec_object.spec_content.section('%prep')
-        setup = [l for l in prep if l.startswith('%setup')][0]
-        assert '-n %{name}-%{version}-rc1' in setup
+        assert (
+            mocked_spec_object.spec.prep().content.setup.options.n # pylint: disable=no-member
+            == '%{name}-%{version}-rc1'
+        )
 
     def test_find_archive_target_in_prep(self, spec_object):
         target = spec_object.find_archive_target_in_prep('documentation.tar.xz')
@@ -275,7 +269,6 @@ def test_find_archive_target_in_prep(self, spec_object):
     @pytest.mark.parametrize('spec_attributes, kwargs, expected_content', [
         (
             {
-                'keep_comments': False,
                 'removed_patches': [],
                 'spec_content': dedent("""\
                     Patch0:    0.patch
@@ -293,7 +286,7 @@ def test_find_archive_target_in_prep(self, spec_object):
                     """),
             },
             {
-                'patches':
+                'patches_dict':
                     {
                         'deleted': ['2.patch'],
                         'inapplicable': ['1.patch'],
@@ -316,7 +309,6 @@ def test_find_archive_target_in_prep(self, spec_object):
         ),
         (
             {
-                'keep_comments': False,
                 'removed_patches': [],
                 'spec_content': dedent("""\
                     Patch0:    0.patch
@@ -334,7 +326,7 @@ def test_find_archive_target_in_prep(self, spec_object):
                     """),
             },
             {
-                'patches':
+                'patches_dict':
                     {
                         'deleted': ['2.patch'],
                         'inapplicable': ['1.patch'],
@@ -357,7 +349,6 @@ def test_find_archive_target_in_prep(self, spec_object):
         ),
         (
             {
-                'keep_comments': False,
                 'removed_patches': [],
                 'spec_content': dedent("""\
                     Patch0:     0.patch
@@ -371,7 +362,7 @@ def test_find_archive_target_in_prep(self, spec_object):
                     """),
             },
             {
-                'patches':
+                'patches_dict':
                     {
                         'deleted': ['1.patch'],
                     },
@@ -382,48 +373,15 @@ def test_find_archive_target_in_prep(self, spec_object):
             
             Patch2:     2.patch
             """),
-        ),
-        (
-            {
-                'keep_comments': True,
-                'removed_patches': [],
-                'spec_content': dedent("""\
-                Patch0:     0.patch
-
-
-                # Patch comment
-                # line2
-                Patch1:     1.patch
-
-                Patch2:     2.patch
-                """),
-            },
-            {
-                'patches':
-                    {
-                        'deleted': ['1.patch'],
-                    },
-                'disable_inapplicable': False,
-            },
-            dedent("""\
-            Patch0:     0.patch
-            
-            
-            # Patch comment
-            # line2
-
-            Patch2:     2.patch
-        """),
         )
     ], ids=[
         'do_not_disable_inapplicable',
         'disable_inapplicable',
-        'comments_and_blank_lines',
-        'keep_comments'
+        'comments_and_blank_lines'
     ])
     def test_write_updated_patches(self, mocked_spec_object, kwargs, expected_content):
         mocked_spec_object.write_updated_patches(**kwargs)
-        assert expected_content == str(mocked_spec_object.spec_content)
+        assert expected_content == str(mocked_spec_object.spec)
 
     @pytest.mark.parametrize('spec_attributes', [
         {
@@ -431,168 +389,26 @@ def test_write_updated_patches(self, mocked_spec_object, kwargs, expected_conten
         }
     ])
     def test_update_paths_to_sources_and_patches(self, mocked_spec_object):
-        line = [l for l in mocked_spec_object.spec_content.section('%package') if l.startswith('Patch5')][0]
-        assert 'rebased-sources' in line
-
+        assert 'rebased-sources' in mocked_spec_object.spec.patches().content[0].location # pylint: disable=no-member
         mocked_spec_object.update_paths_to_sources_and_patches()
-
-        line = [l for l in mocked_spec_object.spec_content.section('%package') if l.startswith('Patch5')][0]
-        assert 'rebased-sources' not in line
+        assert 'rebased-sources' not in mocked_spec_object.spec.patches().content[0].location # pylint: disable=no-member
 
     def test_tags(self, spec_object):
+        tags = spec_object.spec.tags().content # pylint: disable=no-member
         # sanity check
-        assert spec_object.tag('Name') == Tag(0, '%package', 16, 'Name', (6, 10), True)
+        assert tags.name.name == 'Name'
         # no workaround
-        assert spec_object.tag('Patch100') is None
-        assert spec_object.tag('Patch101') is not None
-        assert spec_object.tag('Patch102') is None
-        assert spec_object.get_raw_tag_value('Patch101') == 'no_workaround.patch'
-        # workaround
-        spec_object.predefined_macros = {'use_workaround': '1'}
+        assert 'Patch100' in tags and not tags.patch100.valid
+        assert 'Patch101' in tags and tags.patch101.valid
+        assert 'Patch102' in tags and not tags.patch102.valid
+        assert tags.patch101.expanded_value == 'no_workaround.patch'
+        spec_object.spec.macros.append(('use_workaround', '1'))
         spec_object.update()
-        assert spec_object.tag('Patch100') is not None
-        assert spec_object.tag('Patch101') is not None
-        assert spec_object.tag('Patch102') is not None
-        assert spec_object.get_raw_tag_value('Patch100') == 'workaround_base.patch'
-        assert spec_object.get_raw_tag_value('Patch101') == 'workaround_1.patch'
-        assert spec_object.get_raw_tag_value('Patch102') == 'workaround_2.patch'
-
-    @pytest.mark.parametrize('preserve_macros', [
-        False,
-        True,
-    ], ids=[
-        'ignoring_macros',
-        'preserving_macros',
-    ])
-    @pytest.mark.parametrize('tag, value, lines, lines_preserve', [
-        (
-            'Summary',
-            'A testing SPEC file',
-            [
-                '%{!?specfile: %global specfile spec file}',
-                '%global summary %{?longsum}%{!?longsum:A testing %{specfile}}',
-                'Summary: A testing SPEC file',
-            ],
-            [
-                '%{!?specfile: %global specfile SPEC file}',
-                '%global summary %{?longsum}%{!?longsum:A testing %{specfile}}',
-                'Summary: %{summary}',
-            ],
-        ),
-        (
-            'Version',
-            '1.1.8',
-            [
-                '%global version_major 1',
-                '%global version_minor 0',
-                '%global version_patch 2',
-                '%global version_major_minor %{version_major}.%{version_minor}',
-                '%global version %{version_major_minor}.%{version_patch}',
-                'Version: 1.1.8',
-            ],
-            [
-                '%global version_major 1',
-                '%global version_minor 1',
-                '%global version_patch 8',
-                '%global version_major_minor %{version_major}.%{version_minor}',
-                '%global version %{version_major_minor}.%{version_patch}',
-                'Version: %{version}',
-            ],
-        ),
-        (
-            'Release',
-            '42%{?dist}',
-            [
-                '%global release 34',
-                '%global release_str %{release}%{?dist}',
-                'Release: 42%{?dist}',
-            ],
-            [
-                '%global release 42',
-                '%global release_str %{release}%{?dist}',
-                'Release: %{release_str}',
-            ],
-        ),
-        (
-            'Source8',
-            'https://github.com/rebase-helper/rebase-helper/archive/'
-            'b0ed0b235bd5ea295fc897e1e2e8e6b6637f2c2d/'
-            'rebase-helper-b0ed0b235bd5ea295fc897e1e2e8e6b6637f2c2d.tar.gz',
-            [
-                '%global project rebase-helper',
-                '%global commit d70cb5a2f523db5b6088427563531f43b7703859',
-                'Source8: https://github.com/rebase-helper/rebase-helper/archive/'
-                'b0ed0b235bd5ea295fc897e1e2e8e6b6637f2c2d/'
-                'rebase-helper-b0ed0b235bd5ea295fc897e1e2e8e6b6637f2c2d.tar.gz',
-            ],
-            [
-                '%global project rebase-helper',
-                '%global commit b0ed0b235bd5ea295fc897e1e2e8e6b6637f2c2d',
-                'Source8: https://github.com/%{project}/%{project}/archive/%{commit}/%{project}-%{commit}.tar.gz',
-            ],
-        ),
-        (
-            'Patch1000',
-            '0.8.b5%{?dist}',
-            [
-                '%global prever b4',
-                'Patch1000: 0.8.b5%{?dist}',
-            ],
-            [
-                '%global prever b5',
-                'Patch1000: 0.8.%{?prever}%{?dist}',
-            ],
-        ),
-        (
-            'Patch1001',
-            '1.22.2',
-            [
-                '%global branch 1.22',
-                'Patch1001: 1.22.2',
-            ],
-            [
-                '%global branch 1.22',
-                'Patch1001: %{branch}.2',
-            ],
-        ),
-        (
-            'URL',
-            'http://testing2.org',
-            [
-                '%global domain testing',
-                '%global address %{domain}.org',
-                '%global full_address http://%{address}',
-                'URL: http://testing2.org',
-            ],
-            [
-                '%global domain testing2',
-                '%global address %{domain}.org',
-                '%global full_address http://%{address}',
-                'URL: %{full_address}',
-            ],
-        )
-    ], ids=[
-        'Summary=>"A testing SPEC file..."',
-        'Version=>"1.1.8"',
-        'Release=>"42%{?dist}"',
-        'Source8=>"https://github.com/rebase-helper/rebase-helper/archive/..."',
-        'Patch1000=>"0.8.b5%{?dist}"',
-        'Patch1001=>"1.22.2"',
-        'URL=>"http://testing2.org"',
-    ])
-    def test_set_tag(self, spec_object, preserve_macros, tag, value, lines, lines_preserve):
-        spec_object.set_tag(tag, value, preserve_macros=preserve_macros)
-        for line in lines_preserve if preserve_macros else lines:
-            assert line in spec_object.spec_content.section('%package')
-
-    def test_get_new_log_with_non_c_locale(self, spec_object):
-        # Changelogs should be identical no matter what locale
-        changelog = spec_object.get_new_log("test2")
-        for l in locale.locale_alias:
-            try:
-                locale.setlocale(locale.LC_TIME, l)
-                # Prevents us from trying out strange locale aliases that fail
-                locale.setlocale(locale.LC_TIME, locale.getlocale(locale.LC_TIME))
-            except locale.Error:
-                continue
-            assert changelog == spec_object.get_new_log("test2")
+        tags = spec_object.spec.tags().content # pylint: disable=no-member
+        # workaround
+        assert 'Patch100' in tags and tags.patch100.valid
+        assert 'Patch101' in tags and tags.patch101.valid
+        assert 'Patch102' in tags and tags.patch102.valid
+        assert tags.patch100.expanded_value == 'workaround_base.patch'
+        assert tags.patch101.expanded_value == 'workaround_1.patch'
+        assert tags.patch102.expanded_value == 'workaround_2.patch'
diff --git a/rebasehelper/tests/test_tags.py b/rebasehelper/tests/test_tags.py
deleted file mode 100644
index 65af8dbf7..000000000
--- a/rebasehelper/tests/test_tags.py
+++ /dev/null
@@ -1,133 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This tool helps you rebase your package to the latest version
-# Copyright (C) 2013-2019 Red Hat, Inc.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-#
-# Authors: Petr Hráček <phracek@redhat.com>
-#          Tomáš Hozza <thozza@redhat.com>
-#          Nikola Forró <nforro@redhat.com>
-#          František Nečas <fifinecas@seznam.cz>
-
-import pytest  # type: ignore
-import rpm  # type: ignore
-
-from rebasehelper.tags import Tag, Tags
-from rebasehelper.spec_content import SpecContent
-
-
-class TestTags:
-
-    RAW_CONTENT = '''
-Name: test
-Version: 0.1
-Source: test.tar.xz
-SOURCE1: additional-files.zip
-Patch00: patch0000.diff
-Patch:   patch0001.diff
-Patch:   patch0002.diff
-Patch03: patch0003.diff
-%{?!optimize:Patch999: extras.patch}
-%{?optimize:Patch999: extras-optimized.patch}
-
-%sourcelist
-   # test comment
-source2.zip
-
-%patchlist
-list.patch
-
-%Package libs
-Requires: %{name}-%{version}
-Requires: extradep
-Source: source3.tar.gz
-
-%sourcelist
-source4.zip
-
-%if 0%{?with_utils:1}
-%package utils
-Requires: %{name}-%{version}
-%endif
-    '''
-
-    PARSED_CONTENT = '''
-Name: test
-Version: 0.1
-Source: test.tar.xz
-SOURCE1: additional-files.zip
-Patch00: patch0000.diff
-Patch:   patch0001.diff
-Patch:   patch0002.diff
-Patch03: patch0003.diff
-
-Patch999: extras-optimized.patch
-
-%sourcelist
-   # test comment
-source2.zip
-
-%patchlist
-list.patch
-
-%Package libs
-Requires: test-0.1
-Requires: extradep
-Source: source3.tar.gz
-
-%sourcelist
-source4.zip
-
-    '''
-
-    MACROS = [
-        ('name', 'test'),
-        ('version', '0.1'),
-        ('optimize', '1'),
-    ]
-
-    @pytest.fixture
-    def tags(self):
-        rpm.reloadConfig()
-        for macro in self.MACROS:
-            rpm.addMacro(*macro)
-        return Tags(SpecContent(self.RAW_CONTENT), SpecContent(self.PARSED_CONTENT))
-
-    def test_tags(self, tags):
-        assert len(tags) == 16
-        assert tags[0] == Tag(0, '%package', 1, 'Name', (6, 10), True)
-        assert tags[1] == Tag(0, '%package', 2, 'Version', (9, 12), True)
-        assert tags[2] == Tag(0, '%package', 3, 'Source0', (8, 19), True, 0)
-        assert tags[3] == Tag(0, '%package', 4, 'Source1', (9, 29), True, 1)
-        assert tags[4] == Tag(0, '%package', 5, 'Patch0', (9, 23), True, 0)
-        assert tags[5] == Tag(0, '%package', 6, 'Patch1', (9, 23), True, 1)
-        assert tags[6] == Tag(0, '%package', 7, 'Patch2', (9, 23), True, 2)
-        assert tags[7] == Tag(0, '%package', 8, 'Patch3', (9, 23), True, 3)
-        assert tags[8] == Tag(0, '%package', 10, 'Patch999', (22, 44), True, 999)
-        assert tags[9] == Tag(1, '%sourcelist', 1, 'Source2', (0, 11), True, 2)
-        assert tags[10] == Tag(2, '%patchlist', 0, 'Patch1000', (0, 10), True, 1000)
-        assert tags[11] == Tag(3, '%package libs', 0, 'Requires', (10, 28), True)
-        assert tags[12] == Tag(3, '%package libs', 1, 'Requires', (10, 18), True)
-        assert tags[13] == Tag(3, '%package libs', 2, 'Source3', (8, 22), True, 3)
-        assert tags[14] == Tag(4, '%sourcelist', 0, 'Source4', (0, 11), True, 4)
-        assert tags[15] == Tag(5, '%package utils', 0, 'Requires', (10, 28), False)
-
-    def test_filter(self, tags):
-        assert len(list(tags.filter(section_name='%package'))) == 9
-        assert len(list(tags.filter(section_name='%package', name='Patch*'))) == 5
-        assert len(list(tags.filter(section_name='%package', valid=False))) == 0
-        assert len(list(tags.filter(valid=False))) == 1
-        assert len(list(tags.filter(name='Requires', valid=None))) == 3