Repository: davanstrien/flyswot

Issue:
Switch to using 🤗 hub for storing flyswot models 
closes #363 

- Switch to using 🤗 for storing flyswot models
- Removes redundant tests
- [WIP] show model cards as part of inference summary. 


Discussion:

Resolving patch:
diff --git a/src/flyswot/config.py b/src/flyswot/config.py
--- a/src/flyswot/config.py
+++ b/src/flyswot/config.py
@@ -1,4 +1,4 @@
 """configuration"""
 
 APP_NAME: str = "flyswot"
-MODEL_REPO_URL: str = "https://api.github.com/repos/davanstrien/flyswot-models/releases"
+MODEL_REPO_ID: str = "davanstrien/flyswot"
diff --git a/src/flyswot/inference.py b/src/flyswot/inference.py
--- a/src/flyswot/inference.py
+++ b/src/flyswot/inference.py
@@ -17,6 +17,7 @@
 from typing import Dict
 from typing import Iterable
 from typing import List
+from typing import Optional
 from typing import Union
 
 import numpy as np
@@ -164,13 +165,12 @@ def predict_directory(
     """
     start_time = time.perf_counter()
     model_dir = models.ensure_model_dir()
-    if model_name == "latest":
-        model_parts = models.ensure_model(model_dir, check_latest=True)
-    if model_name != "latest" and not model_path:
-        model_parts = models._get_model_parts(Path(model_dir / Path(model_name)))
-    if model_name != "latest" and model_path:
-        model_parts = models._get_model_parts(Path(model_path / Path(model_name)))
-    onnxinference = OnnxInferenceSession(model_parts.model, model_parts.vocab)
+    model = models.ensure_model(model_dir)
+    # if model_name != "latest" and not model_path:
+    #     model_parts = models._get_model_parts(Path(model_dir / Path(model_name)))
+    # if model_name != "latest" and model_path:
+    #     model_parts = models._get_model_parts(Path(model_path / Path(model_name)))
+    onnxinference = OnnxInferenceSession(model.model, model.vocab)
     files = sorted(core.get_image_files_from_pattern(directory, pattern, image_format))
     check_files(files, pattern, directory)
     typer.echo(f"Found {len(files)} files matching {pattern} in {directory}")
@@ -179,7 +179,7 @@ def predict_directory(
         images_checked = 0
         for i, batch in enumerate(itertoolz.partition_all(bs, files)):
             batch_predictions = onnxinference.predict_batch(batch, bs)
-            if i == 0:
+            if i == 0:  # pragma: no cover
                 create_csv_header(batch_predictions, csv_fname)
             write_batch_preds_to_csv(batch_predictions, csv_fname)
             progress.update(len(batch))
@@ -197,6 +197,7 @@ def print_inference_summary(
     csv_fname: Path,
     image_format: str,
     matched_file_count: int,
+    local_model: Optional[models.LocalModel] = None,
 ):
     """prints summary report"""
     print(flyswot_logo())
@@ -221,6 +222,8 @@ def print_inference_summary(
     )
     inference_summary_columns = get_inference_table_columns(csv_fname)
     print(Panel(inference_summary_columns, title="Prediction Summary"))
+    if local_model is not None:
+        print(models.show_model_card(localmodel=local_model))
 
 
 def create_file_summary_markdown(
diff --git a/src/flyswot/models.py b/src/flyswot/models.py
--- a/src/flyswot/models.py
+++ b/src/flyswot/models.py
@@ -1,14 +1,7 @@
 """Model Commands."""
-import datetime
 import fnmatch
-import json
-import urllib.error
-import urllib.request
-import zipfile
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Any
-from typing import Dict
 from typing import List
 from typing import Optional
 from typing import Tuple
@@ -16,50 +9,40 @@
 
 import typer
 import validators  # type: ignore
+from huggingface_hub import snapshot_download
 from rich.markdown import Markdown
 from toolz import itertoolz
 from toolz import recipes
 
 from flyswot.config import APP_NAME
-from flyswot.config import MODEL_REPO_URL
+from flyswot.config import MODEL_REPO_ID
 from flyswot.console import console
 
 app = typer.Typer()
 
-REPO_URL = "https://api.github.com/repos/davanstrien/learn-onnx/releases"
-
-
-@dataclass
-class GitHubRelease:
-    """A GitHub Release of a model"""
-
-    html_url: str
-    body: str
-    updated_at: datetime.datetime
-    browser_download_url: str
-    model_name: str
-
 
 @dataclass
 class LocalModel:
     """A local model container"""
 
-    vocab: Path
-    modelcard: Path
-    model: Path
+    hf_cache: Path
 
+    def __post_init__(self):
+        """Returns model parts contained under hf_cache"""
+        self._get_model_parts(self.hf_cache)
 
-def get_release_metadata(release: Dict[str, Any]) -> GitHubRelease:
-    """Extracts required fields from `release` for `GitHubRelease`"""
-    html_url = release["html_url"]
-    body = release["body"]
-    assets_json = release["assets"][0]
-    updated_at = datetime.datetime.strptime(
-        assets_json["updated_at"], "%Y-%m-%dT%H:%M:%S%z"
-    )
-    browser_download_url = assets_json["browser_download_url"]
-    name = assets_json["name"]
-    return GitHubRelease(html_url, body, updated_at, browser_download_url, name)
+    def _get_model_parts(self, hf_cache: Path):
+        """Returns model path, vocab and metadata for a model"""
+        model_files = Path(hf_cache).iterdir()
+        for file in model_files:
+            if fnmatch.fnmatch(file.name, "vocab.txt"):
+                self.vocab = file
+            if fnmatch.fnmatch(file.name, "README.md"):
+                self.modelcard = file
+            if fnmatch.fnmatch(file.name, "*.onnx") or fnmatch.fnmatch(
+                file.name, "*.pkl"
+            ):
+                self.model = file
 
 
 def _url_callback(url: str) -> Union[str, None]:
@@ -72,20 +55,6 @@ def _url_callback(url: str) -> Union[str, None]:
         raise typer.BadParameter(f"Please check {url} is a valid url")
 
 
-def get_remote_release_json(
-    url: str, single: bool = False
-) -> Dict[str, Any]:  # pragma : no cover
-    """Returns all releases found at `url`"""
-    with urllib.request.urlopen(url) as response:  # pragma: no cover
-        try:
-            html = response.read()
-            response_json: Dict[str, Any] = json.loads(html)
-            return response_json
-        except ConnectionError as error:  # pragma: no cover
-            typer.echo(error)
-            raise typer.Exit(code=1)
-
-
 def ensure_model_dir(model_dir_path: Union[Path, None] = None) -> Path:
     """Checks for a local model dir and creates one if not found"""
     if not model_dir_path:
@@ -111,119 +80,53 @@ def show_model_dir() -> None:
 
 
 @app.command(name="download")
-def download_model(
-    url: str = typer.Argument("latest", callback=_url_callback),
+def get_model(
+    revision: Optional[str] = typer.Argument(None, callback=_url_callback),
     model_dir: Path = typer.Argument(
         None,
         envvar="MODEL_DIR",
         help="Optionally specify a directory to store model files in",
     ),
-) -> None:  # pragma: no cover
+    local_only=False,
+) -> Path:  # pragma: no cover
     """Downloads models, defaults to the latest available model"""
-    if url != "latest":
+    repo_id = MODEL_REPO_ID
+    with console.status("Getting model", spinner="dots"):
+        model = snapshot_download(
+            repo_id, cache_dir=model_dir, revision=None, local_files_only=local_only
+        )
+    return Path(model)
 
-        raise NotImplementedError
-    url = MODEL_REPO_URL + "/latest"
-    remote_release_json = get_remote_release_json(url, single=True)
-    if not remote_release_json:
-        raise typer.Exit()
-    release_metadata = get_release_metadata(remote_release_json)
-    download_url = release_metadata.browser_download_url
-    updated_date = release_metadata.updated_at
-    typer.echo(f"Remote model updated on {updated_date}")
-    model_dir = ensure_model_dir()
-    model_save_path = model_dir / release_metadata.model_name
-    with console.status(
-        "Downloading model...",
-        spinner="aesthetic",
-    ):
-        urllib.request.urlretrieve(download_url, model_save_path)
-    typer.echo(f"Model saving to {model_save_path}...")
-    typer.echo("Extracting model...")
-    with zipfile.ZipFile(model_save_path, "r") as zip_ref:
-        zip_ref.extractall(model_dir / release_metadata.model_name.replace(".zip", ""))
-
-
-def _get_model_date(model: Path) -> datetime.datetime:
-    """Gets the date from a `model_name` fname"""
-    return datetime.datetime.strptime(model.name, "%Y%m%d")
-
-
-def _sort_local_models_by_date(model_dir: Path) -> List[Path]:
-    return sorted(
-        [
-            d
-            for d in Path(model_dir).iterdir()
-            if (d.is_dir() and not d.name.startswith("."))
-        ],
-        reverse=True,
-    )
-
-
-def _get_latest_model(model_dir: Path) -> Optional[Path]:
-    models = _sort_local_models_by_date(model_dir)
-    if not models:
-        return None
-    else:
-        return models[0]
-
-
-def _get_model_parts(model_dir: Path) -> LocalModel:
-    """Returns model path, vocab and metadata for a model"""
-    model_files = Path(model_dir / "model").iterdir()
-    for file in model_files:
-        if fnmatch.fnmatch(file.name, "vocab.txt"):
-            vocab = file
-        if fnmatch.fnmatch(file.name, "modelcard.md"):
-            modelcard = file
-        if fnmatch.fnmatch(file.name, "*.onnx") or fnmatch.fnmatch(file.name, "*.pkl"):
-            model = file
-    return LocalModel(vocab, modelcard, model)
-
-
-def _compare_remote_local(local_model: Path) -> bool:  # pragma: no cover
-    url = MODEL_REPO_URL + "/latest"
-    remote_release_json = get_remote_release_json(url, single=True)
-    if not remote_release_json:
-        raise typer.Exit()
-    release_metadata = get_release_metadata(remote_release_json)
-    return (
-        release_metadata.updated_at.isoformat()
-        > _get_model_date(local_model).isoformat()
-    )
-
-
-def load_model(model_dir: Path):  # pragma: no cover
-    """returns a local model"""
-    return _get_model_parts(model_dir)
-
-
-def ensure_model(
-    model_dir: Path, check_latest: bool = False
-) -> Optional[LocalModel]:  # pragma: no cover
+
+# def _get_model_parts(model_dir: Path) -> Optional[LocalModel]:
+#     """Returns model path, vocab and metadata for a model"""
+#     model_files = Path(model_dir).iterdir()
+#     vocab = None
+#     modelcard = None
+#     model = None
+#     for file in model_files:
+#         if fnmatch.fnmatch(file.name, "vocab.txt"):
+#             vocab = file
+#         if fnmatch.fnmatch(file.name, "README.md"):
+#             modelcard = file
+#         if fnmatch.fnmatch(file.name, "*.onnx") or fnmatch.fnmatch(file.name, "*.pkl"):
+#             model = file
+#     if vocab and modelcard and model is not None:
+#         return LocalModel(vocab, modelcard, model)
+
+
+# def load_model(model_dir: Path):  # pragma: no cover
+#     """returns a local model"""
+#     return _get_model_parts(model_dir)
+
+
+def ensure_model(model_dir: Path) -> LocalModel:  # pragma: no cover
     """Checks for a local model and if not found downloads the latest available remote model"""
-    local_model = _get_latest_model(model_dir)
-    if local_model and not check_latest:
-        model_parts = _get_model_parts(local_model)
-        return model_parts
-    if local_model and check_latest:
-        if _compare_remote_local(local_model):
-            download_model(url="latest", model_dir=model_dir)
-        local_model = _get_latest_model(model_dir)
-        if local_model:
-            model_parts = _get_model_parts(local_model)
-            return model_parts
-        else:
-            typer.echo("No model found locally...")
-
-    if not local_model:
-        download_model(url="latest", model_dir=model_dir)
-        local_model = _get_latest_model(model_dir)
-        if local_model:
-            model_parts = _get_model_parts(local_model)
-            return model_parts
-        typer.echo("Not able to find a model")
-        raise typer.Exit()
+    model = get_model(model_dir=model_dir)
+    if model:
+        return LocalModel(model)
+    typer.echo("Not able to find a model")
+    raise typer.Exit()
 
 
 def is_pipe(c: Tuple) -> bool:
@@ -244,23 +147,28 @@ def load_vocab(vocab: Path) -> List[List[str]]:
 
 
 @app.command()
-def vocab(
-    model: str = typer.Argument("latest"), show: bool = typer.Option(True)
-) -> Optional[List[str]]:
+def vocab(model: str = typer.Argument("latest"), show: bool = typer.Option(True)):
     """Prints out vocab for latest model"""
     if model != "latest":
         raise NotImplementedError
     model_dir = ensure_model_dir()
-    model_path = _get_latest_model(model_dir)
-    if model_path:
-        model_parts = _get_model_parts(model_path)
-        if model_parts.vocab:
-            vocab = load_vocab(model_parts.vocab)
+    model_path = get_model(model_dir=model_dir)
+    if model_path:  # pragma: no cover
+        local_model = LocalModel(model_path)
+        if local_model.vocab:
+            vocab = load_vocab(local_model.vocab)
             if show:
                 console.print(Markdown("# Model Vocab"))
                 console.print(vocab)
             return vocab
 
 
+def show_model_card(localmodel: LocalModel):
+    """Shows model card for model"""
+    with open(localmodel.modelcard, "r") as f:
+        md = Markdown(f.read())
+    console.print(md)
+
+
 if __name__ == "__main__":  # pragma: no cover
     app()


Test patch:
diff --git a/tests/test_models.py b/tests/test_models.py
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -29,97 +29,6 @@ def test_app_exists() -> None:
     assert models.app
 
 
-single_github_release_dict: Dict[Any, Any] = {
-    "url": "https://api.github.com/repos/davanstrien/learn-onnx/releases/40756746",
-    "assets_url": "https://api.github.com/repos/davanstrien/learn-onnx/releases/40756746/assets",
-    "upload_url": "https://uploads.github.com/repos/davanstrien/learn-onnx/releases/40756746/assets{?name,label}",
-    "html_url": "https://github.com/davanstrien/learn-onnx/releases/tag/2021.03.31",
-    "id": 40756746,
-    "author": {
-        "login": "davanstrien",
-        "id": 8995957,
-        "node_id": "MDQ6VXNlcjg5OTU5NTc=",
-        "avatar_url": "https://avatars.githubusercontent.com/u/8995957?v=4",
-        "gravatar_id": "",
-        "url": "https://api.github.com/users/davanstrien",
-        "html_url": "https://github.com/davanstrien",
-        "followers_url": "https://api.github.com/users/davanstrien/followers",
-        "following_url": "https://api.github.com/users/davanstrien/following{/other_user}",
-        "gists_url": "https://api.github.com/users/davanstrien/gists{/gist_id}",
-        "starred_url": "https://api.github.com/users/davanstrien/starred{/owner}{/repo}",
-        "subscriptions_url": "https://api.github.com/users/davanstrien/subscriptions",
-        "organizations_url": "https://api.github.com/users/davanstrien/orgs",
-        "repos_url": "https://api.github.com/users/davanstrien/repos",
-        "events_url": "https://api.github.com/users/davanstrien/events{/privacy}",
-        "received_events_url": "https://api.github.com/users/davanstrien/received_events",
-        "type": "User",
-        "site_admin": False,
-    },
-    "node_id": "MDc6UmVsZWFzZTQwNzU2NzQ2",
-    "tag_name": "2021.03.31",
-    "target_commitish": "main",
-    "name": "2021.03.31",
-    "draft": False,
-    "prerelease": False,
-    "created_at": "2021-03-28T18:33:30Z",
-    "published_at": "2021-03-31T12:47:12Z",
-    "assets": [
-        {
-            "url": "https://api.github.com/repos/davanstrien/learn-onnx/releases/assets/34258568",
-            "id": 34258568,
-            "node_id": "MDEyOlJlbGVhc2VBc3NldDM0MjU4NTY4",
-            "name": "2021-03-31-model.onnx",
-            "label": None,
-            "uploader": {
-                "login": "davanstrien",
-                "id": 8995957,
-                "node_id": "MDQ6VXNlcjg5OTU5NTc=",
-                "avatar_url": "https://avatars.githubusercontent.com/u/8995957?v=4",
-                "gravatar_id": "",
-                "url": "https://api.github.com/users/davanstrien",
-                "html_url": "https://github.com/davanstrien",
-                "followers_url": "https://api.github.com/users/davanstrien/followers",
-                "following_url": "https://api.github.com/users/davanstrien/following{/other_user}",
-                "gists_url": "https://api.github.com/users/davanstrien/gists{/gist_id}",
-                "starred_url": "https://api.github.com/users/davanstrien/starred{/owner}{/repo}",
-                "subscriptions_url": "https://api.github.com/users/davanstrien/subscriptions",
-                "organizations_url": "https://api.github.com/users/davanstrien/orgs",
-                "repos_url": "https://api.github.com/users/davanstrien/repos",
-                "events_url": "https://api.github.com/users/davanstrien/events{/privacy}",
-                "received_events_url": "https://api.github.com/users/davanstrien/received_events",
-                "type": "User",
-                "site_admin": False,
-            },
-            "content_type": "application/octet-stream",
-            "state": "uploaded",
-            "size": 5076633,
-            "download_count": 0,
-            "created_at": "2021-03-31T12:45:59Z",
-            "updated_at": "2021-03-31T12:46:03Z",
-            "browser_download_url": "https://github.com/davanstrien/learn-onnx/releases/download/2021.03.31/2021-03-31-model.onnx",
-        }
-    ],
-    "tarball_url": "https://api.github.com/repos/davanstrien/learn-onnx/tarball/2021.03.31",
-    "zipball_url": "https://api.github.com/repos/davanstrien/learn-onnx/zipball/2021.03.31",
-    "body": "\r\n# Description \r\n\r\nblah blah blah\r\n\r\n## Model Metadata\r\n\r\n### Classes\r\n\r\ncat, dog, mouse\r\n\r\n### Scores\r\n\r\n| class | f1 |\r\n|----|---|\r\n|cat | 0.9 |",
-}
-
-
-def test_get_release_metadata_exists(
-    test_dict: Dict[Any, Any] = single_github_release_dict
-) -> None:
-    """It exists"""
-    release = models.get_release_metadata(test_dict)
-    assert release
-
-
-def test_get_release_metadata_types(
-    test_dict: Dict[Any, Any] = single_github_release_dict
-) -> None:
-    release = models.get_release_metadata(test_dict)
-    assert type(release.html_url) == str
-
-
 def test_url_callback() -> None:
     url_latest = "latest"
     assert models._url_callback(url_latest) == url_latest
@@ -146,49 +55,17 @@ def test_show_model_dir() -> None:
     assert "model" in result.stdout
 
 
-@pytest.mark.parametrize(
-    "model_dir, expected",
-    [
-        (Path("20210331"), "2021-03-31 00:00:00"),
-        (Path("20210423"), "2021-04-23 00:00:00"),
-    ],
-)
-def test_get_model_date(model_dir: Any, expected: Any) -> None:
-    """It returns datetime and str matches"""
-    date = models._get_model_date(model_dir)
-    assert type(date) == datetime.datetime
-    assert str(date) == expected
-
-
-def test_get_latest_model(tmp_path: Any) -> None:
-    flyswot = tmp_path / "flyswot"
-    flyswot.mkdir()
-    for model in ["20210331", "20210328"]:
-        model_path = flyswot / model
-        model_path.mkdir()
-    latest = models._get_latest_model(flyswot)
-    assert latest.name == "20210331"
-
-
-def test_get_latest_model_none(tmp_path: Any) -> None:
-    """It returns None if model directory empty"""
-    flyswot = tmp_path / "flyswot"
-    flyswot.mkdir()
-    latest = models._get_latest_model(flyswot)
-    assert latest is None
-
-
-def test_get_model_parts(tmp_path: Any) -> None:
-    model_dir = tmp_path / "20210331/model"
+def test_local_model(tmp_path: Any) -> None:
+    model_dir = tmp_path / "model"
     model_dir.mkdir(parents=True)
     assert model_dir.is_dir()
     vocab = model_dir / "vocab.txt"
     vocab.touch()
-    model_card = model_dir / "modelcard.md"
+    model_card = model_dir / "README.md"
     model_card.touch()
     modelfile = model_dir / "20210331.onnx"
     modelfile.touch()
-    parts = models._get_model_parts(tmp_path / "20210331")
+    parts = models.LocalModel(model_dir)
     assert parts.vocab
     assert parts.model
     assert parts.modelcard
@@ -225,5 +102,19 @@ def test_vocab_print(capsys) -> None:
     assert "Model Vocab" in captured.out
 
 
+def test_show_model_card(tmp_path):
+    model_dir = tmp_path / "model"
+    model_dir.mkdir(parents=True)
+    assert model_dir.is_dir()
+    vocab = model_dir / "vocab.txt"
+    vocab.touch()
+    model_card = model_dir / "README.md"
+    model_card.touch()
+    modelfile = model_dir / "20210331.onnx"
+    modelfile.touch()
+    localmodel = models.LocalModel(model_dir)
+    models.show_model_card(localmodel)
+
+
 def test_app(tmp_path: Any) -> None:
     pass