From 8876b673b95c47773164e0ad17d9d8682968f20b Mon Sep 17 00:00:00 2001 From: Melody Horn Date: Mon, 29 Mar 2021 17:19:40 -0600 Subject: parse and compare versions --- app.py | 8 ++++++- repos/__init__.py | 2 +- repos/alpine_linux.py | 15 ++++++++----- repos/base.py | 55 +++++++++++++++++++++++++++++++++++++++++------ requirements.txt | 6 ++++-- templates/badge.svg.jinja | 39 +++++++++++++++++++++++++++------ 6 files changed, 103 insertions(+), 22 deletions(-) diff --git a/app.py b/app.py index 8fa15de..d1183e0 100644 --- a/app.py +++ b/app.py @@ -12,7 +12,13 @@ def hello_world(): @app.route('/badge/.svg') def badge(package: str): - return render_template('badge.svg.jinja', versions=repos.get_versions(package)) + versions = repos.get_versions(package) + newest_version = max(v for family in versions.values() for v in family.values()) + print(newest_version) + rendered = render_template('badge.svg.jinja', versions=versions, newest_version=newest_version) + response = make_response(rendered) + response.headers['Content-Type'] = 'image/svg+xml' + return response if __name__ == '__main__': diff --git a/repos/__init__.py b/repos/__init__.py index 0f71dd1..1dea3a1 100644 --- a/repos/__init__.py +++ b/repos/__init__.py @@ -1,4 +1,4 @@ -from typing import Mapping, List, Any +from typing import Mapping, List from . import alpine_linux from .base import Repository diff --git a/repos/alpine_linux.py b/repos/alpine_linux.py index 64f159a..7c646cc 100644 --- a/repos/alpine_linux.py +++ b/repos/alpine_linux.py @@ -1,9 +1,10 @@ from io import TextIOWrapper from pathlib import Path +import re import tarfile from typing import Mapping, TextIO -from .base import Repository +from .base import Repository, Version __all__ = [ 'stable_main_x86_64', @@ -13,7 +14,9 @@ __all__ = [ 'edge_testing_x86_64', ] -def parse_apkindex(apkindex: TextIO) -> Mapping[str, str]: +PACKAGE_REVISION_INFO = re.compile(r'-r\d+$') + +def parse_apkindex(apkindex: TextIO) -> Mapping[str, Version]: result = dict() current_package = None current_version = None @@ -37,14 +40,16 @@ def parse_apkindex(apkindex: TextIO) -> Mapping[str, str]: elif line_type == 'P': current_package = line_data elif line_type == 'V': - current_version = line_data + version = line_data + clean_version = PACKAGE_REVISION_INFO.sub('', version) + current_version = Version(version, clean_version) elif line_type in ignore_lines: pass else: raise ValueError('unknown line type: ' + line_type + ' in line ' + repr(line)) return result -def parse_cached(cached: Path) -> Mapping[str, str]: +def parse_cached(cached: Path) -> Mapping[str, Version]: apkindex = tarfile.open(cached) for archive_member in apkindex.getmembers(): if archive_member.name == 'APKINDEX': @@ -52,7 +57,7 @@ def parse_cached(cached: Path) -> Mapping[str, str]: apkindex_file = TextIOWrapper(apkindex_file) return parse_apkindex(apkindex_file) -def build_repo(name: str, url_path: str): +def build_repo(name: str, url_path: str) -> Repository: url = f'http://dl-cdn.alpinelinux.org/alpine/{url_path}/APKINDEX.tar.gz' return Repository( family='Alpine Linux', diff --git a/repos/base.py b/repos/base.py index 66ecf2d..220a30f 100644 --- a/repos/base.py +++ b/repos/base.py @@ -1,26 +1,66 @@ -from dataclasses import dataclass +from dataclasses import dataclass, asdict as dataclass_asdict import datetime +from functools import total_ordering import gzip import json import os from pathlib import Path import re -from typing import Callable, Mapping +from typing import Any, Callable, Mapping import requests +import semver + +__all__ = [ + 'Repository', + 'Version', +] HTTP_DATE = '%a, %d %b %Y %H:%M:%S GMT' -SLUGIFY = re.compile('\W+') +SLUGIFY = re.compile(r'\W+') def slug(text: str) -> str: return SLUGIFY.sub('-', text.lower()).strip('-') +@total_ordering +@dataclass() +class Version: + original: str + clean: str + + def __str__(self) -> str: + return self.original + + def __lt__(self, other: Any): + if not isinstance(other, Version): + return NotImplemented + if semver.VersionInfo.isvalid(self.clean) and semver.VersionInfo.isvalid(other.clean): + return semver.compare(self.clean, other.clean) < 0 + return self.original < other.original + +class JSONEncoder(json.JSONEncoder): + def default(self, o: Any) -> Any: + if isinstance(o, Version): + return dataclass_asdict(o) + return super().default(o) + +class JSONDecoder(json.JSONDecoder): + @staticmethod + def object_hook(o: dict) -> Any: + if o.keys() == {'original', 'clean'}: + return Version(**o) + return o + + def __init__(self): + super().__init__(object_hook=self.object_hook) + + @dataclass() class Repository: family: str repo: str index_url: str - parse: Callable[[Path], Mapping[str, str]] + parse: Callable[[Path], Mapping[str, Version]] def _full_name(self): return f'{self.family} {self.repo}' @@ -31,7 +71,7 @@ class Repository: def _cache_file(self, name: str) -> Path: return self._cache_dir() / name - def get_versions(self) -> Mapping[str, str]: + def get_versions(self) -> Mapping[str, Version]: self._cache_dir().mkdir(parents=True, exist_ok=True) downloaded_file = self._cache_file('downloaded') if downloaded_file.exists(): @@ -57,10 +97,11 @@ class Repository: set_mtime = datetime.datetime.strptime(set_mtime, HTTP_DATE) os.utime(downloaded_file, (datetime.datetime.now().timestamp(), set_mtime.timestamp())) + if response.status_code != requests.codes.not_modified or not parsed_file.exists(): parsed_data = self.parse(downloaded_file) with gzip.open(parsed_file, 'wt') as f: - json.dump(parsed_data, f) + json.dump(parsed_data, f, cls=JSONEncoder) return parsed_data else: with gzip.open(parsed_file, 'rt') as f: - return json.load(f) + return json.load(f, cls=JSONDecoder) diff --git a/requirements.txt b/requirements.txt index 8598ba2..a7e9e12 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -Flask==1.1.2 -requests==2.25.1 +Flask~=1.1.2 +Jinja2~=2.11.3 +requests~=2.25.1 +semver~=2.13.0 diff --git a/templates/badge.svg.jinja b/templates/badge.svg.jinja index 64117b8..fe2da3f 100644 --- a/templates/badge.svg.jinja +++ b/templates/badge.svg.jinja @@ -1,12 +1,39 @@ +{%- set ROW_HEIGHT = 24 -%} +{%- set ROW_MARGIN = 2 -%} +{%- set FONT_SIZE_PERCENT = 75 -%} +{%- set FONT_SIZE = (ROW_HEIGHT - ROW_MARGIN) * FONT_SIZE_PERCENT / 100 -%} +{%- set ROWS = 1 + versions|length + (versions.items()|map('length')|sum) -%} +{%- set OUTER_MARGIN = 5 -%} +{%- set REPO_LEFT_MARGIN = 15 -%} +{%- set FONT_WIDENESS = 0.5 -%} +{# ugh. just ugh. #} +{%- set ns = namespace(longest_repo = 0, longest_version = 0) -%} +{%- for family_contents in versions.values() -%} + {%- set this_longest_repo = family_contents.keys()|map('length')|max -%} + {%- set this_longest_version = family_contents.values()|map('string')|map('length')|max -%} + {%- set ns.longest_repo = [ns.longest_repo, this_longest_repo]|max -%} + {%- set ns.longest_version = [ns.longest_version, this_longest_version]|max -%} +{%- endfor -%} +{%- set VERSION_X = OUTER_MARGIN + REPO_LEFT_MARGIN + ns.longest_repo * FONT_WIDENESS * FONT_SIZE -%} +{%- set VERSION_WIDTH = OUTER_MARGIN + ns.longest_version * FONT_WIDENESS * FONT_SIZE -%} +{%- set WIDTH = VERSION_X + VERSION_WIDTH -%} +{%- set ns = namespace(y = 0) -%} +{%- macro y(offset=0) -%}{{ ns.y + offset }}{%- endmacro -%} +{%- macro texty() -%}{{ y(FONT_SIZE) }}{%- endmacro -%} +{%- macro end_row() -%}{%- set ns.y = ns.y + ROW_HEIGHT %}{%- endmacro -%} - - {% set ns = namespace(y = 20) %} - Packaging Status + + + Packaging Status + {{ end_row() }} {% for family, family_contents in versions.items() %} - {{ family }} + {{ family }}{{ end_row() }} {% for repo, version in family_contents.items() %} - {{ repo }} - {{ version }} + {{ repo }} + + {{ version }}{{ end_row() }} {% endfor %} {% endfor %} -- cgit v1.2.3