python-pid/archive/repolib/file.py
2023-02-23 22:05:14 +02:00

545 lines
20 KiB
Python

#!/usr/bin/python3
"""
Copyright (c) 2022, Ian Santopietro
All rights reserved.
This file is part of RepoLib.
RepoLib is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
RepoLib 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 Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with RepoLib. If not, see <https://www.gnu.org/licenses/>.
"""
import logging
from pathlib import Path
import dbus
from .source import Source, SourceError
from . import util
FILE_COMMENT = "## Added/managed by repolib ##"
class SourceFileError(util.RepoError):
""" Exception from a source file."""
def __init__(self, *args, code:int = 1, **kwargs):
"""Exception with a source file
Arguments:
:code int, optional, default=1: Exception error code.
"""
super().__init__(*args, **kwargs)
self.code: int = code
class SourceFile:
""" A Source File on disk
Attributes:
path(Pathlib.Path): the path for this file on disk
name(str): The name for this source (filename less the extension)
format(SourceFormat): The format used by this source file
contents(list): A list containing all of this file's contents
"""
def __init__(self, name:str='') -> None:
"""Initialize a source file
Arguments:
name(str): The filename within the sources directory to load
"""
self.log = logging.getLogger(__name__)
self.name:str = ''
self.path:Path = Path()
self.alt_path:Path = Path()
self.format:util.SourceFormat = util.SourceFormat.DEFAULT
self.contents:list = []
self.sources:list = []
self.contents.append(FILE_COMMENT)
self.contents.append('#')
if name:
self.name = name
self.reset_path()
if self.path.exists():
self.contents = []
self.load()
def __str__(self):
return self.output
def __repr__(self):
return f'SourceFile(name={self.name})'
def add_source(self, source:Source) -> None:
"""Adds a source to the file
Arguments:
source(Source): The source to add
"""
if source not in self.sources:
self.contents.append(source)
self.sources.append(source)
source.file = self
def remove_source(self, ident:str) -> None:
"""Removes a source from the file
Arguments:
ident(str): The ident of the source to remove
"""
source = self.get_source_by_ident(ident)
self.contents.remove(source)
self.sources.remove(source)
self.save()
## Remove sources prefs files/pin-priority
prefs_path = source.prefs
try:
if prefs_path.exists() and prefs_path.name:
prefs_path.unlink()
except AttributeError:
# No prefs path
pass
except PermissionError:
bus = dbus.SystemBus()
privileged_object = bus.get_object('org.pop_os.repolib', '/Repo')
privileged_object.delete_prefs_file(str(prefs_path))
def get_source_by_ident(self, ident: str) -> Source:
"""Find a source within this file by its ident
Arguments:
ident(str): The ident to search for
Returns: Source
The located source
"""
self.log.debug(f'Looking up ident {ident} in {self.name}')
for source in self.sources:
if source.ident == ident:
self.log.debug(f'{ident} found')
return source
raise SourceFileError(
f'The file {self.path} does not contain the source {ident}'
)
def reset_path(self) -> None:
"""Attempt to detect the correct path for this File.
We default to DEB822 .sources format files, but if that file doesn't
exist, fallback to legacy .list format. If this also doesn't exist, we
swap back to DEB822 format, as this is likely a new file."""
self.log.debug('Resetting path')
default_path = util.SOURCES_DIR / f'{self.name}.sources'
legacy_path = util.SOURCES_DIR / f'{self.name}.list'
if default_path.exists():
self.path = default_path
self.alt_path = legacy_path
self.format = util.SourceFormat.DEFAULT
elif legacy_path.exists():
self.path = legacy_path
self.alt_path = default_path
self.format = util.SourceFormat.LEGACY
else:
self.path = default_path
self.alt_path = legacy_path
return
def find_unique_ident(self, source1:Source, source2:Source) -> bool:
"""Takes two sources with identical idents, and finds a new, unique
idents for them.
The rules for this are mildly complicated, and vary depending on the
situation:
* (DEB822) If the sources are identical other than some portion of
data, then the two will be combined into a single source.
* (legacy) If the two sources are identical other than source type
(common with legacy-format PPAs with source code) then the second
source will be dropped until export.
* (legacy) If the sources differ by URIs, Components, or Suites, then
the differing data will be appended to the sources' idents.
* (Either) If no other rules can be determined, then the sources will
have a number appended to them
Arguments:
source1(Source): The original source with the ident
source2(Source): The new colliding source with the ident
Returns: bool
`True` if the two sources were successfully deduped, `False` if the
second source should be discarded.
"""
ident_src1:str = source1.ident
ident_src2:str = source2.ident
self.log.debug(f'Idents {ident_src1} and {ident_src2} conflict')
if self.format == util.SourceFormat.DEFAULT:
util.combine_sources(source1, source2)
ident_src2 = ''
else:
excl_keys = [
'X-Repolib-Name',
'X-Repolib-ID',
'X-Repolib-Comment',
'Enabled',
'Types'
]
if len(source1.types) == 1 and len(source2.types) == 1:
if util.compare_sources(source1, source2, excl_keys):
util.combine_sources(source1, source2)
source1.types = [
util.SourceType.BINARY, util.SourceType.SOURCECODE
]
source1.twin_source = True
source1.sourcecode_enabled = source2.enabled
ident_src2 = ''
diffs = util.find_differences_sources(source1, source2, excl_keys)
if diffs:
for key in diffs:
raw_diffs:tuple = diffs[key]
diff1_list = raw_diffs[0].strip().split()
diff2_list = raw_diffs[1].strip().split()
for i in diff1_list:
if i not in diff2_list:
ident_src1 += f'-{i}'
break
for i in diff2_list:
if i not in diff1_list:
ident_src2 += f'-{i}'
break
if ident_src1 != ident_src2:
break
if ident_src2 and ident_src1 != ident_src2:
source1.ident = ident_src1
source2.ident = ident_src2
return True
elif ident_src2 and ident_src1 == ident_src2:
for source in self.sources:
src_index = self.sources.index(source)
source.ident = f'{self.name}-{src_index}'
return True
elif not ident_src2:
return False
return True
def load(self) -> None:
"""Loads the sources from the file on disk"""
self.log.debug(f'Loading source file {self.path}')
self.contents = []
self.sources = []
if not self.name:
raise SourceFileError('You must provide a filename to load.')
if not self.path.exists():
raise SourceFileError(f'The file {self.path} does not exist.')
with open(self.path, 'r') as source_file:
srcfile_data = source_file.readlines()
item:int = 0
raw822:list = []
parsing_deb822:bool = False
source_name:str = ''
commented:bool = False
idents:dict = {}
# Main file parsing loop
for line in srcfile_data:
comment_found:str = ''
name_line:bool = 'X-Repolib-Name' in line
if not parsing_deb822:
commented = line.startswith('#')
# Find commented out lines
if commented:
# Exclude disabled legacy deblines
valid_legacy = util.validate_debline(line.strip())
if not valid_legacy and not name_line:
# Found a standard comment
self.contents.append(line.strip())
elif valid_legacy:
if self.format != util.SourceFormat.LEGACY:
raise SourceFileError(
f'File {self.path.name} is an updated file, but '
'contains legacy-format sources. This is not '
'allowed. Please fix the file manually.'
)
new_source = Source()
new_source.load_from_data([line])
if source_name:
new_source.name = source_name
if not new_source.ident:
new_source.ident = self.name
to_add:bool = True
if new_source.ident in idents:
old_source = idents[new_source.ident]
idents.pop(old_source.ident)
to_add = self.find_unique_ident(old_source, new_source)
idents[old_source.ident] = old_source
idents[new_source.ident] = new_source
if to_add:
new_source.file = self
self.contents.append(new_source)
self.sources.append(new_source)
elif name_line:
source_name = ':'.join(line.split(':')[1:])
source_name = source_name.strip()
# Active legacy line
elif not commented:
if util.validate_debline(line.strip()):
if self.format != util.SourceFormat.LEGACY:
raise SourceFileError(
f'File {self.path.name} is an updated file, but '
'contains legacy-format sources. This is not '
'allowed. Please fix the file manually.'
)
new_source = Source()
new_source.load_from_data([line])
if source_name:
new_source.name = source_name
if not new_source.ident:
new_source.ident = self.name
to_add:bool = True
if new_source.ident in idents:
old_source = idents[new_source.ident]
idents.pop(old_source.ident)
to_add = self.find_unique_ident(old_source, new_source)
idents[old_source.ident] = old_source
idents[new_source.ident] = new_source
if to_add:
new_source.file = self
self.contents.append(new_source)
self.sources.append(new_source)
# Empty lines are treated as comments
if line.strip() == '':
self.contents.append('')
# Find 822 sources
# Valid sources can begin with any key:
for key in util.valid_keys:
if line.startswith(key):
if self.format == util.SourceFormat.LEGACY:
raise SourceFileError(
f'File {self.path.name} is a DEB822-format file, but '
'contains legacy sources. This is not allowed. '
'Please fix the file manually.'
)
parsing_deb822 = True
raw822.append(line.strip())
item += 1
elif parsing_deb822:
# Deb822 sources are terminated with an empty line
if line.strip() == '':
parsing_deb822 = False
new_source = Source()
new_source.load_from_data(raw822)
new_source.file = self
if source_name:
new_source.name = source_name
if not new_source.ident:
new_source.ident = self.name
if new_source.ident in idents:
old_source = idents[new_source.ident]
idents.pop(old_source.ident)
self.find_unique_ident(old_source, new_source)
idents[old_source.ident] = old_source
idents[new_source.ident] = new_source
new_source.file = self
self.contents.append(new_source)
self.sources.append(new_source)
raw822 = []
item += 1
self.contents.append('')
else:
raw822.append(line.strip())
if raw822:
parsing_deb822 = False
new_source = Source()
new_source.load_from_data(raw822)
new_source.file = self
if source_name:
new_source.name = source_name
if not new_source.ident:
new_source.ident = self.name
if new_source.ident in idents:
old_source = idents[new_source.ident]
idents.pop(old_source.ident)
self.find_unique_ident(old_source, new_source)
idents[old_source.ident] = old_source
idents[new_source.ident] = new_source
new_source.file = self
self.contents.append(new_source)
self.sources.append(new_source)
raw822 = []
item += 1
self.contents.append('')
for source in self.sources:
if not source.has_required_parts:
raise SourceFileError(
f'The file {self.path.name} is malformed and contains '
'errors. Maybe it has some extra new-lines?'
)
self.log.debug('File %s loaded', self.path)
def save(self) -> None:
"""Saves the source file to disk using the current format"""
self.log.debug(f'Saving source file to {self.path}')
for source in self.sources:
self.log.debug('New Source %s: \n%s', source.ident, source)
save_path = util.SOURCES_DIR / f'{self.name}.save'
for source in self.sources:
if source.key:
source.key.save_gpg()
source.tasks_save()
if not self.name or not self.format:
raise SourceFileError('There was not a complete filename to save')
if not util.SOURCES_DIR.exists():
try:
util.SOURCES_DIR.mkdir(parents=True)
except PermissionError:
self.log.error(
'Source destination path does not exist and cannot be created '
'Failures expected now.'
)
if len(self.sources) > 0:
self.log.debug('Saving, Main path %s; Alt path: %s', self.path, self.alt_path)
try:
with open(self.path, mode='w') as output_file:
output_file.write(self.output)
if self.alt_path.exists():
self.alt_path.rename(save_path)
except PermissionError:
bus = dbus.SystemBus()
privileged_object = bus.get_object('org.pop_os.repolib', '/Repo')
privileged_object.output_file_to_disk(self.path.name, self.output)
self.log.debug('File %s saved', self.path)
else:
try:
self.path.unlink(missing_ok=True)
self.alt_path.unlink(missing_ok=True)
save_path.unlink(missing_ok=True)
except PermissionError:
bus = dbus.SystemBus()
privileged_object = bus.get_object('org.pop_os.repolib', '/Repo')
privileged_object.delete_source_file(self.path.name)
self.log.debug('File %s removed', self.path)
## Attribute properties
@property
def format(self) -> util.SourceFormat: # type: ignore (We don't use str.format)
"""The format of the file on disk"""
return self._format
@format.setter
def format(self, format:util.SourceFormat) -> None: # type: ignore (We don't use str.format)
"""The path needs to be updated when the format changes"""
alt_format:util.SourceFormat = util.SourceFormat.LEGACY
self._format = format
self.path = util.SOURCES_DIR / f'{self.name}.{self._format.value}'
for format_ in util.SourceFormat:
if format != format_:
alt_format = format_
self.alt_path = util.SOURCES_DIR / f'{self.name}.{alt_format.value}'
## Output properties
@property
def legacy(self) -> str:
"""Outputs the file in the output_legacy format"""
legacy_output:str = ''
for item in self.contents:
try:
legacy_output += item.legacy
except AttributeError:
legacy_output += item
legacy_output += '\n'
return legacy_output
@property
def deb822(self) -> str:
"""Outputs the file in the output_822 format"""
deb822_output:str = ''
for item in self.contents:
try:
deb822_output += item.deb822
except AttributeError:
deb822_output += item
deb822_output += '\n'
return deb822_output
@property
def ui(self) -> str:
"""Outputs the file in the output_ui format"""
ui_output:str = ''
for item in self.contents:
try:
ui_output += item.ui
except AttributeError:
pass # Skip file comments in UI mode
ui_output += '\n'
return ui_output
@property
def output(self) -> str:
"""Outputs the file in the output format"""
default_output:str = ''
for item in self.contents:
try:
if self.format == util.SourceFormat.DEFAULT:
default_output += item.deb822
elif self.format == util.SourceFormat.LEGACY:
default_output += item.legacy
default_output += '\n'
except AttributeError:
default_output += item
default_output += '\n'
return default_output