Skip to content
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

Merged
merged 7 commits into from
Apr 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,46 @@ LABEL authors='[\
"email": "doe@john.com"\
}\
]'
LABEL docs='http://path.to.your.docs.com'
LABEL company='{\
"about": "",\
"about": "brief description",\
"name": "Company/Person Name",\
"email": "email@company.com"\
}'
LABEL readme="https://raw.githubusercontent.com/username/repo/{tag}/README.md"
LABEL links='{\
"website": "https://...",\
"support": "mailto:support@company.com",\
"documentation": "https://docs.company.com/cool-extension/",\
}'
LABEL type="example"
LABEL tags='[\
"positioning",\
"navigation"\
]'
```

- `version` is the name of the current tag, which we expect to be a valid [semver](https://semver.org/).
- `permissions`is a json file that follows the [Docker API payload for creating containers](https://docs.docker.com/engine/api/v1.41/#tag/Container/operation/ContainerCreate).
- `docs` is a url for the documentation of your extension.
- `authors` is a json list of authors of your extension
- `company` is a json, which currently only contains an "about" section for a brief description about your company.
- `company` is a json with information about the maintainer responsible for providing new versions
- `readme` is a URL to a markdown-based README file for the extension
- `links` is a collection of additional useful/relevant links
Copy link
Member

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

Copy link
Collaborator Author

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.

- `type` is a primary categorisation of the extension, and should be one of:
- "device-integration"
- "example"
- "theme"
- "other"
- `tags` is a collection of relevant tags for filtering, which should be lowercase alpha-numeric with dashes
- limit of 10 per extension

Some additional information and examples are available in the
[metadata documentation](https://docs.bluerobotics.com/ardusub-zola/software/onboard/BlueOS-latest/extensions#metadata-dockerfile).

## How this repo works

Every time this repo changes, a Github Action runs and goes through all the .json files in here. For each of them, it reaches out to dockerhub and fetches all the available tags, extracting the metadata in LABELS and crafting a complete `manifest.json`, which is stored in this repo's gh-pages branch.
Every time this repo changes, a Github Action runs and goes through all the .json files in here. For each of them, it reaches out to dockerhub and fetches all the available tags, extracting the metadata in LABELS and crafting a complete `manifest.json`, which is stored in this repo's `gh-pages` branch.

There is also a [website](https://docs.bluerobotics.com/BlueOS-Extensions-Repository) that gets generated, to show which extensions are currently available in the store.

## Testing the website locally

Expand Down
58 changes: 48 additions & 10 deletions blueos_repository/consolidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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):
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class ExtensionType(HumanStringEnum):
    DEVICE_INTEGRATION = auto()
    EXAMPLE = auto()
    THEME = auto()
    OTHER = auto()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't create desirable values.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

Copy link
Collaborator Author

@ES-Alexander ES-Alexander Apr 28, 2023

Choose a reason for hiding this comment

The 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:
Expand All @@ -58,6 +73,14 @@ class Version:
readme: Optional[str]
company: Optional[Company]
support: Optional[str]
type: ExtensionType
filter_tags: List[str]
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
Copy link
Member

Choose a reason for hiding this comment

The 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)]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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 Potato, witchcraft, emojis, non latin characters and etc ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the main "docs" labels is not the priority ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 links does not contain "docs" or "documentation" then there's a fallback check for the old structure that had "docs" as an independent label.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add something on CI to avoid people using the deprecated docs from the old structure. That can be done in future PRs, but we should at least create an issue to keep a note.
Besides that, can you add a comment on the code explaining it as well ?
There is no comments on code and in the commit about this change.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add something on CI to avoid people using the deprecated docs from the old structure. That can be done in future PRs, but we should at least create an issue to keep a note.

I've added a note to #30

Besides that, can you add a comment on the code explaining it as well ?

Done

There is no comments on code and in the commit about this change.

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:
Expand Down
70 changes: 47 additions & 23 deletions website/src/components/Counter.vue
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>
10 changes: 10 additions & 0 deletions website/src/types/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ export interface Company {
email?: string;
}

export enum ExtensionType {
DEVICE_INTEGRATION = "device-integration",
THEME = "theme",
OTHER = "other",
EXAMPLE = "example"
}

export interface Version {
permissions?: {[key: string]: any};
requirements?: string;
Expand All @@ -19,6 +26,9 @@ export interface Version {
readme?: string;
company?: Company;
support?: string;
type: ExtensionType;
filter_tags: string[];
extra_links: {[key: string]: string};
}

export interface RepositoryEntry {
Expand Down