-
Notifications
You must be signed in to change notification settings - Fork 20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support additional metadata #44
Changes from all commits
472cb3c
dd23e45
7616192
adc8f02
5db3161
afb0eac
38d398e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,14 +2,22 @@ | |
import asyncio | ||
import dataclasses | ||
import json | ||
from enum import Enum | ||
from pathlib import Path | ||
from typing import Any, AsyncIterable, Dict, List, Optional, Union | ||
|
||
import aiohttp | ||
import semver | ||
from registry import Registry | ||
|
||
REPO_ROOT = "https://raw.githubusercontent.com/bluerobotics/BlueOS-Extensions-Repository/master/" | ||
REPO_ROOT = "https://raw.githubusercontent.com/bluerobotics/BlueOS-Extensions-Repository/master/repos" | ||
|
||
|
||
class StrEnum(str, Enum): | ||
"""Temporary filler until Python 3.11 available.""" | ||
|
||
def __str__(self) -> str: | ||
return self.value # type: ignore | ||
Comment on lines
+16
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. class HumanStringEnum(str, Enum):
def __str__(self) -> str:
return self.name.lower().replace('_', '-') |
||
|
||
|
||
class EnhancedJSONEncoder(json.JSONEncoder): | ||
|
@@ -46,6 +54,13 @@ def from_json(json_dict: Dict[str, str]) -> "Company": | |
return Company(name=json_dict["name"], email=json_dict.get("email", None), about=json_dict.get("about", None)) | ||
|
||
|
||
class ExtensionType(StrEnum): | ||
DEVICE_INTEGRATION = "device-integration" | ||
EXAMPLE = "example" | ||
THEME = "theme" | ||
OTHER = "other" | ||
Comment on lines
+57
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. class ExtensionType(HumanStringEnum):
DEVICE_INTEGRATION = auto()
EXAMPLE = auto()
THEME = auto()
OTHER = auto() There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't create desirable values. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it prints nice things, but the actual values end up as string versions of numbers. >>> ExtensionType.DEVICE_INTEGRATION
<ExtensionType.DEVICE_INTEGRATION: '1'>
>>> ExtensionType.DEVICE_INTEGRATION == 'device-integration'
False
>>> ExtensionType.DEVICE_INTEGRATION == '1'
True |
||
|
||
|
||
# pylint: disable=too-many-instance-attributes | ||
@dataclasses.dataclass | ||
class Version: | ||
|
@@ -58,6 +73,14 @@ class Version: | |
readme: Optional[str] | ||
company: Optional[Company] | ||
support: Optional[str] | ||
type: ExtensionType | ||
filter_tags: List[str] | ||
ES-Alexander marked this conversation as resolved.
Show resolved
Hide resolved
|
||
extra_links: Dict[str, str] | ||
|
||
@staticmethod | ||
def validate_filter_tags(tags: List[str]) -> List[str]: | ||
"""Returns a list of up to 10 lower-case alpha-numeric tags (dashes allowed).""" | ||
return [tag.lower() for tag in tags if tag.replace("-", "").isalnum()][:10] | ||
Comment on lines
+81
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should enforce our defined tags and not allow custom ones. return [tag for tag in tags if tag in (str(e) for e in ExtensionTags)] There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't have fixed tags, just fixed types. If someone wants to create an arbitrary tag then they can. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So... I can have a tag that will show on the interface as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, they're required to be lower-case alpha-numeric tags (with dashes allowed), and there's a limit of 10 per extension, as specified in the docstring of that function, as well as in our extensions metadata documentation and in the readme as well. |
||
|
||
|
||
@dataclasses.dataclass | ||
|
@@ -126,50 +149,65 @@ async def all_repositories(self) -> AsyncIterable[RepositoryEntry]: | |
raise Exception(f"unable to read file {repo}: {error}") from error | ||
|
||
@staticmethod | ||
def is_valid_semver(string: str) -> bool: | ||
def valid_semver(string: str) -> Optional[semver.VersionInfo]: | ||
# We want to allow versions to be prefixed with a 'v'. | ||
# This is up for discussion | ||
if string.startswith("v"): | ||
string = string[1:] | ||
try: | ||
semver.VersionInfo.parse(string) | ||
return True | ||
return semver.VersionInfo.parse(string) | ||
except ValueError: | ||
return False | ||
return None # not valid | ||
|
||
# pylint: disable=too-many-locals | ||
async def run(self) -> None: | ||
async for repository in self.all_repositories(): | ||
for tag in await self.registry.fetch_remote_tags(repository.docker): | ||
try: | ||
if not self.is_valid_semver(tag): | ||
if not self.valid_semver(tag): | ||
print(f"{tag} is not valid SemVer, ignoring it...") | ||
continue | ||
raw_labels = await self.registry.fetch_labels(f"{repository.docker}:{tag}") | ||
permissions = raw_labels.get("permissions", None) | ||
website = raw_labels.get("website", None) | ||
links = raw_labels.get("links", {}) | ||
website = links.pop("website", raw_labels.get("website", None)) | ||
authors = raw_labels.get("authors", None) | ||
docs = raw_labels.get("docs", None) | ||
# documentation is just a URL for a link, but the old format had it as its own label | ||
docs = links.pop("docs", links.pop("documentation", raw_labels.get("docs", None))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why the main "docs" labels is not the priority ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documentation is just a URL, so it should go as an entry within the new "links" label. If There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should add something on CI to avoid people using the deprecated There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I've added a note to #30
Done
I was originally considering the point about 'links' in the commit description to be sufficient, since I don't think this is a particularly consequential or important change - the field isn't currently being used in BlueOS, and most existing extensions have no value for it. |
||
readme = raw_labels.get("readme", None) | ||
if readme is not None: | ||
readme = readme.replace(r"{tag}", tag) | ||
try: | ||
readme = await self.fetch_readme(readme) | ||
except Exception as error: # pylint: disable=broad-except | ||
readme = str(error) | ||
company_raw = raw_labels.get("company", None) | ||
company = Company.from_json(json.loads(company_raw)) if company_raw is not None else None | ||
support = raw_labels.get("support", None) | ||
support = links.pop("support", raw_labels.get("support", None)) | ||
type_ = raw_labels.get("type", ExtensionType.OTHER) | ||
filter_tags = raw_labels.get("tags", []) | ||
|
||
new_version = Version( | ||
permissions=json.loads(permissions) if permissions else None, | ||
website=website, | ||
authors=json.loads(authors) if authors else [], | ||
docs=json.loads(docs) if docs else None, | ||
readme=await self.fetch_readme(readme) if readme is not None else None, | ||
readme=readme, | ||
company=company, | ||
support=support, | ||
extra_links=links, | ||
type=type_, | ||
filter_tags=Version.validate_filter_tags(filter_tags), | ||
requirements=raw_labels.get("requirements", None), | ||
tag=tag, | ||
) | ||
repository.versions[tag] = new_version | ||
except KeyError as error: | ||
raise Exception(f"unable to parse repository {repository}: {error}") from error | ||
# sort the versions, with the highest version first | ||
repository.versions = dict( | ||
sorted(repository.versions.items(), key=lambda i: self.valid_semver(i[0]), reverse=True) # type: ignore | ||
) | ||
self.consolidated_data.append(repository) | ||
|
||
with open("manifest.json", "w", encoding="utf-8") as manifest_file: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,38 +1,62 @@ | ||
<template> | ||
<v-container> | ||
<v-row> | ||
<v-col v-for="extension in extensions" :key="extension.identifier"> | ||
<v-card class="mx-auto" width="400px" outlined> | ||
<v-card-title> | ||
<v-row no-gutters class="justify-center align-center"> | ||
<v-avatar tile size="50"> | ||
<v-img :src="extension.extension_logo" /> | ||
</v-avatar> | ||
<v-col> | ||
<v-card-title> | ||
<a v-bind:href="extension.website">{{ extension.name }}</a> | ||
</v-card-title> | ||
<v-card-subtitle> | ||
<a v-bind:href="'https://hub.docker.com/r/' + extension.docker">{{ extension.identifier }}</a> | ||
</v-card-subtitle> | ||
</v-col> | ||
</v-row> | ||
</v-card-title> | ||
<v-card-text>{{ extension.description }}</v-card-text> | ||
</v-card> | ||
</v-col> | ||
<v-row | ||
v-for="(section, index) in sections" | ||
:key="index" | ||
> | ||
<v-row class="ma-1"> | ||
<h1>{{ section.replace("-"," ").replace(/\b\w/g, s => s.toUpperCase()) + "s" }}</h1> | ||
</v-row> | ||
<v-row class="ma-1" align="center"> | ||
<template v-for="extension in extensions" :key="extension.identifier"> | ||
<v-col v-if="highestVersion(extension).type === section"> | ||
<v-card class="mx-auto" width="400px" outlined> | ||
<v-card-title> | ||
<v-row no-gutters class="justify-center align-center"> | ||
<v-avatar tile size="65" class="ma-2"> | ||
<v-img :src="extension.extension_logo" /> | ||
</v-avatar> | ||
<v-col> | ||
<v-card-title> | ||
<a v-bind:href="extension.website">{{ extension.name }}</a> | ||
</v-card-title> | ||
<v-card-subtitle> | ||
<a v-bind:href="'https://hub.docker.com/r/' + extension.docker">{{ extension.identifier }}</a> | ||
</v-card-subtitle> | ||
</v-col> | ||
<v-slide-group class="mt-2" v-if="highestVersion(extension).filter_tags.length > 0"> | ||
<v-chip label class="ma-1" density="compact" color="blue" v-for="tag in highestVersion(extension).filter_tags"> | ||
{{ tag }} | ||
</v-chip> | ||
</v-slide-group> | ||
</v-row> | ||
</v-card-title> | ||
<v-card-text>{{ extension.description }}</v-card-text> | ||
</v-card> | ||
</v-col> | ||
</template> | ||
</v-row> | ||
</v-row> | ||
</v-container> | ||
</template> | ||
|
||
<script setup lang="ts"> | ||
import { onMounted, ref } from "vue"; | ||
import { RepositoryEntry } from "@/types/manifest" | ||
import { computed, onMounted, ref } from "vue"; | ||
import { ExtensionType, RepositoryEntry, Version } from "@/types/manifest" | ||
|
||
const extensions = ref(); | ||
|
||
onMounted(async () => { | ||
const response = await fetch("manifest.json"); | ||
extensions.value = await response.json() as [RepositoryEntry]; | ||
}); | ||
|
||
const sections = computed(() => { | ||
return ExtensionType | ||
}) | ||
|
||
function highestVersion(extension: RepositoryEntry) : Version { | ||
// Assumes versions are pre-sorted by semver, highest first | ||
return Object.values(extension.versions)[0]; | ||
} | ||
</script> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it does not say about the special case for docs and documentation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that's particularly important to specify - the "links" label in general can contain arbitrary links that are decided on by the user, we'll most likely just be displaying them all as hyperlinks.