#!/usr/bin/python3
# -*- coding: utf-8 -*-
# pylint: disable=line-too-long
# kate: space-indent on; indent-width 4; replace-tabs on; indent-mode python; remove-trailing-space modified;
# vim: expandtab ts=4
# pylint: enable=line-too-long
############################################################################
# Copyright © 2015-2024 José Manuel Santamaría Lema <panfaust@gmail.com> #
# #
# 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 module just provides the KAControlFile class"""
import tempfile
import os
import re
import shutil
from debian import deb822
from debian.debian_support import Version
from libka.pkgedit.wrap_control import wrap_field
from libka.ka_data_utils import read_json_data_file
from libka.utils.python_debian_warnings import ignore_python_debian_warnings
from libka.utils.python_debian_warnings import reset_python_debian_warnings
[docs]
class KAControlFile():
"""Class to represent a debian/control file"""
def __init__(self, file_path='debian/control'):
self._file_path = file_path
self._control_file_parsed = False
self._src_pkg = None
self._bin_pkg_list = []
self._bin_pkg_map = {}
[docs]
def sanitize(self):
"""Sanitizes a debian/control file so it won't trigger bugs from python-debian
or wrap-and-sort, see, for instance:
https://git.launchpad.net/~kubuntu-packagers/kubuntu-packaging/+git/cantor/commit/?id=7422c21f
https://git.launchpad.net/~kubuntu-packagers/kubuntu-packaging/+git/cantor/commit/?id=a90b3e90"""
#Open original and new control files
orig_control_file = open(self._file_path, 'r')
new_control_fd, new_control_path = tempfile.mkstemp(text=True)
new_control_file = os.fdopen(new_control_fd, mode='w')
for line in orig_control_file:
#Replace lines containing only whitespaces or tabs with blank lines
line = re.sub('^[ \t]*$', '', line)
#Write the line to the new control file only if it's not a comment
if re.match('^[ \t]*#', line) is None:
new_control_file.write(line)
#Close files
orig_control_file.close()
new_control_file.close()
#Move and delete the temporary file
shutil.copy(new_control_path, self._file_path)
os.remove(new_control_path)
[docs]
def parse_control(self):
"""Parses a control file"""
self.sanitize()
self._control_file_parsed = True
control_file = deb822.Packages.iter_paragraphs(open(self._file_path, 'r'))
self._bin_pkg_list = []
self._bin_pkg_map = {}
for pkg in control_file:
if 'Source' in pkg:
self._src_pkg = pkg
elif 'Package' in pkg:
pkg_name = pkg['Package']
self._bin_pkg_list.append(pkg_name)
self._bin_pkg_map[pkg_name] = pkg
elif 'Tests' in pkg:
#We treat autopktest names as binary packages, this is a bit hackish but
#convenient for some things like version bumping
pkg_name = pkg['Tests']
self._bin_pkg_list.append(pkg_name)
self._bin_pkg_map[pkg_name] = pkg
control_file.close()
[docs]
def get_standards_version(self):
"""Returns the current Standards-Version as a Version object"""
if not self._control_file_parsed:
self.parse_control()
result = Version("0")
if 'Standards-Version' in self._src_pkg:
result = Version(self._src_pkg['Standards-Version'])
return result
[docs]
def set_standards_version(self, new_value):
"""Sets the current Standards-Version"""
if not self._control_file_parsed:
self.parse_control()
self._src_pkg['Standards-Version'] = str(new_value)
[docs]
def set_kubuntu_maintainer_fields(self):
"""
Sets the maintainer fields for Kubuntu.
Returns `True` if the file was actually changed.
"""
#Parse control file if it wasn't already
if not self._control_file_parsed:
self.parse_control()
#Take note of the previous maintainer field value
previous_maintainer_field = self._src_pkg['Maintainer']
#Set the desired value for the maintainer field
kubuntu_maintainer_field = "Kubuntu Developers <kubuntu-devel@lists.ubuntu.com>"
#Update the maintainers fields if needed
file_changed = False
if previous_maintainer_field != kubuntu_maintainer_field:
#Re-create the source paragraph in debian/control
src_pkg_copy = self._src_pkg.copy()
#Iterate over the fields of the original source package paragraph
#and re-create the source paragraph, but this time setting the Maintainer
#field and right below it, the XSBC-Original-Maintainer field
fields = self._src_pkg.keys()
for field in fields:
#Delete the field in question to add it to the bottom later
del src_pkg_copy[field]
if field != 'Maintainer':
#Just add the field as it is to the bottom
src_pkg_copy[field] = self._src_pkg[field]
else:
#Add to the bottom the Maintainer and XSBC-Original-Maintainer properly
#set for Kubuntu
src_pkg_copy['Maintainer'] = kubuntu_maintainer_field
src_pkg_copy['XSBC-Original-Maintainer'] = previous_maintainer_field
#Replace the orginal source paragraph with the copy
self._src_pkg = src_pkg_copy
#Mark the file as changed
file_changed = True
#Return result, True if the file had to be changed
return file_changed
[docs]
def set_kubuntu_vcs_fields(self, release_type, repo_name):
"""
Sets the vcs fields for Kubuntu.
Returns `True` if the file was actually changed.
"""
#Parse control file if it wasn't already
if not self._control_file_parsed:
self.parse_control()
#Take note of the current vcs fields values
current_vcs_git_field = self._src_pkg['Vcs-Git']
current_vcs_browser_field = self._src_pkg['Vcs-Browser']
#Read JSON file with needed urls
git_remotes = read_json_data_file('git-remotes.json')
#Find out the desired values for the vcs fields
kubuntu_vcs_git_field = (git_remotes['remotes']['kubuntu-anon'][release_type]
% {'component': release_type, 'repo': repo_name})
kubuntu_vcs_browser_field = (git_remotes['vcs-browser']['kubuntu'][release_type]
% {'component': release_type, 'repo': repo_name})
#Set the values of the vcs fields if needed
control_file_changed = False
if current_vcs_git_field != kubuntu_vcs_git_field:
self._src_pkg['Vcs-Git'] = kubuntu_vcs_git_field
control_file_changed = True
if current_vcs_browser_field != kubuntu_vcs_browser_field:
self._src_pkg['Vcs-Browser'] = kubuntu_vcs_browser_field
control_file_changed = True
#Return result, True if the file had to be changed
return control_file_changed
[docs]
def get_bin_packages(self):
"""Returns the list of binary package names in the control file"""
if not self._control_file_parsed:
self.parse_control()
return self._bin_pkg_list
# Examples of deb822.PkgRelation.parse_relations() output
#
# "emacs | emacsen, make, debianutils (>= 1.7)" becomes
# [ [ {'name': 'emacs'}, {'name': 'emacsen'} ],
# [ {'name': 'make'} ],
# [ {'name': 'debianutils', 'version': ('>=', '1.7')} ] ]
#
# "tcl8.4-dev, procps [!hurd-i386]" becomes
# [ [ {'name': 'tcl8.4-dev'} ],
# [ {'name': 'procps', 'arch': (false, 'hurd-i386')} ] ]
#
# "qttools5-dev-tools (>= 5.11.1~) <!nodoc>" becomes
# [ [ {'name': 'qttools5-dev-tools',
# 'archqual': None,
# 'version': ('>=', '5.11.1~'),
# 'arch': None,
# 'restrictions': [[BuildRestriction(enabled=False, profile='nodoc')]]
# }
# ] ]
#
# pylint: disable=too-many-arguments
# pylint: disable=too-many-locals
# pylint: disable=too-many-nested-blocks
# pylint: disable=too-many-branches
[docs]
def remove_from_relation(self, package_to_change, relation, package_to_remove,
operator=None, version=None):
"""This function removes `package_to_remove` from `relation` of `package_to_change`.
It does nothing if we can't find `relation` in `package_to_change`.
If you whish to alter the source package paragraph with this method, you may pass as
`package_to_change` the special value ":source:".
If `operator` or `version` are given, it will remove `package_to_remove` only if
its operator and version match the values given by that parameters.
Returns `True` if `package_to_remove` was actually removed from `relation`.
Examples:
* This code will remove 'libbar5' from the 'libfoo5' 'Breaks' relation:
ka_control_file.remove_from_relations('libfoo5', 'Breaks', 'libbar5')
* This code will remove 'libbar5' from the 'libfoo5' 'Breaks' relation **only** if the
binary package 'libfoo5' has a 'Breaks' field like this: [...], libbar5 (<< 5.47),
[...]
ka_control_file.remove_from_relations('libfoo5', 'Breaks', 'libbar5', '<<', '5.47')
* This code will remove `pkg-kde-tools` from 'Build-Depends':
ka_control_file.remove_from_relation(package_to_change=':source:',
relation='Build-Depends',
package_to_remove='pkg-kde-tools')
"""
if package_to_change == ":source:":
paragraph_to_change = self._src_pkg
else:
paragraph_to_change = self._bin_pkg_map[package_to_change]
if relation in paragraph_to_change:
#Get the relation in structured fashion
ignore_python_debian_warnings()
relation_structured = deb822.PkgRelation.parse_relations(paragraph_to_change[relation])
reset_python_debian_warnings()
#Create a list of itrems to delete from the current relation
list_to_delete = []
for i, alternatives_list in enumerate(relation_structured):
for j, alternative_item in enumerate(alternatives_list):
if alternative_item['name'] == package_to_remove:
if alternative_item['version'] is not None:
relop, relversion = alternative_item['version']
if (operator is None) or (operator == relop):
if (version is None) or (version == relversion):
list_to_delete.append((i, j))
else:
if version is None:
list_to_delete.append((i, j))
#Delete the items from the relation
for i, j in list_to_delete:
del relation_structured[i][j]
#Delete empty lists.
relation_structured = filter(None, relation_structured)
#Convert relation to string and put it in the paragraph to change
paragraph_to_change[relation] = deb822.PkgRelation.str(relation_structured)
#Delete the relation completely if it's empty.
if paragraph_to_change[relation].strip() == "":
del paragraph_to_change[relation]
#Put the modified paragraph where it belongs, to make the change effective
if package_to_change == ":source:":
self._src_pkg = paragraph_to_change
else:
self._bin_pkg_map[package_to_change] = paragraph_to_change
#Return function result
if list_to_delete:
return True
else:
return False
# pylint: enable=too-many-arguments
# pylint: enable=too-many-locals
# pylint: enable=too-many-nested-blocks
# pylint: enable=too-many-branches
[docs]
def add_to_relation(self, package_to_change, relation, package_to_add):
"""
This method adds `package_to_add` in the `relation` of `package_to_change`.
You may pass as `package_to_change` the special value ":source:" which will
change the source package paragraph.
"""
#Find out the paragraph to change
if package_to_change == ":source:":
paragraph_to_change = self._src_pkg
else:
paragraph_to_change = self._bin_pkg_map[package_to_change]
#Add package_to_add to package_to_change relation
if relation in paragraph_to_change:
package_to_add_found = False
ignore_python_debian_warnings()
relation_structured = deb822.PkgRelation.parse_relations(paragraph_to_change[relation])
reset_python_debian_warnings()
for alternatives_list in relation_structured:
for alternative in alternatives_list:
if alternative['name'] == package_to_add:
package_to_add_found = True
break
#If the package_to_add wasn't found, add it.
if not package_to_add_found:
paragraph_to_change[relation] += ", " + package_to_add
else:
paragraph_to_change[relation] = package_to_add
#Put the modified paragraph where it belongs
if package_to_change == ":source:":
self._src_pkg = paragraph_to_change
else:
self._bin_pkg_map[package_to_change] = paragraph_to_change
[docs]
def wrap_and_sort_field(self, package, field, short_indent=False, trailing_comma=True):
"""
Change `field` from `package` so it would look like if we ran [ka-]wrap-and-sort.
The `package` parameter is either the name of a binary package or the special value
":source:" which would change the source package paragraph.
"""
#Find out the paragraph to change
if package == ":source:":
paragraph_to_change = self._src_pkg
else:
paragraph_to_change = self._bin_pkg_map[package]
#Abort if we don't find the field
if field not in paragraph_to_change:
return
#Wrap field
wrap_field(paragraph_to_change, field, True, short_indent, trailing_comma)
#Put the modified paragraph where it belongs
if package == ":source:":
self._src_pkg = paragraph_to_change
else:
self._bin_pkg_map[package] = paragraph_to_change
# pylint: disable=too-many-arguments
[docs]
def bump_version(self, package_to_change, relation, package_to_bump, version, operator='>='):
"""Bump the version of `package_to_bump` in `package_to_change` to `version`"""
#Find out the paragraph to change
if package_to_change == ":source:":
paragraph_to_change = self._src_pkg
else:
paragraph_to_change = self._bin_pkg_map[package_to_change]
#Abort if we don't find the relation
if relation not in paragraph_to_change:
return
#Bump package_to_bump in the relation
ignore_python_debian_warnings()
relation_structured = deb822.PkgRelation.parse_relations(paragraph_to_change[relation])
reset_python_debian_warnings()
for alternatives_list in relation_structured:
for alternative in alternatives_list:
if alternative['name'] == package_to_bump:
alternative['version'] = (operator, version)
paragraph_to_change[relation] = deb822.PkgRelation.str(relation_structured)
#Put the modified paragraph where it belongs
if package_to_change == ":source:":
self._src_pkg = paragraph_to_change
else:
self._bin_pkg_map[package_to_change] = paragraph_to_change
# pylint: enable=too-many-arguments
[docs]
def bump_versions_with_map(self, package_to_change, relation, pkg_version_map):
"""
This method bumps all the versions given by `pkg_version_map` in `relation`
of `package_to_change`.
If `relation` is not found in the corresponding paragraph, calling this
function will do nothing.
"""
#Find out the paragraph to change
if package_to_change == ":source:":
paragraph_to_change = self._src_pkg
else:
paragraph_to_change = self._bin_pkg_map[package_to_change]
#Abort if we don't find the relation
if relation not in paragraph_to_change:
return
#Bump versions with the versions given in pkg_version_map
ignore_python_debian_warnings()
relation_structured = deb822.PkgRelation.parse_relations(paragraph_to_change[relation])
reset_python_debian_warnings()
for alternatives_list in relation_structured:
for alternative in alternatives_list:
pkg_name = alternative['name']
if pkg_name in pkg_version_map:
alternative['version'] = ('>=', pkg_version_map[pkg_name])
paragraph_to_change[relation] = deb822.PkgRelation.str(relation_structured)
#Put the modified paragraph where it belongs
if package_to_change == ":source:":
self._src_pkg = paragraph_to_change
else:
self._bin_pkg_map[package_to_change] = paragraph_to_change
# Examples of deb822.PkgRelation.parse_relations() output
#
# "emacs | emacsen, make, debianutils (>= 1.7)" becomes
# [ [ {'name': 'emacs'}, {'name': 'emacsen'} ],
# [ {'name': 'make'} ],
# [ {'name': 'debianutils', 'version': ('>=', '1.7')} ] ]
#
# "tcl8.4-dev, procps [!hurd-i386]" becomes
# [ [ {'name': 'tcl8.4-dev'} ],
# [ {'name': 'procps', 'arch': (false, 'hurd-i386')} ] ]
#
# "qttools5-dev-tools (>= 5.11.1~) <!nodoc>" becomes
# [ [ {'name': 'qttools5-dev-tools',
# 'archqual': None,
# 'version': ('>=', '5.11.1~'),
# 'arch': None,
# 'restrictions': [[BuildRestriction(enabled=False, profile='nodoc')]]
# }
# ] ]
#
# pylint: disable=too-many-nested-blocks
# pylint: disable=too-many-branches
[docs]
def remove_doc_build_depends(self):
"""
Calling this method will remove all `Build-Depends` and `Build-Depends-Indep` labelled
as `<!nodoc>` in the control file.
"""
#Get the build depends in structured fashion
ignore_python_debian_warnings()
build_depends = deb822.PkgRelation.parse_relations(self._src_pkg['Build-Depends'])
if 'Build-Depends-Indep' in self._src_pkg:
bds_indep = deb822.PkgRelation.parse_relations(self._src_pkg['Build-Depends-Indep'])
else:
bds_indep = []
reset_python_debian_warnings()
#This is the object representing the <!nodoc> restriction
restriction_to_find = deb822.PkgRelation.BuildRestriction(enabled=False, profile='nodoc')
#Find out the packages to remove in Build-Depends
bds_packages_to_remove = []
for alternatives_list in build_depends:
for alternative in alternatives_list:
package_name = alternative['name']
restrictions = alternative['restrictions']
if restrictions is not None:
for restrictions_list in restrictions:
for restriction in restrictions_list:
if restriction == restriction_to_find:
bds_packages_to_remove.append(package_name)
#Find out the packages to remove in Build-Depeds-Indep
bds_indep_packages_to_remove = []
for alternatives_list in bds_indep:
for alternative in alternatives_list:
package_name = alternative['name']
restrictions = alternative['restrictions']
if restrictions is not None:
for restrictions_list in restrictions:
for restriction in restrictions_list:
if restriction == restriction_to_find:
bds_indep_packages_to_remove.append(package_name)
#Remove the packages
for package in bds_packages_to_remove:
self.remove_from_relation(':source:', 'Build-Depends', package)
for package in bds_indep_packages_to_remove:
self.remove_from_relation(':source:', 'Build-Depends-Indep', package)
# pylint: enable=too-many-nested-blocks
# pylint: enable=too-many-branches
[docs]
def remove_debian_broken_breaks(self):
#TODO
pass
[docs]
def dump(self):
"""Dumps all the changes made so far to hard disk"""
#Open file to write
new_control_file = open(self._file_path, "w")
#Dump contents of source package (if any)
if self._src_pkg is not None:
new_control_file.write(self._src_pkg.dump())
new_control_file.write('\n')
#Dump contents of binary packages/tests
first_bin_pkg = True
for bin_pkg_name in self._bin_pkg_list:
if not first_bin_pkg:
new_control_file.write('\n')
new_control_file.write(self._bin_pkg_map[bin_pkg_name].dump())
first_bin_pkg = False
#Close file
new_control_file.close()