From f2e3490a21765623b8e906ee4709a98db1932010 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Mon, 27 Feb 2023 17:29:50 +0100 Subject: [PATCH 01/26] Add skops space creator app Description This is the code for a streamlit app whose goal it is to let users easily build HF spaces for sklearn models. In particular, it offers a GUI to interface with our skops.card.Card class, giving users a wysiwyg experience. The app is currently hosted on my personal account: https://huggingface.co/spaces/BenjaminB/skops-model-card-creator2 We want to move it to skops and update it automatically. Comments I have adopted the CI to create this space using skops-ci. If the space itself is faulty, the CI job will still finish successfully, but at least one can check it and do some manual testing. Adding automatic tests will prove quite difficult, because, AFAICT, streamlit doesn't have anything like a testing client. Automatic testing would require some heavy lifting from the likes of Selenium or Playwright. It is possible to write tests for the parts of the code that are streamlit-agnostic, e.g. in tasks.py. However, that's the least brittle part of the code, so the additional value is low. Right now, there is no job that automatically builds this space and pushes it to the sklearn orga. I assume for that, we should create a workflow similar to clean-skops-user.yml that runs regularly and that uses a secret token so that random people cannot override the app. I had some strange issues with pre-commit and mypy. I don't want mypy to check the streamlit app -- it is a bit hacky in some places, which results in mypy errors. Therefore, I added an exclusion rule to pyproject.toml, which is indeed respected when I run mypy manually. However, pre-commit will stil check the repo, even though it's told to use the pyproject.toml. Thus I had to exclude the space code explicitly in the pre-commit-config.yaml. If anyone knows what the issue is, please let me know. --- .github/workflows/build-test.yml | 3 + .pre-commit-config.yaml | 1 + pyproject.toml | 2 +- spaces/README.md | 3 + spaces/deploy-skops-space-creator.py | 40 ++ spaces/skops_space_creator/README.md | 323 +++++++++++++++ spaces/skops_space_creator/__init__.py | 0 spaces/skops_space_creator/app.py | 53 +++ spaces/skops_space_creator/create.py | 131 ++++++ spaces/skops_space_creator/edit.py | 431 ++++++++++++++++++++ spaces/skops_space_creator/gethelp.py | 329 +++++++++++++++ spaces/skops_space_creator/make-data.py | 30 ++ spaces/skops_space_creator/packages.txt | 1 + spaces/skops_space_creator/requirements.txt | 7 + spaces/skops_space_creator/start.py | 251 ++++++++++++ spaces/skops_space_creator/tasks.py | 278 +++++++++++++ spaces/skops_space_creator/utils.py | 135 ++++++ 17 files changed, 2017 insertions(+), 1 deletion(-) create mode 100644 spaces/README.md create mode 100644 spaces/deploy-skops-space-creator.py create mode 100644 spaces/skops_space_creator/README.md create mode 100644 spaces/skops_space_creator/__init__.py create mode 100644 spaces/skops_space_creator/app.py create mode 100644 spaces/skops_space_creator/create.py create mode 100644 spaces/skops_space_creator/edit.py create mode 100644 spaces/skops_space_creator/gethelp.py create mode 100644 spaces/skops_space_creator/make-data.py create mode 100644 spaces/skops_space_creator/packages.txt create mode 100644 spaces/skops_space_creator/requirements.txt create mode 100644 spaces/skops_space_creator/start.py create mode 100644 spaces/skops_space_creator/tasks.py create mode 100644 spaces/skops_space_creator/utils.py diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 513ce6d1..baeb4eec 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -90,6 +90,9 @@ jobs: run: | python -m pytest -s -v -m "inference" skops/ + - name: Create skops space creator app + run: python spaces/deploy-skops-space-creator.py + - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c218d7e4..ffa6f6dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,4 +27,5 @@ repos: hooks: - id: mypy args: [--config-file=pyproject.toml] + exclude: "spaces/" additional_dependencies: [types-requests>=2.28.5] diff --git a/pyproject.toml b/pyproject.toml index c957920e..888f2ad8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,6 @@ omit = [ ] [tool.mypy] -exclude = "(\\w+/)*test_\\w+\\.py$" +exclude = "(\\w+/)*test_\\w+\\.py$|spaces/skops_space_creator" ignore_missing_imports = true no_implicit_optional = true diff --git a/spaces/README.md b/spaces/README.md new file mode 100644 index 00000000..eff2d640 --- /dev/null +++ b/spaces/README.md @@ -0,0 +1,3 @@ +# Hugging Face Spaces + +Code and script for creating Hugging Face Spaces go here. diff --git a/spaces/deploy-skops-space-creator.py b/spaces/deploy-skops-space-creator.py new file mode 100644 index 00000000..ad0b5f20 --- /dev/null +++ b/spaces/deploy-skops-space-creator.py @@ -0,0 +1,40 @@ +# Deploy the app in skops_space_creator as a Hugging Face Space +# requires the HF_HUB_TOKEN to be set as environment variable + +import os +from pathlib import Path +from uuid import uuid4 + +from huggingface_hub import HfApi + +import skops + +token = os.environ["HF_HUB_TOKEN"] +repo_name = f"skops-space-creator-{uuid4()}" +user_name = HfApi().whoami(token=token)["name"] +repo_id = f"{user_name}/{repo_name}" +print(f"Creating and pushing to repo: {repo_id}") + +space_repo = Path(skops.__path__[0]).parent / "spaces" / "skops-space-creator" + +client = HfApi() +client.create_repo( + repo_id=repo_id, + token=token, + repo_type="space", + exist_ok=True, + space_sdk="streamlit", +) +out = client.upload_folder( + repo_id=repo_id, + path_in_repo=".", + folder_path=space_repo, + commit_message="Create skops-space-creator space", + token=token, + repo_type="space", + create_pr=False, +) + +# link to main app, not to "/tree/main/" +url = out.rsplit("/", 3)[0] +print(f"visit the space at {url}") diff --git a/spaces/skops_space_creator/README.md b/spaces/skops_space_creator/README.md new file mode 100644 index 00000000..9df1fa0e --- /dev/null +++ b/spaces/skops_space_creator/README.md @@ -0,0 +1,323 @@ +--- +title: Skops Model Card Creator +emoji: 🐨 +colorFrom: indigo +colorTo: blue +sdk: streamlit +sdk_version: 1.17.0 +app_file: app.py +pinned: false +license: bsd-3-clause +tags: + - sklearn + - skops + - model card +--- + +# Create a Hugging Face model repository for scikit learn models + +This page aims to provide a simple interface to use the +[`skops`](https://skops.readthedocs.io/) model card and HF Hub creation +utilities. + +Below, we will explain the steps involved to create your own model repository to +host your scikit-learn model: + +1. Prepare the model repository +2. Edit the model card +3. Create the model repository on Hugging Face Hub + +## Step 1: Prepare the model repository + +In this step, you do the necessary preparation work to create a [model +repository on Hugging Face Hub](https://huggingface.co/docs/hub/models). + +### Upload a model + +Here you should upload the sklearn model we want to present in the model +repository. The model should be stored either as a ``pickle`` file or it should +use the [secure skops persistence +format](https://skops.readthedocs.io/en/stable/persistence.html). Later, this +model will be uploaded to the model repository so that you can share it with +others. + +The uploaded model should be a scikit-learn model or a model that is compatible +with the sklearn API, e.g. using [XGBoost sklearn +wrapper](https://xgboost.readthedocs.io/en/stable/python/python_api.html#module-xgboost.sklearn) +when it's an XGBoost model. + +If you just want to test out the application and don't want to upload a model, a +dummy model will be used instead. + +### Upload input data + +It's possible to upload input data as a csv file. If that is done, the first few +rows of the input data will be used as sample data for the model, e.g. when +trying out the [inference API](https://huggingface.co/inference-api). + + +### Choose the task type + +Choose the type of task that the model is intended to solve. It can be either +classification or regression, with input data being either tabular in nature or +text. + +### Requirements + +This is the list of Python requirements needed to run the model. + +### Choose the model card template + +This is the final step and choosing one of the options will bring you to the +editing step. + +#### Create a new skops model card + +This is the recommended way of getting started. The skops model card template +prefills the model card with some [useful +contents](https://skops.readthedocs.io/en/stable/model_card.html#model-card-content) +that you probably want to have in most model cards. Don't worry: If you don't +like part of the content, you can always edit or delete it later. + +#### Create a new empty model card + +If you want to start the model card completely from scratch, that's also +possible by choosing this option. It will generate a completely empty model card +for you that you can fashion to your liking. + +#### Load existing model card from HF Hub + +If you want to use an existing model card and edit it, that's also possible. +Please enter the Hugging Face Hub repository ID here and the corresponding model +card will be loaded. The repo ID is typically someting like `username/reponame`, +e.g. `openai/whisper-small`. Some models also omit the user name, e.g. `gpt2`. + +Note that when you choose an existing model card, a couple of files will be +downloaded, because they may be required to render the model card (e.g. images). +Therefore, depending on the repository, this step may take a bit. + +If you notice any problems when rendering the existing model card, please let us +know by [creating an issue](https://github.com/skops-dev/skops/issues). + +## Step 2: Edit the model card + +Before creating the model repository, it is crucial to ensure that the [model +card](https://huggingface.co/blog/model-cards) is edited to best represent the +model you're working on. This can be achieved in the editing step, which is +described in more detail below. + +### Editing sidbar + +In the left sidebar, you will be able to edit the model card, whereas the main +screen is reserved for rendering the model card so that you see what you will +get. We will start by describing the editing sidebar. + +Tip: You should increase the width of the side bar if it is too narrow for your +taste. + +#### Undo, redo & reset + +On top of the side bar, you have the option to undo, redo, and reset the last +operation you did. Say, you accidentally made a change, just press the `Undo` +button to undo this change. Similarly, if you want to undo your undo operation, +press the `Redo` button. Finally, if you press `Reset`, all your operations will +be undone (but don't worry if you click the button accidentally, you can redo +all of them if you want). + +#### Save, create repo & delete + +These buttons are intended for when you finished editing the model card. When +you click on `Save`, you will get the option to download the model card as a +markdown file. + +When clicking the `Create Repo` button, you will be taken to the next screen, +which offers you to create a model repository on Hugging Face Hub. This step +will be explained in more detail further below. + +Finally, you can click on `Delete` to completely discard all the changes you +made and be taken back to the start screen of the app. Be careful, any change +you made will be lost. It is thus advised to first save the model card before +pressing `Delete`. + +#### Edit a section + +Each section has its own form field, which allows you to make edits. Change the +name of the section or change the content (or both), then click `Update` to see +a preview of your change. As with all other operations, you can undo the change +by clicking on `Undo`. + +#### Delete a section + +Below the form field for editing the section, you will find a `Delete` button +(including the name of the section to make it clear which section it refers to). +If you click that button, the whole section, _including its subsections_, will +be deleted. Again, click on `Undo` if you accidentally deleted something that +you want to keep. + +#### Add section below + +If you click on this button, a new subsection wil be created under the current +section. This will create a section with a dummy title and dummy content, which +you can then edit. + +Note that this will create a new _subsection_. If there are already existing +subsections in the current section, the new subsection will be created _below_ +those existing subsections. So the new subsection you create might not appear +exactly where you expect it to appear. To illustrate this, assume that we have +the following sections and subsections: + +- Section A + - Subsection A.1 + - Subsection A.2 +- Section B + +If you create a new section below "Section A", it will be created on the same +level, and below of, "Subsection A.2", resulting in the following structure: + +- Section A + - Subsection A.1 + - Subsection A.2 + - NEW SUBSECTION +- Section B + +If you create a new section below the "Subsection A.1", you will actually create +a sub-subsection, resulting in the following structure instead: + +- Section A + - Subsection A.1 + - NEW SUB-SUBSECTION + - Subsection A.2 +- Section B + +Hopefully, this clarifies things. Unfortunately, there is no possibility (yet) +to re-order sections. + +#### Add figure below + +This button works quite similarly to adding a new section. The main difference +is that instead of having a text area to enter content, you will be asked to +upload an image file. By default, a dummy image will be shown in the preview. + +#### Add metrics (only skops template) + +If you have chosen the skops template, you will see an additional field called +`Add metrics` near the top of the side bar. Here you can choose metrics you want +to be shown in the model card, e.g. the accuracy the model achieved on a +specific dataset. Please enter one metric per line, with the metric name on the +left, then an `=` sign, and the value on the right, e.g. `accuracy on test set = +0.85`. + +After pressing `update`, the metrics will be shown in a table in the section +`Model description/Evaluation Results`. You can always add or remove metrics +from this field later. If you want to delete this section completely, look for +its edit form further below and press `Delete`. There, you can also edit the +table in a more fine grained way, e.g. by changing the alignment. + +If you don't use the skops template and still want to add a table, it is +possible to do that, but it's requires a bit more work. Add a new section as +described above, then, in the text area, create a table using the [markdown +table +syntax](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables#creating-a-table). + +### Model card visualization + +The main part of the page will show you what the final model card will look +like. + +#### Metadata + +On the very top, you can see the metadata of the model card (it is collapsed by +default). The metadata can be very useful for features on the HF Hub, e.g. +allowing other users to find your model by a given tag. + +Right now, it is not possible to edit the metadata directly from here. But don't +worry, once you have created the model card repository, you can easily edit the +metadata there. + +#### Table of Contents + +For your convenience, a table of contents is also shown at the top (collapsed by +default). This is useful if you have a bigger model card and want to see the +overview of all its contents. + +#### Markdown preview + +Finally, the model card itself is shown. This is how the model card will look +like once it is saved as markdown and then rendered. + +## Step 3: Creating a model repository + +After you have finished editing the model card, it is time to create a model +repository on Hugging Face Hub. Click on `Create Repo` and you will be taken to +the final step of the process. + +### Back & Delete + +If you find yourself wanting to make more edits to the model card, just click on +the `Back` button and you'll be brought back to the editing step. + +You can also click `Delete`, which will discard all your changes and bring you +back to the start page. Be careful: This step cannot be undone and all your +progress will be lost. + +### Files included in the repository + +For your convenience, this will show a preview of all the files included in the +repository, as well as their sizes. Don't create a repository if you see files +there that you don't want to be uploaded. + +### Privacy settings + +By default, a private repository will be created. If you untick this box, it +will be public instead. More information on what that implies can be found in +the [docs on repository +settings](https://huggingface.co/docs/hub/repositories-settings). + +### Name of the repository + +Here you have to enter the name of the repository. Typically, that's something +like `username/reponame` or `organame/reponame`. This field is mandatory and you +should ensure that the corresponding repository ID does not exist yet. + +### Enter your Hugging Face token + +Here you need to paste your Hugging Face token, which is used for +authentication. The token can be found [here](https://hf.co/settings/token) and +it always starts with "hf_". Entering a token is necessary to create a +repository. + +Note that if you don't already have an account on Hugging Face, you need to +create one to get a token. It's free. + +### Create a new repository + +Once all the required fields are filled, click on this button to create the +repository. Depending on the size, it may take a couple of seconds to finish. +Once it is created, you will see a success notification that includes the link +to the repository. Congratulations, you're done! + +## Troubleshooting + +### Not all skops features available + +This app is based on the [skops model card +feature](https://skops.readthedocs.io/en/stable/model_card.html#model-card-content). +However, it does not support all the options that are available there. If you +want to use all those options in a programmatic fashion, please follow the link +and read up on what it takes to create a model card with skops. The full power +of the `Card` class is documented +[here](https://skops.readthedocs.io/en/stable/modules/classes.html#skops.card.Card). + +### Strange behavior + +If the app behaves strangely, shows error messages, or renders incorrectly, it +may be necessary to refresh the browser tab. This will take you back to the +start page, with all progress being lost. If you can reproduce that behavior, +please [creating an issue](https://github.com/skops-dev/skops/issues) and let us +know. + +### Contact + +If you want to contact us, you can join our discord channel. To do that, follow +[these +instructions](https://skops.readthedocs.io/en/stable/community.html#discord). diff --git a/spaces/skops_space_creator/__init__.py b/spaces/skops_space_creator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/spaces/skops_space_creator/app.py b/spaces/skops_space_creator/app.py new file mode 100644 index 00000000..446ddd62 --- /dev/null +++ b/spaces/skops_space_creator/app.py @@ -0,0 +1,53 @@ +"""The app.py used with streamlit + +This ties together the different parts of the app. + +""" + +import os +import shutil +from pathlib import Path +from tempfile import mkdtemp +from typing import Literal + +import streamlit as st +from create import create_repo_input_form +from edit import edit_input_form +from gethelp import help_page +from start import start_input_form + +# Change cwd to a temporary path +if "work_dir" not in st.session_state: + work_dir = Path(mkdtemp(prefix="skops-")) + shutil.copy2("cat.png", work_dir / "cat.png") + os.chdir(work_dir) + st.session_state.work_dir = work_dir + +# Create a hf_path, which is where the repo will be created locally. When the +# session is created, copy the dummy cat.png file there and make it the cwd +if "hf_path" not in st.session_state: + hf_path = Path(mkdtemp(prefix="skops-")) + st.session_state.hf_path = hf_path + + +st.header("Skops model card creator") + + +class Screen: + state: Literal["start", "edit", "create_repo"] = "start" + + +if "screen" not in st.session_state: + st.session_state.screen = Screen() + + +if st.session_state.screen.state == "start": + start_input_form() +elif st.session_state.screen.state == "help": + help_page() +elif st.session_state.screen.state == "edit": + edit_input_form() +elif st.session_state.screen.state == "create_repo": + create_repo_input_form() +else: + st.write("Something went wrong, please open an issue") diff --git a/spaces/skops_space_creator/create.py b/spaces/skops_space_creator/create.py new file mode 100644 index 00000000..7ac069c1 --- /dev/null +++ b/spaces/skops_space_creator/create.py @@ -0,0 +1,131 @@ +import os +from pathlib import Path + +import streamlit as st +from utils import get_rendered_model_card + +from skops import hub_utils + + +def _add_back_button(): + def fn(): + st.session_state.screen.state = "edit" + + st.button("Back", help="continue editing the model card", on_click=fn) + + +def _add_delete_button(): + def fn(): + if "hf_path" in st.session_state: + del st.session_state["hf_path"] + if "model_card" in st.session_state: + del st.session_state["model_card"] + if "task_state" in st.session_state: + st.session_state.task_state.reset() + if "create_repo_name" in st.session_state: + del st.session_state["create_repo_name"] + if "hf_token" in st.session_state: + del st.session_state["hf_token"] + st.session_state.screen.state = "start" + + st.button("Delete", on_click=fn, help="Start over from scratch (lose all progress)") + + +def _save_model_card(path: Path) -> None: + model_card = st.session_state.get("model_card") + if model_card: + # do not use model_card.save, see doc of get_rendered_model_card + rendered = get_rendered_model_card( + model_card, hf_path=str(st.session_state.hf_path) + ) + with open(path / "README.md", "w") as f: + f.write(rendered) + + +def _display_repo_overview(path: Path) -> None: + text = "Files included in the repository:\n" + for file in os.listdir(path): + size = os.path.getsize(path / file) + text += f"- `{file} ({size:,} bytes)`\n" + st.markdown(text) + + +def _display_private_box(): + tip = ( + "Private repositories can only seen by you or members of the same " + "organization, see https://huggingface.co/docs/hub/repositories-settings" + ) + st.checkbox( + "Make repository private", value=True, help=tip, key="create_repo_private" + ) + + +def _repo_id_field(): + st.text_input("Name of the repository (e.g. 'User/MyRepo')", key="create_repo_name") + + +def _hf_token_field(): + tip = "The Hugging Face token can be found at https://hf.co/settings/token" + st.text_input("Enter your Hugging Face token ('hf_***')", key="hf_token", help=tip) + + +def _create_hf_repo(path, repo_name, hf_token, private): + try: + hub_utils.push( + repo_id=repo_name, + source=path, + token=hf_token, + private=private, + create_remote=True, + ) + except Exception as exc: + st.error( + "Oops, something went wrong, please create an issue. " + f"The error message is:\n\n{exc}" + ) + return + + st.success(f"Successfully created the repo 'https://huggingface.co/{repo_name}'") + + +def _add_create_repo_button(): + private = bool(st.session_state.get("create_repo_private")) + repo_name = st.session_state.get("create_repo_name") + hf_token = st.session_state.get("hf_token") + disabled = (not repo_name) or (not hf_token) + + button_text = "Create a new repository" + tip = "Creating a repo requires a name and a token" + path = st.session_state.get("hf_path") + st.button( + button_text, + help=tip, + disabled=disabled, + on_click=_create_hf_repo, + args=(path, repo_name, hf_token, private), + ) + + if not repo_name: + st.info("Repository name is required") + if not hf_token: + st.info("Token is required") + + +def create_repo_input_form(): + if not st.session_state.screen.state == "create_repo": + return + + col_0, col_1, *_ = st.columns([2, 2, 2, 2]) + with col_0: + _add_back_button() + with col_1: + _add_delete_button() + + hf_path = st.session_state.hf_path + _save_model_card(hf_path) + _display_repo_overview(hf_path) + _display_private_box() + st.markdown("---") + _repo_id_field() + _hf_token_field() + _add_create_repo_button() diff --git a/spaces/skops_space_creator/edit.py b/spaces/skops_space_creator/edit.py new file mode 100644 index 00000000..4447020c --- /dev/null +++ b/spaces/skops_space_creator/edit.py @@ -0,0 +1,431 @@ +"""The editing page of the app + +This is the meat of the application. On the sidebar, the content of the model +card is displayed in the form of editable fields. On the right side, the +rendered model card is shown. + +In the side bar, users can: + +- edit the title and content of existing sections +- delete sections +- add new sections below the current section +- add new figures below the current section + +Moreover, each action results in a "task" that is tracked in the task state. A +task has a "do" and an "undo" method. This allows us to provide "undo" and +"redo" features to the app, making it easier for users to experiment and deal +with errors. The "reset" button undoes all the tasks, leading back to the +initial model card. + +When the user is finished, there is a "save" button that downloads the model +card. They can also click "delete" to start over again, leading them to the +start page. + +""" + + +from __future__ import annotations + +import reprlib +from pathlib import Path +from tempfile import mkdtemp + +import streamlit as st +from huggingface_hub import hf_hub_download +from tasks import ( + AddFigureTask, + AddMetricsTask, + AddSectionTask, + DeleteSectionTask, + TaskState, + UpdateFigureTask, + UpdateSectionTask, +) +from utils import ( + get_rendered_model_card, + iterate_key_section_content, + process_card_for_rendering, +) + +from skops import card +from skops.card._model_card import PlotSection, split_subsection_names + +arepr = reprlib.Repr() +arepr.maxstring = 24 +tmp_path = Path(mkdtemp(prefix="skops-")) # temporary files + + +def load_model_card_from_repo(repo_id: str) -> card.Card: + print("downloading model card") + path = hf_hub_download(repo_id, "README.md") + model_card = card.parse_modelcard(path) + return model_card + + +def _update_model_card( + model_card: card.Card, + key: str, + section_name: str, + content: str, + is_fig: bool, +) -> None: + # This is a very roundabout way to update the model card but it's necessary + # because of how streamlit handles session state. Basically, there have to + # be "key" arguments, which have to be retrieved from the session_state, as + # they are up-to-date. Just getting the Python variables is not enough, as + # they can be out of date. + + # key names must match with those used in form + new_title = st.session_state[f"{key}.title"] + new_content = st.session_state[f"{key}.content"] + + # determine if title is the same + old_title_split = split_subsection_names(section_name) + new_title_split = old_title_split[:-1] + [new_title] + is_title_same = old_title_split == new_title_split + + # determine if content is the same + if is_fig: + if isinstance(new_content, PlotSection): + is_content_same = content == new_content + else: + is_content_same = not bool(new_content) + else: + is_content_same = content == new_content + + if is_title_same and is_content_same: + return + + if is_fig: + old_path, fpath = None, None + if new_content: # new figure uploaded + fname = new_content.name.replace(" ", "_") + fpath = st.session_state.hf_path / fname + old_path = fpath.parent / model_card.select(key).content.path + + task = UpdateFigureTask( + model_card, + key=key, + old_name=section_name, + new_name=new_title, + data=new_content, + new_path=fpath, + old_path=old_path, + ) + else: + task = UpdateSectionTask( + model_card, + key=key, + old_name=section_name, + new_name=new_title, + old_content=content, + new_content=new_content, + ) + st.session_state.task_state.add(task) + + +def _add_section(model_card: card.Card, key: str) -> None: + section_name = f"{key}/Untitled" + task = AddSectionTask( + model_card, title=section_name, content="[More Information Needed]" + ) + st.session_state.task_state.add(task) + + +def _add_figure(model_card: card.Card, key: str) -> None: + section_name = f"{key}/Untitled" + hf_path = st.session_state.hf_path + task = AddFigureTask( + model_card, path=hf_path, title=section_name, content="cat.png" + ) + st.session_state.task_state.add(task) + + +def _delete_section(model_card: card.Card, key: str, path: Path) -> None: + task = DeleteSectionTask(model_card, key=key, path=path) + st.session_state.task_state.add(task) + + +def _add_section_form( + model_card: card.Card, key: str, section_name: str, old_title: str, content: str +) -> None: + with st.form(key, clear_on_submit=False): + st.header(section_name) + # setting the 'key' argument below to update the session_state + st.text_input("Section name", value=old_title, key=f"{key}.title") + st.text_area("Content", value=content, key=f"{key}.content") + is_fig = False + st.form_submit_button( + "Update", + on_click=_update_model_card, + args=(model_card, key, section_name, content, is_fig), + ) + + +def _add_fig_form( + model_card: card.Card, key: str, section_name: str, old_title: str, content: str +) -> None: + with st.form(key, clear_on_submit=False): + st.header(section_name) + # setting the 'key' argument below to update the session_state + st.text_input("Section name", value=old_title, key=f"{key}.title") + st.file_uploader("Upload image", key=f"{key}.content") + is_fig = True + st.form_submit_button( + "Update", + on_click=_update_model_card, + args=(model_card, key, section_name, content, is_fig), + ) + + +def create_form_from_section( + model_card: card.Card, + key: str, + section_name: str, + content: str, + is_fig: bool = False, +) -> None: + split_sections = split_subsection_names(section_name) + old_title = split_sections[-1] + if is_fig: + _add_fig_form( + model_card=model_card, + key=key, + section_name=section_name, + old_title=old_title, + content=content, + ) + else: + _add_section_form( + model_card=model_card, + key=key, + section_name=section_name, + old_title=old_title, + content=content, + ) + + col_0, col_1, col_2 = st.columns([4, 2, 2]) + with col_0: + path = st.session_state.hf_path / content.path if is_fig else None + st.button( + f"Delete '{arepr.repr(old_title)}'", + on_click=_delete_section, + args=(model_card, key, path), + key=f"{key}.delete", + help="Delete this section, including all its subsections", + ) + with col_1: + st.button( + "add section below", + on_click=_add_section, + args=(model_card, key), + key=f"{key}.add", + help="Add a new subsection below this section", + ) + with col_2: + st.button( + "add figure below", + on_click=_add_figure, + args=(model_card, key), + key=f"{key}.fig", + help="Add a new figure below this section", + ) + + +def display_sections(model_card: card.Card) -> None: + for section_info in iterate_key_section_content(model_card._data): + create_form_from_section( + model_card, + key=section_info.return_key, + section_name=section_info.title, + content=section_info.content, + is_fig=section_info.is_fig, + ) + + +def display_toc(model_card: card.Card) -> None: + elements = [] + for section_info in iterate_key_section_content(model_card._data): + title, level = section_info.title, section_info.level + section_name = split_subsection_names(title)[-1] + elements.append(" " * level + "- " + section_name) + st.markdown("\n".join(elements)) + + +def display_model_card(model_card: card.Card) -> None: + rendered = model_card.render() + metadata, rendered = process_card_for_rendering(rendered) + + # strip metadata + with st.expander("show metadata"): + st.text(metadata) + + with st.expander("Table of Contents"): + display_toc(model_card) + + st.markdown(rendered, unsafe_allow_html=True) + + +def reset_model_card() -> None: + if "task_state" not in st.session_state: + return + if "model_card" not in st.session_state: + del st.session_state["model_card"] + + while st.session_state.task_state.done_list: + st.session_state.task_state.undo() + + +def delete_model_card() -> None: + if "hf_path" in st.session_state: + del st.session_state["hf_path"] + if "model_card" in st.session_state: + del st.session_state["model_card"] + if "task_state" in st.session_state: + st.session_state.task_state.reset() + st.session_state.screen.state = "start" + + +def undo_last(): + st.session_state.task_state.undo() + display_model_card(st.session_state.model_card) + + +def redo_last(): + st.session_state.task_state.redo() + display_model_card(st.session_state.model_card) + + +def add_download_model_card_button(): + model_card = st.session_state.model_card + data = get_rendered_model_card(model_card, hf_path=str(st.session_state.hf_path)) + tip = "Download the generated model card as markdown file" + st.download_button( + "Save (md)", + data=data, + help=tip, + file_name="README.md", + ) + + +def add_create_repo_button(): + def fn(): + st.session_state.screen.state = "create_repo" + + button_disabled = not bool(st.session_state.get("model_card")) + st.button( + "Create Repo", + help="Create a model repository on Hugging Face Hub", + on_click=fn, + disabled=button_disabled, + ) + + +def display_edit_buttons(): + # first row: undo + redo + reset + col_0, col_1, col_2, *_ = st.columns([2, 2, 2, 2]) + undo_disabled = not bool(st.session_state.task_state.done_list) + redo_disabled = not bool(st.session_state.task_state.undone_list) + with col_0: + name = f"UNDO ({len(st.session_state.task_state.done_list)})" + tip = "Undo the last edit" + st.button(name, on_click=undo_last, disabled=undo_disabled, help=tip) + with col_1: + name = f"REDO ({len(st.session_state.task_state.undone_list)})" + tip = "Redo the last undone edit" + st.button(name, on_click=redo_last, disabled=redo_disabled, help=tip) + with col_2: + tip = "Undo all edits" + st.button("Reset", on_click=reset_model_card, help=tip) + + # second row: download + create repo + delete + col_0, col_1, col_2, *_ = st.columns([2, 2, 2, 2]) + with col_0: + add_download_model_card_button() + with col_1: + add_create_repo_button() + with col_2: + tip = "Start over from scratch (lose all progress)" + st.button("Delete", on_click=delete_model_card, help=tip) + + +def _update_model_diagram(): + val = st.session_state.get("special_model_diagram", True) + model_card = st.session_state.model_card + model_card.model_diagram = val + + # TODO: this may no longer be necesssary once this issue is solved: + # https://github.com/skops-dev/skops/issues/292 + if val: + model_card.add_model_plot() + else: + model_card.delete("Model description/Training Procedure/Model Plot") + + +def _parse_metrics(metrics: str) -> dict[str, str | float]: + # parse metrics from text area, one per line, into a dict + metrics_table = {} + for line in metrics.splitlines(): + line = line.strip() + val: str | float + name, _, val = line.partition("=") + try: + # try to coerce to float but don't error if it fails + val = float(val.strip()) + except ValueError: + pass + metrics_table[name.strip()] = val + return metrics_table + + +def _update_metrics(): + metrics = st.session_state.get("special_metrics_text", {}) + model_card = st.session_state.model_card + metrics_table = _parse_metrics(metrics) + + # check if any change + if metrics_table == model_card._metrics: + return + + task = AddMetricsTask(model_card, metrics_table) + st.session_state.task_state.add(task) + + +def display_skops_special_fields(): + st.checkbox( + "Show model diagram", + value=True, + on_change=_update_model_diagram, + key="special_model_diagram", + ) + + with st.expander("Add metrics"): + with st.form("special_metrics", clear_on_submit=False): + st.text_area( + "Add one metric per line, e.g. 'accuracy = 0.9'", + key="special_metrics_text", + ) + st.form_submit_button( + "Update", + on_click=_update_metrics, + ) + + +def edit_input_form(): + if "task_state" not in st.session_state: + st.session_state.task_state = TaskState() + + with st.sidebar: + # TOP ROW BUTTONS + display_edit_buttons() + + # SHOW SPECIAL FIELDS IF SKOPS TEMPLATE WAS USED + if st.session_state.get("model_card_type", "") == "skops": + display_skops_special_fields() + + # SHOW EDITABLE SECTIONS + if "model_card" in st.session_state: + display_sections(st.session_state.model_card) + + if "model_card" in st.session_state: + display_model_card(st.session_state.model_card) diff --git a/spaces/skops_space_creator/gethelp.py b/spaces/skops_space_creator/gethelp.py new file mode 100644 index 00000000..1f793f42 --- /dev/null +++ b/spaces/skops_space_creator/gethelp.py @@ -0,0 +1,329 @@ +import streamlit as st + + +def add_back_button(key): + def fn(): + st.session_state.screen.state = "start" + + st.button("Back", help="Get back to the start screen", on_click=fn, key=key) + + +help_md = """# Create a Hugging Face model repository for scikit learn models + +This page aims to provide a simple interface to use the +[`skops`](https://skops.readthedocs.io/) model card and HF Hub creation +utilities. + +Below, we will explain the steps involved to create your own model repository to +host your scikit-learn model: + +1. Prepare the model repository +2. Edit the model card +3. Create the model repository on Hugging Face Hub + +## Step 1: Prepare the model repository + +In this step, you do the necessary preparation work to create a [model +repository on Hugging Face Hub](https://huggingface.co/docs/hub/models). + +### Upload a model + +Here you should upload the sklearn model we want to present in the model +repository. The model should be stored either as a ``pickle`` file or it should +use the [secure skops persistence +format](https://skops.readthedocs.io/en/stable/persistence.html). Later, this +model will be uploaded to the model repository so that you can share it with +others. + +The uploaded model should be a scikit-learn model or a model that is compatible +with the sklearn API, e.g. using [XGBoost sklearn +wrapper](https://xgboost.readthedocs.io/en/stable/python/python_api.html#module-xgboost.sklearn) +when it's an XGBoost model. + +If you just want to test out the application and don't want to upload a model, a +dummy model will be used instead. + +### Upload input data + +It's possible to upload input data as a csv file. If that is done, the first few +rows of the input data will be used as sample data for the model, e.g. when +trying out the [inference API](https://huggingface.co/inference-api). + + +### Choose the task type + +Choose the type of task that the model is intended to solve. It can be either +classification or regression, with input data being either tabular in nature or +text. + +### Requirements + +This is the list of Python requirements needed to run the model. + +### Choose the model card template + +This is the final step and choosing one of the options will bring you to the +editing step. + +#### Create a new skops model card + +This is the recommended way of getting started. The skops model card template +prefills the model card with some [useful +contents](https://skops.readthedocs.io/en/stable/model_card.html#model-card-content) +that you probably want to have in most model cards. Don't worry: If you don't +like part of the content, you can always edit or delete it later. + +#### Create a new empty model card + +If you want to start the model card completely from scratch, that's also +possible by choosing this option. It will generate a completely empty model card +for you that you can fashion to your liking. + +#### Load existing model card from HF Hub + +If you want to use an existing model card and edit it, that's also possible. +Please enter the Hugging Face Hub repository ID here and the corresponding model +card will be loaded. The repo ID is typically someting like `username/reponame`, +e.g. `openai/whisper-small`. Some models also omit the user name, e.g. `gpt2`. + +Note that when you choose an existing model card, a couple of files will be +downloaded, because they may be required to render the model card (e.g. images). +Therefore, depending on the repository, this step may take a bit. + +If you notice any problems when rendering the existing model card, please let us +know by [creating an issue](https://github.com/skops-dev/skops/issues). + +## Step 2: Edit the model card + +Before creating the model repository, it is crucial to ensure that the [model +card](https://huggingface.co/blog/model-cards) is edited to best represent the +model you're working on. This can be achieved in the editing step, which is +described in more detail below. + +### Editing sidbar + +In the left sidebar, you will be able to edit the model card, whereas the main +screen is reserved for rendering the model card so that you see what you will +get. We will start by describing the editing sidebar. + +Tip: You should increase the width of the side bar if it is too narrow for your +taste. + +#### Undo, redo & reset + +On top of the side bar, you have the option to undo, redo, and reset the last +operation you did. Say, you accidentally made a change, just press the `Undo` +button to undo this change. Similarly, if you want to undo your undo operation, +press the `Redo` button. Finally, if you press `Reset`, all your operations will +be undone (but don't worry if you click the button accidentally, you can redo +all of them if you want). + +#### Save, create repo & delete + +These buttons are intended for when you finished editing the model card. When +you click on `Save`, you will get the option to download the model card as a +markdown file. + +When clicking the `Create Repo` button, you will be taken to the next screen, +which offers you to create a model repository on Hugging Face Hub. This step +will be explained in more detail further below. + +Finally, you can click on `Delete` to completely discard all the changes you +made and be taken back to the start screen of the app. Be careful, any change +you made will be lost. It is thus advised to first save the model card before +pressing `Delete`. + +#### Edit a section + +Each section has its own form field, which allows you to make edits. Change the +name of the section or change the content (or both), then click `Update` to see +a preview of your change. As with all other operations, you can undo the change +by clicking on `Undo`. + +#### Delete a section + +Below the form field for editing the section, you will find a `Delete` button +(including the name of the section to make it clear which section it refers to). +If you click that button, the whole section, _including its subsections_, will +be deleted. Again, click on `Undo` if you accidentally deleted something that +you want to keep. + +#### Add section below + +If you click on this button, a new subsection wil be created under the current +section. This will create a section with a dummy title and dummy content, which +you can then edit. + +Note that this will create a new _subsection_. If there are already existing +subsections in the current section, the new subsection will be created _below_ +those existing subsections. So the new subsection you create might not appear +exactly where you expect it to appear. To illustrate this, assume that we have +the following sections and subsections: + +- Section A + - Subsection A.1 + - Subsection A.2 +- Section B + +If you create a new section below "Section A", it will be created on the same +level, and below of, "Subsection A.2", resulting in the following structure: + +- Section A + - Subsection A.1 + - Subsection A.2 + - NEW SUBSECTION +- Section B + +If you create a new section below the "Subsection A.1", you will actually create +a sub-subsection, resulting in the following structure instead: + +- Section A + - Subsection A.1 + - NEW SUB-SUBSECTION + - Subsection A.2 +- Section B + +Hopefully, this clarifies things. Unfortunately, there is no possibility (yet) +to re-order sections. + +#### Add figure below + +This button works quite similarly to adding a new section. The main difference +is that instead of having a text area to enter content, you will be asked to +upload an image file. By default, a dummy image will be shown in the preview. + +#### Add metrics (only skops template) + +If you have chosen the skops template, you will see an additional field called +`Add metrics` near the top of the side bar. Here you can choose metrics you want +to be shown in the model card, e.g. the accuracy the model achieved on a +specific dataset. Please enter one metric per line, with the metric name on the +left, then an `=` sign, and the value on the right, e.g. `accuracy on test set = +0.85`. + +After pressing `update`, the metrics will be shown in a table in the section +`Model description/Evaluation Results`. You can always add or remove metrics +from this field later. If you want to delete this section completely, look for +its edit form further below and press `Delete`. There, you can also edit the +table in a more fine grained way, e.g. by changing the alignment. + +If you don't use the skops template and still want to add a table, it is +possible to do that, but it's requires a bit more work. Add a new section as +described above, then, in the text area, create a table using the [markdown +table +syntax](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables#creating-a-table). + +### Model card visualization + +The main part of the page will show you what the final model card will look +like. + +#### Metadata + +On the very top, you can see the metadata of the model card (it is collapsed by +default). The metadata can be very useful for features on the HF Hub, e.g. +allowing other users to find your model by a given tag. + +Right now, it is not possible to edit the metadata directly from here. But don't +worry, once you have created the model card repository, you can easily edit the +metadata there. + +#### Table of Contents + +For your convenience, a table of contents is also shown at the top (collapsed by +default). This is useful if you have a bigger model card and want to see the +overview of all its contents. + +#### Markdown preview + +Finally, the model card itself is shown. This is how the model card will look +like once it is saved as markdown and then rendered. + +## Step 3: Creating a model repository + +After you have finished editing the model card, it is time to create a model +repository on Hugging Face Hub. Click on `Create Repo` and you will be taken to +the final step of the process. + +### Back & Delete + +If you find yourself wanting to make more edits to the model card, just click on +the `Back` button and you'll be brought back to the editing step. + +You can also click `Delete`, which will discard all your changes and bring you +back to the start page. Be careful: This step cannot be undone and all your +progress will be lost. + +### Files included in the repository + +For your convenience, this will show a preview of all the files included in the +repository, as well as their sizes. Don't create a repository if you see files +there that you don't want to be uploaded. + +### Privacy settings + +By default, a private repository will be created. If you untick this box, it +will be public instead. More information on what that implies can be found in +the [docs on repository +settings](https://huggingface.co/docs/hub/repositories-settings). + +### Name of the repository + +Here you have to enter the name of the repository. Typically, that's something +like `username/reponame` or `organame/reponame`. This field is mandatory and you +should ensure that the corresponding repository ID does not exist yet. + +### Enter your Hugging Face token + +Here you need to paste your Hugging Face token, which is used for +authentication. The token can be found [here](https://hf.co/settings/token) and +it always starts with "hf_". Entering a token is necessary to create a +repository. + +Note that if you don't already have an account on Hugging Face, you need to +create one to get a token. It's free. + +### Create a new repository + +Once all the required fields are filled, click on this button to create the +repository. Depending on the size, it may take a couple of seconds to finish. +Once it is created, you will see a success notification that includes the link +to the repository. Congratulations, you're done! + +## Troubleshooting + +### Not all skops features available + +This app is based on the [skops model card +feature](https://skops.readthedocs.io/en/stable/model_card.html#model-card-content). +However, it does not support all the options that are available there. If you +want to use all those options in a programmatic fashion, please follow the link +and read up on what it takes to create a model card with skops. The full power +of the `Card` class is documented +[here](https://skops.readthedocs.io/en/stable/modules/classes.html#skops.card.Card). + +### Strange behavior + +If the app behaves strangely, shows error messages, or renders incorrectly, it +may be necessary to refresh the browser tab. This will take you back to the +start page, with all progress being lost. If you can reproduce that behavior, +please [creating an issue](https://github.com/skops-dev/skops/issues) and let us +know. + +### Contact + +If you want to contact us, you can join our discord channel. To do that, follow +[these +instructions](https://skops.readthedocs.io/en/stable/community.html#discord). +""" + + +def add_help_content(): + # This is the exact same text as in the README.md of this space + st.markdown(help_md) + + +def help_page(): + add_back_button(key="help_get_back") + add_help_content() + add_back_button(key="help_get_back2") # names must be unique diff --git a/spaces/skops_space_creator/make-data.py b/spaces/skops_space_creator/make-data.py new file mode 100644 index 00000000..1db69efb --- /dev/null +++ b/spaces/skops_space_creator/make-data.py @@ -0,0 +1,30 @@ +# companion script to the space creator +# generates the logreg.pkl and logreg.skops file, as well as data.csv + +import pickle + +import pandas as pd +from sklearn.datasets import make_classification +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + +import skops.io as sio + +X, y = make_classification() +df = pd.DataFrame(X) + +clf = Pipeline( + [ + ("scale", StandardScaler()), + ("clf", LogisticRegression(random_state=0)), + ] +) +clf.fit(X, y) + +with open("logreg.pkl", "wb") as f: + pickle.dump(clf, f) +sio.dump(clf, "logreg.skops") + + +df.to_csv("data.csv", index=False) diff --git a/spaces/skops_space_creator/packages.txt b/spaces/skops_space_creator/packages.txt new file mode 100644 index 00000000..4a59b54c --- /dev/null +++ b/spaces/skops_space_creator/packages.txt @@ -0,0 +1 @@ +pandoc diff --git a/spaces/skops_space_creator/requirements.txt b/spaces/skops_space_creator/requirements.txt new file mode 100644 index 00000000..e1747269 --- /dev/null +++ b/spaces/skops_space_creator/requirements.txt @@ -0,0 +1,7 @@ +catboost +huggingface_hub +lightgbm +pandas +scikit-learn +xgboost +git+https://github.com/skops-dev/skops.git diff --git a/spaces/skops_space_creator/start.py b/spaces/skops_space_creator/start.py new file mode 100644 index 00000000..523591d9 --- /dev/null +++ b/spaces/skops_space_creator/start.py @@ -0,0 +1,251 @@ +"""Start page of the app + +This page is used to initialize a model card that is either: + +1. based on the skops template +2. empty +3. loads an existing model card + +Optionally, users can add a model file, data, requirements, and choose a task. + +""" + +import glob +import io +import os +import pickle +import shutil +from pathlib import Path +from tempfile import mkdtemp + +import pandas as pd +import sklearn +import streamlit as st +from huggingface_hub import snapshot_download +from huggingface_hub.utils import HFValidationError, RepositoryNotFoundError +from sklearn.base import BaseEstimator +from sklearn.dummy import DummyClassifier + +import skops.io as sio +from skops import card, hub_utils + +tmp_path = Path(mkdtemp(prefix="skops-")) # temporary files +description = """Create a Hugging Face model repository for scikit learn models + +This page aims to provide a simple interface to use the +[`skops`](https://skops.readthedocs.io/) model card and HF Hub creation +utilities. + +""" + + +def load_model() -> None: + if st.session_state.get("model_file") is None: + st.session_state.model = DummyClassifier() + return + + bytes_data = st.session_state.model_file.getvalue() + if st.session_state.model_file.name.endswith("skops"): + model = sio.loads(bytes_data, trusted=True) + else: + model = pickle.loads(bytes_data) + assert isinstance(model, BaseEstimator), "model must be an sklearn model" + + st.session_state.model = model + + +def load_data() -> None: + if st.session_state.get("data_file"): + bytes_data = io.BytesIO(st.session_state.data_file.getvalue()) + df = pd.read_csv(bytes_data) + else: + df = pd.DataFrame([]) + + st.session_state.data = df + + +def _clear_repo(path: str) -> None: + for file_path in glob.glob(str(Path(path) / "*")): + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + + +def init_repo() -> None: + path = st.session_state.hf_path + _clear_repo(path) + requirements = [] + task = "tabular-classification" + data = pd.DataFrame([]) + + if "requirements" in st.session_state: + requirements = st.session_state.requirements.splitlines() + if "task" in st.session_state: + task = st.session_state.task + if "data_file" in st.session_state: + load_data() + data = st.session_state.data + + if task.startswith("text") and isinstance(data, pd.DataFrame): + data = data.values.tolist() + + try: + file_name = tmp_path / "model.skops" + sio.dump(st.session_state.model, file_name) + + hub_utils.init( + model=file_name, + dst=path, + task=task, + data=data, + requirements=requirements, + ) + except Exception as exc: + print("Uh oh, something went wrong when initializing the repo:", exc) + + +def create_skops_model_card() -> None: + init_repo() + metadata = card.metadata_from_config(st.session_state.hf_path) + model_card = card.Card(model=st.session_state.model, metadata=metadata) + st.session_state.model_card = model_card + st.session_state.model_card_type = "skops" + st.session_state.screen.state = "edit" + + +def create_empty_model_card() -> None: + init_repo() + metadata = card.metadata_from_config(st.session_state.hf_path) + model_card = card.Card( + model=st.session_state.model, metadata=metadata, template=None + ) + model_card.add(**{"Untitled": "[More Information Needed]"}) + st.session_state.model_card = model_card + st.session_state.model_card_type = "empty" + st.session_state.screen.state = "edit" + + +def create_hf_model_card() -> None: + repo_id = st.session_state.get("hf_repo_id", "").strip().strip("'").strip('"') + if not repo_id: + return + + try: + allow_patterns = [ + "*.md", + ".txt", + "*.png", + "*.gif", + "*.jpg", + "*.jpeg", + "*.bmp", + "*.webp", + ] + path = snapshot_download(repo_id, allow_patterns=allow_patterns) + except (HFValidationError, RepositoryNotFoundError): + st.error( + f"Repository '{repo_id}' could not be found on HF Hub, " + "please check that the repo ID is correct." + ) + return + + # move everything to the hf_path and working dir + hf_path = st.session_state.hf_path + shutil.copytree(path, hf_path, dirs_exist_ok=True) + shutil.copytree(path, ".", dirs_exist_ok=True) + + model_card = card.parse_modelcard(hf_path / "README.md") + st.session_state.model_card = model_card + st.session_state.model_card_type = "loaded" + st.session_state.screen.state = "edit" + + +def add_help_button(): + def fn(): + st.session_state.screen.state = "help" + + st.button( + "Instructions", + on_click=fn, + help="Detailed explanation of this space", + key="get_help", + ) + + +def start_input_form(): + if "model" not in st.session_state: + st.session_state.model = DummyClassifier() + + if "data" not in st.session_state: + st.session_state.data = pd.DataFrame([]) + + if "model_card" not in st.session_state: + st.session_state.model_card = None + + st.markdown(description) + + add_help_button() + + st.markdown("---") + + st.text( + "Upload an sklearn model (strongly recommended)\n" + "The model can be used to automatically populate fields in the model card." + ) + + if not st.session_state.get("model_file"): + st.file_uploader( + "Upload an sklearn model (pickle or skops format)", + on_change=load_model, + key="model_file", + ) + + st.markdown("---") + + st.text( + "Upload samples from your data (in csv format)\n" + "This sample data can be attached to the metadata of the model card" + ) + st.file_uploader( + "Upload input data (csv)", type=["csv"], on_change=load_data, key="data_file" + ) + st.markdown("---") + + st.selectbox( + label="Choose the task type", + options=[ + "tabular-classification", + "tabular-regression", + "text-classification", + "text-regression", + ], + key="task", + on_change=init_repo, + ) + st.markdown("---") + + st.text_area( + label="Requirements", + value=f"scikit-learn=={sklearn.__version__}\n", + key="requirements", + on_change=init_repo, + ) + st.markdown("---") + + st.markdown("Choose one of the options below to get started:") + col_0, col_1, col_2 = st.columns([2, 2, 2]) + with col_0: + st.button("Create a new skops model card", on_click=create_skops_model_card) + + with col_1: + st.button("Create a new empty model card", on_click=create_empty_model_card) + + with col_2: + with st.form("Load existing model card from HF Hub", clear_on_submit=False): + st.markdown("Load existing model card from HF Hub") + st.text_input("Repo name (e.g. 'gpt2')", key="hf_repo_id") + st.form_submit_button("Load", on_click=create_hf_model_card) + + +start_input_form() diff --git a/spaces/skops_space_creator/tasks.py b/spaces/skops_space_creator/tasks.py new file mode 100644 index 00000000..253e9f97 --- /dev/null +++ b/spaces/skops_space_creator/tasks.py @@ -0,0 +1,278 @@ +"""Functionality around tasks + +Tasks are used to implement "undo" and "redo" functionality. + +""" +from __future__ import annotations + +import shutil +from pathlib import Path +from tempfile import mkdtemp +from uuid import uuid4 + +from streamlit.runtime.uploaded_file_manager import UploadedFile + +from skops import card +from skops.card._model_card import PlotSection, split_subsection_names + + +class Task: + """(Abstract) base class for tasks""" + + def do(self) -> None: + raise NotImplementedError + + def undo(self) -> None: + raise NotImplementedError + + +class TaskState: + """Tracking the state of tasks""" + + def __init__(self) -> None: + self.done_list: list[Task] = [] + self.undone_list: list[Task] = [] + + def undo(self) -> None: + if not self.done_list: + return + + task = self.done_list.pop(-1) + task.undo() + self.undone_list.append(task) + + def redo(self) -> None: + if not self.undone_list: + return + + task = self.undone_list.pop(-1) + task.do() + self.done_list.append(task) + + def add(self, task: Task) -> None: + task.do() + self.done_list.append(task) + self.undone_list.clear() + + def reset(self) -> None: + self.done_list.clear() + self.undone_list.clear() + + +class AddSectionTask(Task): + """Add a new text section""" + + def __init__( + self, + model_card: card.Card, + title: str, + content: str, + ) -> None: + self.model_card = model_card + self.title = title + self.key = title + " " + str(uuid4())[:6] + self.content = content + + def do(self) -> None: + self.model_card.add(**{self.key: self.content}) + section = self.model_card.select(self.key) + section.title = split_subsection_names(self.title)[-1] + + def undo(self) -> None: + self.model_card.delete(self.key) + + +class AddFigureTask(Task): + """Add a new figure section + + Figure always starts out with dummy image cat.png. + + """ + + def __init__( + self, + model_card: card.Card, + path: Path, + title: str, + content: str, + ) -> None: + self.model_card = model_card + self.title = title + + # Create a unique file name, since the same image can exist more than + # once per model card. + fname = Path(content) + stem = fname.stem + suffix = fname.suffix + uniq = str(uuid4())[:6] + new_fname = str(path / stem) + "_" + uniq + suffix + + self.key = title + " " + uniq + self.content = Path(new_fname) + + def do(self) -> None: + shutil.copy("cat.png", self.content) + self.model_card.add_plot(**{self.key: self.content}) + section = self.model_card.select(self.key) + section.title = split_subsection_names(self.title)[-1] + section.is_fig = True # type: ignore + + def undo(self) -> None: + self.content.unlink(missing_ok=True) + self.model_card.delete(self.key) + + +class DeleteSectionTask(Task): + """Delete a section + + The section is not completely removed from the underlying data structure, + but only turned invisible. + + """ + + def __init__( + self, + model_card: card.Card, + key: str, + path: Path | None, + ) -> None: + self.model_card = model_card + self.key = key + # when 'deleting' a file, move it to a temp file + self.path = path + self.tmp_path = Path(mkdtemp(prefix="skops-")) / str(uuid4()) + + def do(self) -> None: + self.model_card.select(self.key).visible = False + if self.path: + shutil.move(self.path, self.tmp_path) + + def undo(self) -> None: + self.model_card.select(self.key).visible = True + if self.path: + shutil.move(self.tmp_path, self.path) + + +class UpdateSectionTask(Task): + """Change the title or content of a text section""" + + def __init__( + self, + model_card: card.Card, + key: str, + old_name: str, + new_name: str, + old_content: str, + new_content: str, + ) -> None: + self.model_card = model_card + self.key = key + self.old_name = old_name + self.new_name = new_name + self.old_content = old_content + self.new_content = new_content + + def do(self) -> None: + section = self.model_card.select(self.key) + new_title = split_subsection_names(self.new_name)[-1] + section.title = new_title + section.content = self.new_content + + def undo(self) -> None: + section = self.model_card.select(self.key) + old_title = split_subsection_names(self.old_name)[-1] + section.title = old_title + section.content = self.old_content + + +class UpdateFigureTask(Task): + """Change the title or image of a figure section + + Changing the title is easy, just replace it and be done. + + Changing the figure is a bit more tricky. The old figure is in the hf_path + under its old name. The new figure is an UploadFile object. For the DO + operation, move the old figure to a temporary file and store the UploadFile + content to a new file (which may have a different name). + + For the UNDO operation, delete the new figure (its content is still stored + in the UploadFile) and move back the old figure from its temporary file to + the original location (with its original name). + + """ + + def __init__( + self, + model_card: card.Card, + key: str, + old_name: str, + new_name: str, + data: UploadedFile | None, + new_path: Path | None, + old_path: Path | None, + ) -> None: + self.model_card = model_card + self.key = key + self.old_name = old_name + self.new_name = new_name + self.old_data = self.model_card.select(self.key).content + self.new_path = new_path + self.old_path = old_path + # when 'deleting' the old image, move to temp path + self.tmp_path = Path(mkdtemp(prefix="skops-")) / str(uuid4()) + + if not data: + self.new_data = self.old_data + else: + self.new_data = data + + def do(self) -> None: + section = self.model_card.select(self.key) + new_title = split_subsection_names(self.new_name)[-1] + section.title = self.title = new_title + if self.new_data == self.old_data: # image is same + return + + # write figure + # note: this can still be the same image if the image is a file, there + # is no test to check, e.g., the hash of the image + shutil.move(self.old_path, self.tmp_path) + + with open(self.new_path, "wb") as f: + f.write(self.new_data.getvalue()) + section.content = PlotSection( + alt_text=self.new_data.name, + path=self.new_path, + ) + + def undo(self) -> None: + section = self.model_card.select(self.key) + old_title = split_subsection_names(self.old_name)[-1] + section.title = old_title + if self.new_data == self.old_data: # image is same + return + + self.new_path.unlink(missing_ok=True) + shutil.move(self.tmp_path, self.old_path) + section.content = self.old_data + + +class AddMetricsTask(Task): + """Add new metrics""" + + def __init__( + self, + model_card: card.Card, + metrics: dict[str, str | int | float], + ) -> None: + self.model_card = model_card + self.old_metrics = model_card._metrics.copy() + self.new_metrics = metrics + + def do(self) -> None: + self.model_card._metrics.clear() + self.model_card.add_metrics(**self.new_metrics) + + def undo(self) -> None: + self.model_card._metrics.clear() + self.model_card.add_metrics(**self.old_metrics) diff --git a/spaces/skops_space_creator/utils.py b/spaces/skops_space_creator/utils.py new file mode 100644 index 00000000..08094ea8 --- /dev/null +++ b/spaces/skops_space_creator/utils.py @@ -0,0 +1,135 @@ +"""Utility functions for the app""" + +from __future__ import annotations + +import base64 +import os +import re +from dataclasses import dataclass +from pathlib import Path + +from skops import card +from skops.card._model_card import PlotSection, Section + +PAT_MD_IMG = re.compile( + r'(!\[(?P[^\]]+)\]\((?P[^\)"\s]+)\s*([^\)]*)\))' +) + + +def get_rendered_model_card(model_card: card.Card, hf_path: str) -> str: + # This is a bit hacky: + # As a space, the model card is created in a temporary hf_path directory, + # which is where all the files are put. So e.g. if a figure is added, it is + # found at /tmp/skops-jtyqdgk3/fig.png. However, when the model card is is + # actually used, we don't want that, since there, the files will be in the + # cwd. Therefore, we remove the tmp directory everywhere we find it in the + # file. + if not hf_path.endswith(os.path.sep): + hf_path += os.path.sep + + rendered = model_card.render() + rendered = rendered.replace(hf_path, "") + return rendered + + +def process_card_for_rendering(rendered: str) -> tuple[str, str]: + idx = rendered[1:].index("\n---") + 1 + metadata = rendered[3:idx] + rendered = rendered[idx + 4 :] # noqa: E203 + + # below is a hack to display the images in streamlit + # https://discuss.streamlit.io/t/image-in-markdown/13274/10 The problem is + + # that streamlit does not display images in markdown, so we need to replace + # them with html. However, we only want that in the rendered markdown, not + # in the card that is produced for the hub + def markdown_images(markdown): + # example image markdown: + # ![Test image](images/test.png "Alternate text") + images = PAT_MD_IMG.findall(markdown) + return images + + def img_to_bytes(img_path): + img_bytes = Path(img_path).read_bytes() + encoded = base64.b64encode(img_bytes).decode() + return encoded + + def img_to_html(img_path, img_alt): + img_format = img_path.split(".")[-1] + img_html = ( + f'' + ) + return img_html + + def markdown_insert_images(markdown): + images = markdown_images(markdown) + + for image in images: + image_markdown = image[0] + image_alt = image[1] + image_path = image[2] + markdown = markdown.replace( + image_markdown, img_to_html(image_path, image_alt) + ) + return markdown + + rendered_with_img = markdown_insert_images(rendered) + return metadata, rendered_with_img + + +@dataclass(frozen=True) +class SectionInfo: + return_key: str + title: str + content: str + is_fig: bool + level: int + + +def iterate_key_section_content( + data: dict[str, Section], + parent_section: str = "", + parent_keys: list[str] | None = None, + level: int = 0, +) -> SectionInfo: + parent_keys = parent_keys or [] + + for key, val in data.items(): + if parent_section: + title = "/".join((parent_section, val.title)) + else: + title = val.title + + if not getattr(val, "visible", True): + continue + + return_key = key if not parent_keys else "/".join(parent_keys + [key]) + content = val.content + + is_fig = getattr(val, "is_fig", False) + if isinstance(val.content, str): + img_match = PAT_MD_IMG.match(val.content) + if img_match: # image section found in parsed model card + is_fig = True + img_title = img_match.groupdict()["image_title"] + img_path = img_match.groupdict()["image_path"] + content = PlotSection(alt_text=img_title, path=img_path) + + yield SectionInfo( + return_key=return_key, + title=title, + content=content, + is_fig=is_fig, + level=level, + ) + + if val.subsections: + yield from iterate_key_section_content( + val.subsections, + parent_section=title, + parent_keys=parent_keys + [key], + level=level + 1, + ) From e084670cbaef0a9477a159bab9d23906ac33a784 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Mon, 27 Feb 2023 17:50:21 +0100 Subject: [PATCH 02/26] Explicitly set packages arg in setup Setup fails otherwise, as it thinks that spaces could also be part of the package. --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 658861c8..5f9dad53 100644 --- a/setup.py +++ b/setup.py @@ -79,6 +79,7 @@ def setup_package(): "tests": min_deps.tag_to_packages["tests"], }, include_package_data=True, + packages=["skops"], ) setup(**package_data, **metadata) From 8c5f070ff7a0709d3c81c3057ea350bbc937fc69 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Mon, 27 Feb 2023 18:07:02 +0100 Subject: [PATCH 03/26] Try setting HF hub token --- .github/workflows/build-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index baeb4eec..2ba47226 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -91,6 +91,8 @@ jobs: python -m pytest -s -v -m "inference" skops/ - name: Create skops space creator app + env: + HF_HUB_TOKEN: ${{ secrets.HF_HUB_TOKEN }} run: python spaces/deploy-skops-space-creator.py - name: Upload coverage to Codecov From f17780ca3048cc2d40113e9fd5df24bccd395674 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Tue, 28 Feb 2023 16:42:06 +0100 Subject: [PATCH 04/26] Try to fix missing token --- .github/workflows/build-test.yml | 2 +- spaces/deploy-skops-space-creator.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 2ba47226..1382ec16 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -92,7 +92,7 @@ jobs: - name: Create skops space creator app env: - HF_HUB_TOKEN: ${{ secrets.HF_HUB_TOKEN }} + SUPER_SECRET: ${{ secrets.HF_HUB_TOKEN }} run: python spaces/deploy-skops-space-creator.py - name: Upload coverage to Codecov diff --git a/spaces/deploy-skops-space-creator.py b/spaces/deploy-skops-space-creator.py index ad0b5f20..81dcce34 100644 --- a/spaces/deploy-skops-space-creator.py +++ b/spaces/deploy-skops-space-creator.py @@ -10,14 +10,14 @@ import skops token = os.environ["HF_HUB_TOKEN"] +client = HfApi() +user_name = client.whoami(token=token)["name"] repo_name = f"skops-space-creator-{uuid4()}" -user_name = HfApi().whoami(token=token)["name"] repo_id = f"{user_name}/{repo_name}" print(f"Creating and pushing to repo: {repo_id}") space_repo = Path(skops.__path__[0]).parent / "spaces" / "skops-space-creator" -client = HfApi() client.create_repo( repo_id=repo_id, token=token, From 1c81e37e82d149f0204394ae500e7adbc343594d Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Tue, 28 Feb 2023 16:47:21 +0100 Subject: [PATCH 05/26] Try to fix missing token #2 --- .github/workflows/build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 1382ec16..2ba47226 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -92,7 +92,7 @@ jobs: - name: Create skops space creator app env: - SUPER_SECRET: ${{ secrets.HF_HUB_TOKEN }} + HF_HUB_TOKEN: ${{ secrets.HF_HUB_TOKEN }} run: python spaces/deploy-skops-space-creator.py - name: Upload coverage to Codecov From 48c0a39f2f7bab40728bf9b2436e46e8368edb57 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Tue, 28 Feb 2023 16:52:28 +0100 Subject: [PATCH 06/26] Try to fix missing token attempt 3 --- spaces/deploy-skops-space-creator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spaces/deploy-skops-space-creator.py b/spaces/deploy-skops-space-creator.py index 81dcce34..114d52ba 100644 --- a/spaces/deploy-skops-space-creator.py +++ b/spaces/deploy-skops-space-creator.py @@ -10,7 +10,7 @@ import skops token = os.environ["HF_HUB_TOKEN"] -client = HfApi() +client = HfApi(token=token) user_name = client.whoami(token=token)["name"] repo_name = f"skops-space-creator-{uuid4()}" repo_id = f"{user_name}/{repo_name}" From 7157272d3a594d9735153a3ec65eb9452994ebd7 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Tue, 28 Feb 2023 17:54:05 +0100 Subject: [PATCH 07/26] Debugging --- spaces/deploy-skops-space-creator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spaces/deploy-skops-space-creator.py b/spaces/deploy-skops-space-creator.py index 114d52ba..6aeee05d 100644 --- a/spaces/deploy-skops-space-creator.py +++ b/spaces/deploy-skops-space-creator.py @@ -10,6 +10,9 @@ import skops token = os.environ["HF_HUB_TOKEN"] +print("*" * 20) +print(token) + client = HfApi(token=token) user_name = client.whoami(token=token)["name"] repo_name = f"skops-space-creator-{uuid4()}" From d71fda866e56bf20ab66580e60f075b031104286 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Tue, 28 Feb 2023 18:14:40 +0100 Subject: [PATCH 08/26] Try to fix missing token attempt 4 --- spaces/deploy-skops-space-creator.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spaces/deploy-skops-space-creator.py b/spaces/deploy-skops-space-creator.py index 6aeee05d..6110facc 100644 --- a/spaces/deploy-skops-space-creator.py +++ b/spaces/deploy-skops-space-creator.py @@ -1,16 +1,15 @@ # Deploy the app in skops_space_creator as a Hugging Face Space # requires the HF_HUB_TOKEN to be set as environment variable -import os from pathlib import Path from uuid import uuid4 from huggingface_hub import HfApi import skops +from skops.hub_utils.tests.common import HF_HUB_TOKEN -token = os.environ["HF_HUB_TOKEN"] -print("*" * 20) +token = HF_HUB_TOKEN print(token) client = HfApi(token=token) From 25c759128331b68a964e5a126775ef787bc24bfb Mon Sep 17 00:00:00 2001 From: adrinjalali Date: Fri, 3 Mar 2023 13:56:24 +0100 Subject: [PATCH 09/26] testing CI --- .github/workflows/build-test.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 2ba47226..46468356 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -70,6 +70,9 @@ jobs: pip list shell: bash + - name: Create skops space creator app + run: python spaces/deploy-skops-space-creator.py + - name: Check black run: black --check --diff . @@ -90,11 +93,6 @@ jobs: run: | python -m pytest -s -v -m "inference" skops/ - - name: Create skops space creator app - env: - HF_HUB_TOKEN: ${{ secrets.HF_HUB_TOKEN }} - run: python spaces/deploy-skops-space-creator.py - - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: From 2e3c55b78fd63b3495f097e3b1feaf6bd566ae33 Mon Sep 17 00:00:00 2001 From: adrinjalali Date: Fri, 3 Mar 2023 13:59:11 +0100 Subject: [PATCH 10/26] testing imports --- spaces/deploy-skops-space-creator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spaces/deploy-skops-space-creator.py b/spaces/deploy-skops-space-creator.py index 6110facc..6291f968 100644 --- a/spaces/deploy-skops-space-creator.py +++ b/spaces/deploy-skops-space-creator.py @@ -7,6 +7,8 @@ from huggingface_hub import HfApi import skops +import skops.hub_utils +import skops.hub_utils.tests from skops.hub_utils.tests.common import HF_HUB_TOKEN token = HF_HUB_TOKEN From 2bf54d33b07e62453c253dc2e0e901ff29b73d72 Mon Sep 17 00:00:00 2001 From: adrinjalali Date: Fri, 3 Mar 2023 14:06:41 +0100 Subject: [PATCH 11/26] use cd --- .github/workflows/build-test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 46468356..443e89a5 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -71,7 +71,9 @@ jobs: shell: bash - name: Create skops space creator app - run: python spaces/deploy-skops-space-creator.py + run: | + cd spaces + python spaces/deploy-skops-space-creator.py - name: Check black run: black --check --diff . From ff4c592dd18e318c15ccf6cc8a9cb18fb11194f3 Mon Sep 17 00:00:00 2001 From: adrinjalali Date: Fri, 3 Mar 2023 14:08:39 +0100 Subject: [PATCH 12/26] fix ci script --- .github/workflows/build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 443e89a5..26ea86d4 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -73,7 +73,7 @@ jobs: - name: Create skops space creator app run: | cd spaces - python spaces/deploy-skops-space-creator.py + python deploy-skops-space-creator.py - name: Check black run: black --check --diff . From b060f91072712adc69f2d14abfa78f3da29de659 Mon Sep 17 00:00:00 2001 From: adrinjalali Date: Fri, 3 Mar 2023 14:10:05 +0100 Subject: [PATCH 13/26] trigger ci From a9ebd9d44e4b0fc8f9b0b1fb65589b34c821f9b0 Mon Sep 17 00:00:00 2001 From: adrinjalali Date: Fri, 3 Mar 2023 14:32:40 +0100 Subject: [PATCH 14/26] -e --- .github/workflows/build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 26ea86d4..77f991e7 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -55,7 +55,7 @@ jobs: - name: Install dependencies run: | - pip install .[docs,tests] + pip install -e .[docs,tests] pip install black=="22.6.0" isort=="5.10.1" mypy=="0.981" pip uninstall --yes scikit-learn if [ ${{ matrix.sklearn_version }} == "nightly" ]; From 43442f39e65c148fe4f19f7e52c142fa63e207d5 Mon Sep 17 00:00:00 2001 From: adrinjalali Date: Fri, 3 Mar 2023 14:43:59 +0100 Subject: [PATCH 15/26] run script directly --- .github/workflows/build-test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index cd78be5f..0b4aade9 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -72,8 +72,7 @@ jobs: - name: Create skops space creator app run: | - cd spaces - python deploy-skops-space-creator.py + python spaces/deploy-skops-space-creator.py - name: Check black run: black --check --diff . From 8b1d28133f06a07a107d84ed2b4f904d5293c333 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Fri, 3 Mar 2023 15:02:09 +0100 Subject: [PATCH 16/26] Fix directory name --- spaces/deploy-skops-space-creator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spaces/deploy-skops-space-creator.py b/spaces/deploy-skops-space-creator.py index 6291f968..b1806660 100644 --- a/spaces/deploy-skops-space-creator.py +++ b/spaces/deploy-skops-space-creator.py @@ -20,7 +20,7 @@ repo_id = f"{user_name}/{repo_name}" print(f"Creating and pushing to repo: {repo_id}") -space_repo = Path(skops.__path__[0]).parent / "spaces" / "skops-space-creator" +space_repo = Path(skops.__path__[0]).parent / "spaces" / "skops_space_creator" client.create_repo( repo_id=repo_id, From c7d62c05668900440b4b8e7737da237177fa8132 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Fri, 3 Mar 2023 15:18:33 +0100 Subject: [PATCH 17/26] [WIP] Move space deployment to separate workflow Even though the install steps are shared, it doesn't make sense to put the space deployment job into the testing workflow, since the latter runs multiple times because of the test matrix. The secret for deploying on the HF scikit-learn orga is not set yet, so this is WIP. --- .github/workflows/build-test.yml | 6 +----- spaces/deploy-skops-space-creator.py | 9 +++++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 0b4aade9..f65e61bf 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -55,7 +55,7 @@ jobs: - name: Install dependencies run: | - pip install -e .[docs,tests] + pip install .[docs,tests] pip install black=="22.6.0" isort=="5.10.1" mypy=="1.0.0" pip uninstall --yes scikit-learn if [ ${{ matrix.sklearn_version }} == "nightly" ]; @@ -70,10 +70,6 @@ jobs: pip list shell: bash - - name: Create skops space creator app - run: | - python spaces/deploy-skops-space-creator.py - - name: Check black run: black --check --diff . diff --git a/spaces/deploy-skops-space-creator.py b/spaces/deploy-skops-space-creator.py index b1806660..1aa84b16 100644 --- a/spaces/deploy-skops-space-creator.py +++ b/spaces/deploy-skops-space-creator.py @@ -1,6 +1,7 @@ # Deploy the app in skops_space_creator as a Hugging Face Space # requires the HF_HUB_TOKEN to be set as environment variable +import os from pathlib import Path from uuid import uuid4 @@ -11,8 +12,12 @@ import skops.hub_utils.tests from skops.hub_utils.tests.common import HF_HUB_TOKEN -token = HF_HUB_TOKEN -print(token) +token = os.environ.get("HF_HUB_TOKEN_SKLEARN") +if token: + print("Deploying space to sklearn orga") +else: + print("Deploying space to skops CI") + token = HF_HUB_TOKEN client = HfApi(token=token) user_name = client.whoami(token=token)["name"] From f20c26b9760b07f716c3befc31fe7f718847a338 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Fri, 3 Mar 2023 15:24:46 +0100 Subject: [PATCH 18/26] Add the new workflow file Forgot to add it with last commit ... --- .github/workflows/deploy-space-creator.yml | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/deploy-space-creator.yml diff --git a/.github/workflows/deploy-space-creator.yml b/.github/workflows/deploy-space-creator.yml new file mode 100644 index 00000000..89f4053a --- /dev/null +++ b/.github/workflows/deploy-space-creator.yml @@ -0,0 +1,46 @@ +name: pytest + +on: + - push + - pull_request + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + deploy space creator: + runs-on: "ubuntu-latest" + if: "github.repository == 'skops-dev/skops'" + # Timeout: https://stackoverflow.com/a/59076067/4521646 + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + pip install -e .[docs,tests] + python --version + pip --version + pip list + shell: bash + + - name: Create test skops space creator app + # by default, deploy to skops CI + if: github.ref != 'refs/heads/main' + run: | + python spaces/deploy-skops-space-creator.py + + - name: Create main skops space creator app + # if HF_HUB_TOKEN_SKLEARN, use that instead of skops CI orga + if: github.ref == 'refs/heads/main' + env: + HF_HUB_TOKEN_SKLEARN: "this-is-just-a-test" + run: | + python spaces/deploy-skops-space-creator.py From 4bf0818ad0e4d8b6c650973bab0cc2148494d832 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Fri, 3 Mar 2023 15:26:08 +0100 Subject: [PATCH 19/26] Rename new workflow (was still pytest from c&p) --- .github/workflows/deploy-space-creator.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-space-creator.yml b/.github/workflows/deploy-space-creator.yml index 89f4053a..b0662eb2 100644 --- a/.github/workflows/deploy-space-creator.yml +++ b/.github/workflows/deploy-space-creator.yml @@ -1,4 +1,4 @@ -name: pytest +name: Deploy space creator on: - push From 65436743549c54a529f8a63fb0729e023269c3d7 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Fri, 3 Mar 2023 15:28:06 +0100 Subject: [PATCH 20/26] Fix invalid name of workflow --- .github/workflows/deploy-space-creator.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-space-creator.yml b/.github/workflows/deploy-space-creator.yml index b0662eb2..9668e22c 100644 --- a/.github/workflows/deploy-space-creator.yml +++ b/.github/workflows/deploy-space-creator.yml @@ -1,4 +1,4 @@ -name: Deploy space creator +name: Deploy-Space-Creator on: - push From bce819562cd7baf256d6c888466e64dea819b083 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Fri, 3 Mar 2023 15:29:34 +0100 Subject: [PATCH 21/26] Another invalid name... --- .github/workflows/deploy-space-creator.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-space-creator.yml b/.github/workflows/deploy-space-creator.yml index 9668e22c..79f71458 100644 --- a/.github/workflows/deploy-space-creator.yml +++ b/.github/workflows/deploy-space-creator.yml @@ -9,7 +9,7 @@ concurrency: cancel-in-progress: true jobs: - deploy space creator: + deploy-space-creator: runs-on: "ubuntu-latest" if: "github.repository == 'skops-dev/skops'" # Timeout: https://stackoverflow.com/a/59076067/4521646 From 860dd5a2174c7ff18dc3b81946cc53e2c09d0345 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Fri, 3 Mar 2023 15:43:05 +0100 Subject: [PATCH 22/26] Set correct secret for deploying to HF sklearn org --- .github/workflows/deploy-space-creator.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-space-creator.yml b/.github/workflows/deploy-space-creator.yml index 79f71458..2ab6e3f7 100644 --- a/.github/workflows/deploy-space-creator.yml +++ b/.github/workflows/deploy-space-creator.yml @@ -41,6 +41,6 @@ jobs: # if HF_HUB_TOKEN_SKLEARN, use that instead of skops CI orga if: github.ref == 'refs/heads/main' env: - HF_HUB_TOKEN_SKLEARN: "this-is-just-a-test" + HF_HUB_TOKEN_SKLEARN: ${{ secrets.HF_HUB_TOKEN_SKLEARN }} run: | python spaces/deploy-skops-space-creator.py From 84a8eb0db506b6e19bf6bb14f5511483bdb0ef35 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Fri, 3 Mar 2023 15:54:53 +0100 Subject: [PATCH 23/26] Add missing cat.png --- spaces/skops_space_creator/cat.png | Bin 0 -> 78514 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 spaces/skops_space_creator/cat.png diff --git a/spaces/skops_space_creator/cat.png b/spaces/skops_space_creator/cat.png new file mode 100644 index 0000000000000000000000000000000000000000..a538c784af037cc488784f3cc7eb54641bf6a477 GIT binary patch literal 78514 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�mi)2*Qhm4I()VxQ_5NW+Ez_d#)Qpza^aMgE;HaJAa<#xX*G`QH<_gk zZ8d3=F5@)XrPW%4!2Hr6l%aRU1wZ z50)?e6}q#d%e+k*6G3glp>FEx_41d0^X+o)!;h9BJaY<1z^$WkLZB}0!(T}y5^NqP zSC=?UUqr$|J-O`LG151ObzTM3&*7KREgq>LA(pmfjBh1DLE_OJsjAKFps>oEcNi<3 zcTZJn{On8p_hdEzk zupkN087Z*VYgRQ zV}r62pD0<#HD+?0mXf1&N)%T4fuu)2-OC9sOq0i=g`WqI#Y}armOc-O>3K6+Wapah zv13+Np#}maY+4_7&Fcl!?5VK=a8=|?quc3D36g|um|JV`-cq*EKCCeeSYyQ`!0gF)C#i?v;B2ilJgAfgL` z7SCD2mfU$1MsD4ETls(f`fKIVG|JcGpi?qvD+Itv9G3ZCzx(xaYh;^KX;zww(^gNIWR-QApG%nU6(n9yV}7?J;EYF!${>1;<0sY0 z*Z5oO3%W^xSTUH08h?o#lg;ep*Acu}L|&t)p4XLC7#kF7_@0U4Y9CSHn1U(S=KCy& zg;v}2F+mY?I%$Gu5Sc47Q;?W6D^H=46dIf{{02hLgFZFm_;Dqw88HQkmtM(^xU!a9 zX}kIhPK>HGT5!fXG?qShG(u*gi^XJ0ok7XP8QA()(W_|zAGhP^gGC&!vkD1FzoW61 z%u>5Pz97r7tNyw~8<|#G$85d61nG)y=R0wH+qd6%5#dib@G3Wl6N}$}^(X&#dEvsF zZ%tbV3>w@EMD6m-sLciFTK8t-P z2M-=9HzB_N^ttbNZ)nC9;H!M$FaEO3Lcl-yq4$^V7<&T8F-)SyOIo+m_9*}w%vIsO zA>-^j0xz~SC|RpW6$MQ65Ss-IPowMP6$qad2%pMc5FTv4RNJrxuQa>)Os>~NokG;K zTNysNHZj>2`?8e5)p7sypl$h#5IsoI^?UlR#Y8~;%n;p)O;dyP~YVhnH&mK2=RKf z7Mj|H_b^j^4g(yAbi6{sqaBS#ZgY^tl*%$26{ zq1c(Tm&={Ib|Lq0x*WfHju2KMhr$ zkqoke#%k@5#{99ChdD@w#B)ZGvv~l~uSIlG{4d}5OZZnf>R@WI{F^`c&!u_KNO}AN zkCm}zgswrb=h19>o%`j5D^a!I#jE|dKxUvg@#afxayH_hn$vsqqeDEtUjiI2K#QEf zM^oxQY#c;RE~z`78$gr94m#r6#T*uxo^3eWnn+w10@WJH!Z2}=*Zc39E+sT$=~gJ| zx1}Q=*J?yRJ5OGVX?Q?RBQxfTQb}SuIhMA*B!V2!D0~a>dI`b*349^90kpx@LVDCQ zX)&Q(rPZqOZ1Y=28BpZ#td6yovR%n%hq{PtJ= zW7&pNn11d(PnCNHhD)b?SBKRxziV*D7iOo+f!(`IMEG)*jEjGr-M#LX60lurf{KvWGG?C{)?rn zB{qM|D0a^CZ@%TV87`Ex!acLt+_W@3Rqoz@ds!Q7E&t~?zf$@S?=L_5@t-Ll7~5ZZ zBiWcH044BSr_X}mTg&JO+JHu8ehq;GcGY0+L+yV!I5@te7iYS6V(&L5vZs5fJa+d( z1s&tc=fCjp%kO^Wi)Am`nm%#lePsvETBIF)<;Hlqc=~ijL0W;8A8-8$O*=l@{TO=tMoenSwN=0^m$Cs z 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
    ?57Rr?JKP1 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 YKOAq)y>-$vs{jB107*qoM6N<$f}p%m*8l(j literal 0 HcmV?d00001 From 570332ccb1fea5620f063e131243193b927ac44c Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Tue, 4 Apr 2023 14:57:37 +0200 Subject: [PATCH 24/26] Update to use latest model card changes Most notably, the new card class stores the specific section types like PlotSection in its data. This allows us to more directly modify those sections, e.g. when a user updates a plot file. Also use the new get_toc method. Another change was to add a specific task for updating the title of a plot, instead of having a big task both for title and content with many if...else. This is cleaner. --- spaces/skops_space_creator/edit.py | 86 +++++++++++++---------------- spaces/skops_space_creator/tasks.py | 58 ++++++++++++------- spaces/skops_space_creator/utils.py | 42 +++----------- 3 files changed, 84 insertions(+), 102 deletions(-) diff --git a/spaces/skops_space_creator/edit.py b/spaces/skops_space_creator/edit.py index 4447020c..6d0f5bb7 100644 --- a/spaces/skops_space_creator/edit.py +++ b/spaces/skops_space_creator/edit.py @@ -39,6 +39,7 @@ DeleteSectionTask, TaskState, UpdateFigureTask, + UpdateFigureTitleTask, UpdateSectionTask, ) from utils import ( @@ -67,7 +68,6 @@ def _update_model_card( key: str, section_name: str, content: str, - is_fig: bool, ) -> None: # This is a very roundabout way to update the model card but it's necessary # because of how streamlit handles session state. Basically, there have to @@ -85,34 +85,13 @@ def _update_model_card( is_title_same = old_title_split == new_title_split # determine if content is the same - if is_fig: - if isinstance(new_content, PlotSection): - is_content_same = content == new_content - else: - is_content_same = not bool(new_content) - else: - is_content_same = content == new_content - + is_content_same = (content == new_content) or (not content and not new_content) if is_title_same and is_content_same: return - if is_fig: - old_path, fpath = None, None - if new_content: # new figure uploaded - fname = new_content.name.replace(" ", "_") - fpath = st.session_state.hf_path / fname - old_path = fpath.parent / model_card.select(key).content.path - - task = UpdateFigureTask( - model_card, - key=key, - old_name=section_name, - new_name=new_title, - data=new_content, - new_path=fpath, - old_path=old_path, - ) - else: + section = model_card.select(key) + if not isinstance(section, PlotSection): + # a normal section task = UpdateSectionTask( model_card, key=key, @@ -121,6 +100,25 @@ def _update_model_card( old_content=content, new_content=new_content, ) + else: + # a plot sectoin + if not new_content: # only title changed + task = UpdateFigureTitleTask( + model_card, key=key, old_name=section_name, new_name=new_title + ) + else: # new figure uploaded + fname = new_content.name.replace(" ", "_") + fpath = st.session_state.hf_path / fname + old_path = fpath.parent / Path(section.path).name + task = UpdateFigureTask( + model_card, + key=key, + old_name=section_name, + new_name=new_title, + data=new_content, + new_path=fpath, + old_path=old_path, + ) st.session_state.task_state.add(task) @@ -154,11 +152,10 @@ def _add_section_form( # setting the 'key' argument below to update the session_state st.text_input("Section name", value=old_title, key=f"{key}.title") st.text_area("Content", value=content, key=f"{key}.content") - is_fig = False st.form_submit_button( "Update", on_click=_update_model_card, - args=(model_card, key, section_name, content, is_fig), + args=(model_card, key, section_name, content), ) @@ -170,11 +167,10 @@ def _add_fig_form( # setting the 'key' argument below to update the session_state st.text_input("Section name", value=old_title, key=f"{key}.title") st.file_uploader("Upload image", key=f"{key}.content") - is_fig = True st.form_submit_button( "Update", on_click=_update_model_card, - args=(model_card, key, section_name, content, is_fig), + args=(model_card, key, section_name, content), ) @@ -182,12 +178,14 @@ def create_form_from_section( model_card: card.Card, key: str, section_name: str, - content: str, - is_fig: bool = False, ) -> None: + # Code for creating a single section, plot or text + section = model_card.select(key) + content = section.content split_sections = split_subsection_names(section_name) old_title = split_sections[-1] - if is_fig: + + if isinstance(section, PlotSection): _add_fig_form( model_card=model_card, key=key, @@ -195,6 +193,7 @@ def create_form_from_section( old_title=old_title, content=content, ) + path = st.session_state.hf_path / Path(section.path).name else: _add_section_form( model_card=model_card, @@ -203,10 +202,10 @@ def create_form_from_section( old_title=old_title, content=content, ) + path = None col_0, col_1, col_2 = st.columns([4, 2, 2]) with col_0: - path = st.session_state.hf_path / content.path if is_fig else None st.button( f"Delete '{arepr.repr(old_title)}'", on_click=_delete_section, @@ -233,23 +232,14 @@ def create_form_from_section( def display_sections(model_card: card.Card) -> None: - for section_info in iterate_key_section_content(model_card._data): - create_form_from_section( - model_card, - key=section_info.return_key, - section_name=section_info.title, - content=section_info.content, - is_fig=section_info.is_fig, - ) + # display all sections, looping through them recursively + for key, title in iterate_key_section_content(model_card._data): + create_form_from_section(model_card, key=key, section_name=title) def display_toc(model_card: card.Card) -> None: - elements = [] - for section_info in iterate_key_section_content(model_card._data): - title, level = section_info.title, section_info.level - section_name = split_subsection_names(title)[-1] - elements.append(" " * level + "- " + section_name) - st.markdown("\n".join(elements)) + toc = model_card.get_toc() + st.markdown(toc) def display_model_card(model_card: card.Card) -> None: diff --git a/spaces/skops_space_creator/tasks.py b/spaces/skops_space_creator/tasks.py index 253e9f97..b6238d0c 100644 --- a/spaces/skops_space_creator/tasks.py +++ b/spaces/skops_space_creator/tasks.py @@ -115,7 +115,6 @@ def do(self) -> None: self.model_card.add_plot(**{self.key: self.content}) section = self.model_card.select(self.key) section.title = split_subsection_names(self.title)[-1] - section.is_fig = True # type: ignore def undo(self) -> None: self.content.unlink(missing_ok=True) @@ -185,6 +184,36 @@ def undo(self) -> None: section.content = self.old_content +class UpdateFigureTitleTask(Task): + """Change the title a plot section + + Changing the title is easy, just replace it and be done. + + """ + + def __init__( + self, + model_card: card.Card, + key: str, + old_name: str, + new_name: str, + ) -> None: + self.model_card = model_card + self.key = key + self.old_name = old_name + self.new_name = new_name + + def do(self) -> None: + section = self.model_card.select(self.key) + new_title = split_subsection_names(self.new_name)[-1] + section.title = self.title = new_title + + def undo(self) -> None: + section = self.model_card.select(self.key) + old_title = split_subsection_names(self.old_name)[-1] + section.title = old_title + + class UpdateFigureTask(Task): """Change the title or image of a figure section @@ -207,31 +236,25 @@ def __init__( key: str, old_name: str, new_name: str, - data: UploadedFile | None, - new_path: Path | None, - old_path: Path | None, + data: UploadedFile, + new_path: Path, + old_path: Path, ) -> None: self.model_card = model_card self.key = key self.old_name = old_name self.new_name = new_name - self.old_data = self.model_card.select(self.key).content self.new_path = new_path self.old_path = old_path + self.new_data = data # when 'deleting' the old image, move to temp path self.tmp_path = Path(mkdtemp(prefix="skops-")) / str(uuid4()) - if not data: - self.new_data = self.old_data - else: - self.new_data = data - def do(self) -> None: section = self.model_card.select(self.key) + assert isinstance(section, PlotSection), "has to be a PlotSection" new_title = split_subsection_names(self.new_name)[-1] section.title = self.title = new_title - if self.new_data == self.old_data: # image is same - return # write figure # note: this can still be the same image if the image is a file, there @@ -240,21 +263,18 @@ def do(self) -> None: with open(self.new_path, "wb") as f: f.write(self.new_data.getvalue()) - section.content = PlotSection( - alt_text=self.new_data.name, - path=self.new_path, - ) + + section.path = self.new_path def undo(self) -> None: section = self.model_card.select(self.key) + assert isinstance(section, PlotSection), "has to be a PlotSection" old_title = split_subsection_names(self.old_name)[-1] section.title = old_title - if self.new_data == self.old_data: # image is same - return self.new_path.unlink(missing_ok=True) shutil.move(self.tmp_path, self.old_path) - section.content = self.old_data + section.path = self.old_path class AddMetricsTask(Task): diff --git a/spaces/skops_space_creator/utils.py b/spaces/skops_space_creator/utils.py index 08094ea8..1b4c1d3e 100644 --- a/spaces/skops_space_creator/utils.py +++ b/spaces/skops_space_creator/utils.py @@ -5,11 +5,11 @@ import base64 import os import re -from dataclasses import dataclass from pathlib import Path +from typing import Iterator from skops import card -from skops.card._model_card import PlotSection, Section +from skops.card._model_card import Section PAT_MD_IMG = re.compile( r'(!\[(?P[^\]]+)\]\((?P[^\)"\s]+)\s*([^\)]*)\))' @@ -80,56 +80,28 @@ def markdown_insert_images(markdown): return metadata, rendered_with_img -@dataclass(frozen=True) -class SectionInfo: - return_key: str - title: str - content: str - is_fig: bool - level: int - - def iterate_key_section_content( data: dict[str, Section], parent_section: str = "", parent_keys: list[str] | None = None, - level: int = 0, -) -> SectionInfo: +) -> Iterator[tuple[str, str]]: parent_keys = parent_keys or [] for key, val in data.items(): + if not val.visible: + continue + if parent_section: title = "/".join((parent_section, val.title)) else: title = val.title - if not getattr(val, "visible", True): - continue - return_key = key if not parent_keys else "/".join(parent_keys + [key]) - content = val.content - - is_fig = getattr(val, "is_fig", False) - if isinstance(val.content, str): - img_match = PAT_MD_IMG.match(val.content) - if img_match: # image section found in parsed model card - is_fig = True - img_title = img_match.groupdict()["image_title"] - img_path = img_match.groupdict()["image_path"] - content = PlotSection(alt_text=img_title, path=img_path) - - yield SectionInfo( - return_key=return_key, - title=title, - content=content, - is_fig=is_fig, - level=level, - ) + yield return_key, title if val.subsections: yield from iterate_key_section_content( val.subsections, parent_section=title, parent_keys=parent_keys + [key], - level=level + 1, ) From caca0e6b5d2ec80cb357227c69b1a1a13170ed01 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Tue, 4 Apr 2023 15:17:31 +0200 Subject: [PATCH 25/26] Rename: space creator > model card creator --- pyproject.toml | 2 +- ...reator.py => deploy-skops-model-card-creator.py} | 10 +++++----- .../README.md | 0 .../__init__.py | 0 .../app.py | 0 .../cat.png | Bin .../create.py | 0 .../edit.py | 0 .../gethelp.py | 0 .../make-data.py | 0 .../packages.txt | 0 .../requirements.txt | 0 .../start.py | 0 .../tasks.py | 0 .../utils.py | 0 15 files changed, 6 insertions(+), 6 deletions(-) rename spaces/{deploy-skops-space-creator.py => deploy-skops-model-card-creator.py} (79%) rename spaces/{skops_space_creator => skops_model_card_creator}/README.md (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/__init__.py (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/app.py (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/cat.png (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/create.py (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/edit.py (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/gethelp.py (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/make-data.py (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/packages.txt (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/requirements.txt (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/start.py (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/tasks.py (100%) rename spaces/{skops_space_creator => skops_model_card_creator}/utils.py (100%) diff --git a/pyproject.toml b/pyproject.toml index 888f2ad8..18cde979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,6 @@ omit = [ ] [tool.mypy] -exclude = "(\\w+/)*test_\\w+\\.py$|spaces/skops_space_creator" +exclude = "(\\w+/)*test_\\w+\\.py$|spaces/skops_model_card_creator" ignore_missing_imports = true no_implicit_optional = true diff --git a/spaces/deploy-skops-space-creator.py b/spaces/deploy-skops-model-card-creator.py similarity index 79% rename from spaces/deploy-skops-space-creator.py rename to spaces/deploy-skops-model-card-creator.py index 1aa84b16..507c790a 100644 --- a/spaces/deploy-skops-space-creator.py +++ b/spaces/deploy-skops-model-card-creator.py @@ -1,5 +1,5 @@ -# Deploy the app in skops_space_creator as a Hugging Face Space -# requires the HF_HUB_TOKEN to be set as environment variable +# Deploying the app in skops_model_card_creator as a Hugging Face Space requires +# the HF_HUB_TOKEN to be set as environment variable import os from pathlib import Path @@ -21,11 +21,11 @@ client = HfApi(token=token) user_name = client.whoami(token=token)["name"] -repo_name = f"skops-space-creator-{uuid4()}" +repo_name = f"skops-model-card-creator-{uuid4()}" repo_id = f"{user_name}/{repo_name}" print(f"Creating and pushing to repo: {repo_id}") -space_repo = Path(skops.__path__[0]).parent / "spaces" / "skops_space_creator" +space_repo = Path(skops.__path__[0]).parent / "spaces" / "skops_model_card_creator" client.create_repo( repo_id=repo_id, @@ -38,7 +38,7 @@ repo_id=repo_id, path_in_repo=".", folder_path=space_repo, - commit_message="Create skops-space-creator space", + commit_message="Create skops-model-card-creator space", token=token, repo_type="space", create_pr=False, diff --git a/spaces/skops_space_creator/README.md b/spaces/skops_model_card_creator/README.md similarity index 100% rename from spaces/skops_space_creator/README.md rename to spaces/skops_model_card_creator/README.md diff --git a/spaces/skops_space_creator/__init__.py b/spaces/skops_model_card_creator/__init__.py similarity index 100% rename from spaces/skops_space_creator/__init__.py rename to spaces/skops_model_card_creator/__init__.py diff --git a/spaces/skops_space_creator/app.py b/spaces/skops_model_card_creator/app.py similarity index 100% rename from spaces/skops_space_creator/app.py rename to spaces/skops_model_card_creator/app.py diff --git a/spaces/skops_space_creator/cat.png b/spaces/skops_model_card_creator/cat.png similarity index 100% rename from spaces/skops_space_creator/cat.png rename to spaces/skops_model_card_creator/cat.png diff --git a/spaces/skops_space_creator/create.py b/spaces/skops_model_card_creator/create.py similarity index 100% rename from spaces/skops_space_creator/create.py rename to spaces/skops_model_card_creator/create.py diff --git a/spaces/skops_space_creator/edit.py b/spaces/skops_model_card_creator/edit.py similarity index 100% rename from spaces/skops_space_creator/edit.py rename to spaces/skops_model_card_creator/edit.py diff --git a/spaces/skops_space_creator/gethelp.py b/spaces/skops_model_card_creator/gethelp.py similarity index 100% rename from spaces/skops_space_creator/gethelp.py rename to spaces/skops_model_card_creator/gethelp.py diff --git a/spaces/skops_space_creator/make-data.py b/spaces/skops_model_card_creator/make-data.py similarity index 100% rename from spaces/skops_space_creator/make-data.py rename to spaces/skops_model_card_creator/make-data.py diff --git a/spaces/skops_space_creator/packages.txt b/spaces/skops_model_card_creator/packages.txt similarity index 100% rename from spaces/skops_space_creator/packages.txt rename to spaces/skops_model_card_creator/packages.txt diff --git a/spaces/skops_space_creator/requirements.txt b/spaces/skops_model_card_creator/requirements.txt similarity index 100% rename from spaces/skops_space_creator/requirements.txt rename to spaces/skops_model_card_creator/requirements.txt diff --git a/spaces/skops_space_creator/start.py b/spaces/skops_model_card_creator/start.py similarity index 100% rename from spaces/skops_space_creator/start.py rename to spaces/skops_model_card_creator/start.py diff --git a/spaces/skops_space_creator/tasks.py b/spaces/skops_model_card_creator/tasks.py similarity index 100% rename from spaces/skops_space_creator/tasks.py rename to spaces/skops_model_card_creator/tasks.py diff --git a/spaces/skops_space_creator/utils.py b/spaces/skops_model_card_creator/utils.py similarity index 100% rename from spaces/skops_space_creator/utils.py rename to spaces/skops_model_card_creator/utils.py From bb7ffbdea62cbfdb85cbc4afce2f97723cb597f4 Mon Sep 17 00:00:00 2001 From: Benjamin Bossan Date: Tue, 4 Apr 2023 15:26:43 +0200 Subject: [PATCH 26/26] Forgot to rename in GH workflow --- ...deploy-space-creator.yml => deploy-model-card-creator.yml} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename .github/workflows/{deploy-space-creator.yml => deploy-model-card-creator.yml} (90%) diff --git a/.github/workflows/deploy-space-creator.yml b/.github/workflows/deploy-model-card-creator.yml similarity index 90% rename from .github/workflows/deploy-space-creator.yml rename to .github/workflows/deploy-model-card-creator.yml index 2ab6e3f7..93cddc9f 100644 --- a/.github/workflows/deploy-space-creator.yml +++ b/.github/workflows/deploy-model-card-creator.yml @@ -35,7 +35,7 @@ jobs: # by default, deploy to skops CI if: github.ref != 'refs/heads/main' run: | - python spaces/deploy-skops-space-creator.py + python spaces/deploy-skops-model-card-creator.py - name: Create main skops space creator app # if HF_HUB_TOKEN_SKLEARN, use that instead of skops CI orga @@ -43,4 +43,4 @@ jobs: env: HF_HUB_TOKEN_SKLEARN: ${{ secrets.HF_HUB_TOKEN_SKLEARN }} run: | - python spaces/deploy-skops-space-creator.py + python spaces/deploy-skops-model-card-creator.py