274 lines
9.3 KiB
Python
274 lines
9.3 KiB
Python
#!/usr/bin/python3
|
|
# Copyright (C) 2019-2020 Jelmer Vernooij <jelmer@debian.org>
|
|
#
|
|
# 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 3 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, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
"""Functions for working with watch files."""
|
|
|
|
import re
|
|
from warnings import warn
|
|
|
|
try:
|
|
# pylint: disable=unused-import
|
|
from typing import (
|
|
Iterable,
|
|
Iterator,
|
|
List,
|
|
Optional,
|
|
Sequence,
|
|
TextIO,
|
|
Tuple,
|
|
)
|
|
except ImportError:
|
|
# Lack of typing is not important at runtime
|
|
pass
|
|
|
|
# The default watch file version to use for new files.
|
|
DEFAULT_VERSION = 4
|
|
|
|
# Standard substitutions applied by uscan as documented in uscan(1):
|
|
SUBSTITUTIONS = {
|
|
# This is substituted by the legal upstream version regex (capturing).
|
|
'@ANY_VERSION@': r'[-_]?(\d[\-+\.:\~\da-zA-Z]*)',
|
|
# This is substituted by the typical archive file extension regex
|
|
# (non-capturing).
|
|
'@ARCHIVE_EXT@': r'(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)',
|
|
# This is substituted by the typical signature file extension regex
|
|
# (non-capturing).
|
|
'@SIGNATURE_EXT@':
|
|
r'(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)'
|
|
r'\.(?:asc|pgp|gpg|sig|sign)',
|
|
# This is substituted by the typical Debian extension regexp (capturing).
|
|
'@DEB_EXT@': r'[\+~](debian|dfsg|ds|deb)(\.)?(\d+)?$',
|
|
}
|
|
|
|
|
|
class MissingVersion(Exception):
|
|
"""The version= line is missing."""
|
|
|
|
|
|
class WatchFileFormatError(ValueError):
|
|
"""Raised when the input is not valid.
|
|
"""
|
|
|
|
|
|
def expand(text, package):
|
|
# type: (str, str) -> str
|
|
"""Apply substitutions to a string.
|
|
|
|
:param text: text to apply substitutions to
|
|
:param package: package name, as a string
|
|
:return: text with subsitutions applied
|
|
"""
|
|
substs = dict(SUBSTITUTIONS.items())
|
|
# This is substituted with the source package name found in the first line
|
|
# of the debian/changelog file.
|
|
substs['@PACKAGE@'] = package
|
|
for k, v in substs.items():
|
|
text = text.replace(k, v)
|
|
return text
|
|
|
|
|
|
def _complain(msg, strict):
|
|
# type: (str, bool) -> None
|
|
if strict:
|
|
raise WatchFileFormatError(msg)
|
|
warn(msg)
|
|
|
|
|
|
class WatchFile(object):
|
|
"""A Debian watch file.
|
|
|
|
:ivar entries: list of Watch entries
|
|
:ivar options: optional list of global options, applied to all Watch
|
|
entries
|
|
:ivar version: watch file version
|
|
"""
|
|
|
|
def __init__(self,
|
|
entries=None, # type: Optional[Sequence[Watch]]
|
|
options=None, # type: Optional[Sequence[str]]
|
|
version=DEFAULT_VERSION, # type: Optional[int]
|
|
):
|
|
self.version = version
|
|
if entries is None:
|
|
entries = []
|
|
self.entries = entries
|
|
if options is None:
|
|
options = []
|
|
self.options = options
|
|
|
|
def __iter__(self):
|
|
# type: () -> Iterator[Watch]
|
|
return iter(self.entries)
|
|
|
|
def dump(self, f):
|
|
# type: (TextIO) -> None
|
|
"""Write the contents of a watch file to a file-like object.
|
|
|
|
Note that this will not preserve the formatting of the original file,
|
|
and thus it is currently not possible to use this function to
|
|
parse and reserialize a file and end up with the same contents.
|
|
|
|
:param f: File-like object to write to
|
|
"""
|
|
def serialize_options(opts):
|
|
# type: (Sequence[str]) -> str
|
|
s = ','.join(opts)
|
|
if ' ' in s or '\t' in s:
|
|
return 'opts="' + s + '"'
|
|
return 'opts=' + s
|
|
if self.version is not None:
|
|
f.write('version=%d\n' % self.version)
|
|
if self.options:
|
|
f.write(serialize_options(self.options) + '\n')
|
|
for entry in self.entries:
|
|
if entry.options:
|
|
f.write(serialize_options(entry.options) + ' ')
|
|
f.write(entry.url)
|
|
if entry.matching_pattern:
|
|
f.write(' ' + entry.matching_pattern)
|
|
if entry.version:
|
|
f.write(' ' + entry.version)
|
|
if entry.script:
|
|
f.write(' ' + entry.script)
|
|
f.write('\n')
|
|
|
|
@classmethod
|
|
def from_lines(cls, lines, strict=False):
|
|
# type: (Iterable[str], bool) -> Optional[WatchFile]
|
|
"""Parse from the contents that make up a watch file.
|
|
|
|
:param lines: watch file lines to parse
|
|
:return: instance or None if there are no non-comment lines in the file
|
|
:raise MissingVersion: if there is no version number declared
|
|
:raise ValueError: when syntax errors are encountered
|
|
"""
|
|
joined_lines = [] # type: List[List[str]]
|
|
continued = [] # type: List[str]
|
|
for line in lines:
|
|
if line.startswith('#'):
|
|
continue
|
|
if not line.strip():
|
|
continue
|
|
if line.rstrip('\n').endswith('\\'):
|
|
continued.append(line.rstrip('\n\\'))
|
|
else:
|
|
continued.append(line)
|
|
joined_lines.append(continued)
|
|
continued = []
|
|
if continued:
|
|
# Hmm, broken line?
|
|
_complain('watchfile ended with \\; skipping last line', strict)
|
|
joined_lines.append(continued)
|
|
if not joined_lines:
|
|
return None
|
|
firstline = ''.join(joined_lines.pop(0))
|
|
try:
|
|
key, value = firstline.split('=', 1)
|
|
except ValueError:
|
|
raise MissingVersion()
|
|
if key.strip() != 'version':
|
|
raise MissingVersion()
|
|
version = int(value.strip())
|
|
persistent_options = []
|
|
entries = []
|
|
for chunked in joined_lines:
|
|
if version > 3:
|
|
# Leading whitespace is stripped in version
|
|
# 4 and up.
|
|
chunked = [chunk.lstrip() for chunk in chunked]
|
|
line = ''.join(chunked).strip()
|
|
if not line:
|
|
continue
|
|
if line.startswith('opts='):
|
|
if line[5] == '"':
|
|
optend = line.index('"', 6)
|
|
if optend == -1:
|
|
raise ValueError('Not matching " in %r' % line)
|
|
opts_str = line[6:optend]
|
|
line = line[optend+1:]
|
|
else:
|
|
try:
|
|
(opts_str, line) = line[5:].split(None, 1)
|
|
except ValueError:
|
|
opts_str = line[5:]
|
|
line = ''
|
|
opts = opts_str.split(',')
|
|
else:
|
|
opts = []
|
|
if line:
|
|
try:
|
|
url, line = line.split(None, 1)
|
|
except ValueError:
|
|
url = line
|
|
line = ''
|
|
m = re.findall(r'/([^/]*\([^/]*\)[^/]*)$', url)
|
|
if m:
|
|
parts = (str(m[0]), ) + tuple(line.split(None, 1))
|
|
url = url[:-len(m[0])-1]
|
|
else:
|
|
parts = tuple(line.split(None, 2))
|
|
entries.append(Watch(url, *parts, opts=opts)) # type: ignore
|
|
else:
|
|
persistent_options.extend(opts)
|
|
return cls(
|
|
entries=entries, options=persistent_options, version=version)
|
|
|
|
|
|
class Watch(object):
|
|
"""Watch line entry.
|
|
|
|
This will contain the attributes documented in uscan(1):
|
|
|
|
:ivar url: The URL (possibly including the filename regex)
|
|
:ivar matching_pattern: a filename regex, optional
|
|
:ivar version: version policy, optional
|
|
:ivar script: script to run, optional
|
|
:ivar opts: a list of options, as strings
|
|
"""
|
|
|
|
def __init__(self,
|
|
url, # type: str
|
|
matching_pattern=None, # type: Optional[str]
|
|
version=None, # type: Optional[str]
|
|
script=None, # type: Optional[str]
|
|
opts=None, # type: Optional[Sequence[str]]
|
|
):
|
|
self.url = url
|
|
self.matching_pattern = matching_pattern
|
|
self.version = version
|
|
self.script = script
|
|
if opts is None:
|
|
opts = []
|
|
self.options = opts
|
|
|
|
def __repr__(self):
|
|
# type: () -> str
|
|
return (
|
|
"%s(%r, matching_pattern=%r, version=%r, script=%r, opts=%r)" % (
|
|
self.__class__.__name__, self.url, self.matching_pattern,
|
|
self.version, self.script, self.options))
|
|
|
|
def __eq__(self, other):
|
|
# type: (object) -> bool
|
|
if not isinstance(other, Watch):
|
|
return False
|
|
return (other.url == self.url and
|
|
other.matching_pattern == self.matching_pattern and
|
|
other.version == self.version and
|
|
other.script == self.script and
|
|
other.options == self.options)
|