diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..f9ecf57 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,7 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/check-typos.yaml b/.github/workflows/check-typos.yaml new file mode 100644 index 0000000..edd2e6d --- /dev/null +++ b/.github/workflows/check-typos.yaml @@ -0,0 +1,20 @@ +name: Check spelling typos + +on: + workflow_dispatch: + pull_request: + branches: + - main + +jobs: + + run-typos: + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Check spelling + uses: crate-ci/typos@master + with: + files: . diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml new file mode 100644 index 0000000..d246c38 --- /dev/null +++ b/.github/workflows/deploy-docs.yaml @@ -0,0 +1,36 @@ +name: Deploy Documentation + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@v4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + restore-keys: | + mkdocs-material- + + - name: Install dependencies and deploy + run: | + uv install mkdocs-material --system + mkdocs gh-deploy --force diff --git a/.github/workflows/pre-commit-update.yaml b/.github/workflows/pre-commit-update.yaml new file mode 100644 index 0000000..c12e8c8 --- /dev/null +++ b/.github/workflows/pre-commit-update.yaml @@ -0,0 +1,34 @@ +name: Pre-commit auto-update + +on: + workflow_dispatch: + schedule: + - cron: "0 0 1 * *" # Every 1st of the month at 00:00 UTC + +permissions: write-all + +jobs: + auto-update: + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: pre-commit install autoupdate + run: | + pip install pre-commit + pre-commit autoupdate + + - name: Commit and push changes + uses: peter-evans/create-pull-request@v6 + with: + branch: update-pre-commit-hooks + title: 'Update pre-commit hooks' + commit-message: 'Update pre-commit hooks' + body: | + Update versions of pre-commit hooks to latest versions. diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml new file mode 100644 index 0000000..cf6a671 --- /dev/null +++ b/.github/workflows/pull-request.yaml @@ -0,0 +1,65 @@ +name: PR Checks + +on: + pull_request: + branches: + - main + +jobs: + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Install & run linter + run: | + uv pip install ruff --system + make lint + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12"] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Install dependencies and run tests + run: | + uv pip install -r requirements.txt --system + uv pip install pytest pytest-cov pytest-xdist --system + make test-cov + - name: Install and run mypy + run: | + uv pip install mypy --system + mypy sksmithy tests + + + doc-build: + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Install dependencies and check docs can build + run: | + uv pip install mkdocs-material --system + mkdocs build -v -s diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cdfb8be --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: requirements-txt-fixer + - id: check-json + - id: check-yaml + - id: check-ast + - id: check-added-large-files +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.7 + hooks: + - id: ruff-format + args: [sksmithy, tests] + - id: ruff + args: [--fix, sksmithy, tests] +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + args: [sksmithy, tests] +- repo: https://github.com/Lucas-C/pre-commit-hooks-bandit + rev: v1.0.6 + hooks: + - id: python-bandit-vulnerability-check + args: [--skip, "B101",--severity-level, medium, --recursive, sksmithy] +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-no-eval +- repo: https://github.com/crate-ci/typos + rev: v1.21.0 + hooks: + - id: typos diff --git a/README.md b/README.md index 4230514..49ce637 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,30 @@ # Scikit-learn Smithy -Scikit-learn smithy is a tool that helps you to forge scikit-learn compatible estimator templates with ease. +Scikit-learn smithy is a tool that helps you to forge scikit-learn compatible estimator with ease. + +--- + +[Documentation](https://fbruzzesi.github.io/sklearn-smithy) | [Repository](https://github.com/fbruzzesi/sklearn-smithy) | [Issue Tracker](https://github.com/fbruzzesi/sklearn-smithy/issues) + +--- How can you use it? -- โœ… From a [web UI](https://sklearn-smithy.streamlit.app/) powered by [streamlit](https://streamlit.io/). -- โœ… As a CLI (command line interface): `smith forge` command (see [installation](#installation) and [commands](#available-cli-commands)). -- ๐Ÿšง As a TUI (terminal user interface): We are not there yet! +โœ… Directly from the web: we have a [web UI](https://sklearn-smithy.streamlit.app/) powered by [streamlit](https://streamlit.io/). +โœ… As a CLI (command line interface) in your terminal (requires [installation](#installation)) powered by [typer](https://typer.tiangolo.com/): + + ```terminal + smith forge + ``` + +๐Ÿšง As a TUI (terminal user interface): Working in progress! + +All these tools will prompt a series of questions regarding the estimator you want to create, and then it will generate the boilerplate code for you. ## Why โ“ -Writing a scikit-learn compatible estimators might be harder than expected. +Writing scikit-learn compatible estimators might be harder than expected. While everyone knows about the `fit` and `predict`, there are other behaviours, methods and attributes that scikit-learn might be expecting from your estimator depending on: @@ -27,93 +40,61 @@ questions about it, and then generating the boilerplate code. In this way you will be able to fully focus on the core implementation logic, and not on nitty-gritty details of the scikit-learn API. -Once the core logic is implemented, the estimator should be ready to test against the _somewhat official_ [`parametrize_with_checks`](https://scikit-learn.org/dev/modules/generated/sklearn.utils.estimator_checks.parametrize_with_checks.html#sklearn.utils.estimator_checks.parametrize_with_checks) pytest compatible decorator: +### Sanity check + +Once the core logic is implemented, the estimator should be ready to test against the _somewhat official_ +[`parametrize_with_checks`](https://scikit-learn.org/dev/modules/generated/sklearn.utils.estimator_checks.parametrize_with_checks.html#sklearn.utils.estimator_checks.parametrize_with_checks) +pytest compatible decorator: ```py from sklearn.utils.estimator_checks import parametrize_with_checks -@parametrize_with_checks([YourAwesomeRegressor, MoreAwesomeClassifier, EvenMoreAwesomeTransformer]) +@parametrize_with_checks([ + YourAwesomeRegressor, + MoreAwesomeClassifier, + EvenMoreAwesomeTransformer, +]) def test_sklearn_compatible_estimator(estimator, check): check(estimator) ``` -## Installation - -To use the tool from the terminal, we suggest to install it directly from pypi: +and it should be compatible with scikit-learn Pipeline, GridSearchCV, etc. -```bash -python -m pip install sklearn-smithy -``` +### Official guide -This will make the `smith` command available in your terminal. +Scikit-learn documentation on how to +[develop estimators](https://scikit-learn.org/dev/developers/develop.html#developing-scikit-learn-estimators). -## Available CLI commands +## Installation -The `smith` entrypoint offers two commands: +sklearn-smithy is available on [pypi](https://pypi.org/project/sklearn-smithy), so you can install it directly from there: ```bash -smith --help +python -m pip install sklearn-smithy ``` -```terminal -Usage: smith [OPTIONS] COMMAND [ARGS]... - -CLI to generate scikit-learn estimator boilerplate code - -... - -โ•ญโ”€ Commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ forge Generate a new shiny scikit-learn compatible estimator โœจ โ”‚ -โ”‚ version Display library version. โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -``` +**Remark:** The minimum Python version supported is 3.10. -and as you can already guess, the `forge` command is the one that will generate the boilerplate code for you. +This will make the `smith` command available in your terminal, and you should be able to run the following: ```bash -smith forge --help +smith version ``` -```terminal -Generate a new shiny scikit-learn compatible estimator โœจ +> sklearn-smithy=... -Depending on the estimator type the following additional information could be required: +## User guide ๐Ÿ“š -* if the estimator is linear (classifier or regression) -* if the estimator implements `.predict_proba()` method (classifier or outlier detector) -* if the estimator implements `.decision_function()` method (classifier only) - -Finally, the following two questions will be prompt: - -* if the estimator should have tags (To know more about tags, check the dedicated scikit-learn documentation - at https://scikit-learn.org/dev/developers/develop.html#estimator-tags) -* in which file the class should be saved (default is `f'{name.lower()}.py'`) - - -โ•ญโ”€ Options โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ * --name TEXT Name of the estimator [default: None] [required] โ”‚ -โ”‚ * --estimator-type [classifier|outlier|regressor|transformer|cluster] Estimator type [default: None] [required] โ”‚ -โ”‚ --required-params TEXT List of (comma-separated) required parameters โ”‚ -โ”‚ --optional-params TEXT List of (comma-separated) optional parameters โ”‚ -โ”‚ --sample-weight --no-sample-weight Whether or not `.fit()` supports `sample_weight` [default: no-sample-weight] โ”‚ -โ”‚ --linear --no-linear Whether or not the estimator is linear [default: no-linear] โ”‚ -โ”‚ --predict-proba --no-predict-proba Whether or not the estimator implements `predict_proba` method [default: no-predict-proba] โ”‚ -โ”‚ --decision-function --no-decision-function Whether or not the estimator implements `decision_function` method โ”‚ -โ”‚ [default: no-decision-function] โ”‚ -โ”‚ --tags TEXT List of optional extra scikit-learn tags โ”‚ -โ”‚ --output-file TEXT Destination file where to save the boilerplate code โ”‚ -โ”‚ --help Show this message and exit. โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -``` +Please refer to the dedicated [user guide](https://fbruzzesi.github.io/sklearn-smithy/user-guide/) documentation section. ## Origin story -The idea for this tool originated from [scikit-lego #660](https://github.com/koaning/scikit-lego/pull/660), which I cannot better explain than quoting the PR description: +The idea for this tool originated from [scikit-lego #660](https://github.com/koaning/scikit-lego/pull/660), which I cannot better explain than quoting the PR description itself: > So the story goes as the following: > > - The CI/CD fails for scikit-learn==1.5rc1 because of a change in the `check_estimator` internals > - In the [scikit-learn issue](https://github.com/scikit-learn/scikit-learn/issues/28966) I got a better picture of how to run test for compatible components -> - In particular, in [rolling your own estimator](https://scikit-learn.org/dev/developers/develop.html#rolling-your-own-estimator) suggests to use [`parametrize_with_checks`](https://scikit-learn.org/dev/modules/generated/sklearn.utils.estimator_checks.parametrize_with_checks.html#sklearn.utils.estimator_checks.parametrize_with_checks), and of course I thought "that is a great idea to avoid dealing manually with each test" +> - In particular, [rolling your own estimator](https://scikit-learn.org/dev/developers/develop.html#rolling-your-own-estimator) suggests to use [`parametrize_with_checks`](https://scikit-learn.org/dev/modules/generated/sklearn.utils.estimator_checks.parametrize_with_checks.html#sklearn.utils.estimator_checks.parametrize_with_checks), and of course I thought "that is a great idea to avoid dealing manually with each test" > - Say no more, I enter a rabbit hole to refactor all our tests - which would be fine > - Except that these tests failures helped me figure out a few missing parts in the codebase diff --git a/docs/contribute.md b/docs/contribute.md new file mode 100644 index 0000000..736af4d --- /dev/null +++ b/docs/contribute.md @@ -0,0 +1,82 @@ +# Contributing ๐Ÿ‘ + +## Guidelines ๐Ÿ’ก + +We welcome contributions to the library! If you have a bug fix or new feature that you would like to contribute, please follow the steps below: + +1. Check the [existing issues](https://github.com/FBruzzesi/sklearn-smithy/issues){:target="_blank"} and/or [open a new one](https://github.com/FBruzzesi/sklearn-smithy/issues/new){:target="_blank"} to discuss the problem and potential solutions. +2. [Fork the repository](https://github.com/FBruzzesi/sklearn-smithy/fork){:target="_blank"} on GitHub. +3. [Clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository){:target="_blank"} to your local machine. +4. Create a new branch for your bug fix or feature. +5. Make your changes and test them thoroughly, making sure that it passes all current tests. +6. Commit your changes and push the branch to your fork. +7. [Open a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request){:target="_blank"} on the main repository. + +## Submitting Pull Requests ๐ŸŽฏ + +When submitting a pull request, please make sure that you've followed the steps above and that your code has been thoroughly tested. Also, be sure to include a brief summary of the changes you've made and a reference to any issues that your pull request resolves. + +## Code formatting ๐Ÿš€ + +**sklearn-smithy** uses [ruff](https://docs.astral.sh/ruff/){:target="_blank"} for both formatting and linting. Specific settings are declared in the pyproject.toml file. + +To format the code, you can run the following commands: + +=== "with Make" + + ```bash + make lint + ``` + +=== "without Make" + + ```bash + ruff version + ruff format smithy tests + ruff check smithy tests --fix + ruff clean + ``` + +As part of the checks on pull requests, it is checked whether the code follows those standards. To ensure that the standard is met, it is recommended to install [pre-commit hooks](https://pre-commit.com/){:target="_blank"}: + +```bash +python -m pip install pre-commit +pre-commit install +``` + +## Developing ๐Ÿ + +Let's suppose that you already did steps 1-4 from the above list, now you should install the library and its developing dependencies in editable way. + +```bash +cd sklearn-smithy +pip install -e ".[streamlit]" --no-cache-dir +pre-commit install +``` + +Now you are ready to proceed with all the changes you want to! + +## Testing ๐Ÿงช + +Once you are done with changes, you should: + +- add tests for the new features in the `/tests` folder +- make sure that new features do not break existing codebase by running tests: + + === "with Make" + + ```bash + make test + ``` + + === "without Make" + + ```bash + pytest tests -n auto + ``` + +## Docs ๐Ÿ“‘ + +The documentation is generated using [mkdocs-material](https://squidfunk.github.io/mkdocs-material/){:target="_blank"}, the API part uses [mkdocstrings](https://mkdocstrings.github.io/){:target="_blank"}. + +If a new feature or a breaking change is developed, then we suggest to update documentation in the `/docs` folder as well, in order to describe how this can be used from a user perspective. diff --git a/docs/css/custom.css b/docs/css/custom.css new file mode 100644 index 0000000..fd5872a --- /dev/null +++ b/docs/css/custom.css @@ -0,0 +1,30 @@ +/** + * custom.js + * From https://github.com/tiangolo/typer/blob/master/docs/css/custom.css + * + * @author Sebastiรกn Ramรญrez + * @license MIT + */ +.termynal-comment { + color: #4a968f; + font-style: italic; + display: block; +} + +.termy [data-termynal] { + white-space: pre-wrap; +} + +a.external-link::after { + /* \00A0 is a non-breaking space + to make the mark be on the same line as the link + */ + content: "\00A0[โ†ช]"; +} + +a.internal-link::after { + /* \00A0 is a non-breaking space + to make the mark be on the same line as the link + */ + content: "\00A0โ†ช"; +} diff --git a/docs/css/termynal.css b/docs/css/termynal.css new file mode 100644 index 0000000..bbf7628 --- /dev/null +++ b/docs/css/termynal.css @@ -0,0 +1,110 @@ +/** + * termynal.js + * + * @author Lines Montani + * @version 0.0.1 + * @license MIT + */ + + :root { + --color-bg: #252a33; + --color-text: #eee; + --color-text-subtle: #a2a2a2; +} + +[data-termynal] { + width: 750px; + max-width: 100%; + background: var(--color-bg); + color: var(--color-text); + /* font-size: 18px; */ + font-size: 15px; + /* font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; */ + font-family: 'Roboto Mono', 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; + border-radius: 4px; + padding: 75px 45px 35px; + position: relative; + -webkit-box-sizing: border-box; + box-sizing: border-box; + line-height: 1.2; +} + +[data-termynal]:before { + content: ''; + position: absolute; + top: 15px; + left: 15px; + display: inline-block; + width: 15px; + height: 15px; + border-radius: 50%; + /* A little hack to display the window buttons in one pseudo element. */ + background: #d9515d; + -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; + box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; +} + +[data-termynal]:after { + content: 'bash'; + position: absolute; + color: var(--color-text-subtle); + top: 5px; + left: 0; + width: 100%; + text-align: center; +} + +a[data-terminal-control] { + text-align: right; + display: block; + color: #aebbff; +} + +[data-ty] { + display: block; + line-height: 2; +} + +[data-ty]:before { + /* Set up defaults and ensure empty lines are displayed. */ + content: ''; + display: inline-block; + vertical-align: middle; +} + +[data-ty="input"]:before, +[data-ty-prompt]:before { + margin-right: 0.75em; + color: var(--color-text-subtle); +} + +[data-ty="input"]:before { + content: '$'; +} + +[data-ty][data-ty-prompt]:before { + content: attr(data-ty-prompt); +} + +[data-ty-cursor]:after { + content: attr(data-ty-cursor); + font-family: monospace; + margin-left: 0.5em; + -webkit-animation: blink 1s infinite; + animation: blink 1s infinite; +} + + +/* Cursor animation */ + +@-webkit-keyframes blink { + 50% { + opacity: 0; + } +} + +@keyframes blink { + 50% { + opacity: 0; + } +} diff --git a/docs/index.md b/docs/index.md index e69de29..c7b68dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -0,0 +1,30 @@ + + +# Scikit-learn Smithy + +Scikit-learn smithy is a tool that helps you to forge scikit-learn compatible estimator with ease. + +How can you use it? + +- [x] Directly from the web: we have a [web UI](https://sklearn-smithy.streamlit.app/){:target="_blank"} powered by [streamlit](https://streamlit.io/){:target="_blank"}. +- [x] As a CLI (command line interface) in your terminal (requires [installation](installation.md)) powered by [typer](https://typer.tiangolo.com/){:target="_blank"}: + + ```terminal + smith forge + ``` + +- [ ] As a TUI (terminal user interface): [Working in progress](https://github.com/FBruzzesi/sklearn-smithy/issues/1)! + +All these tools will prompt a series of questions regarding the estimator you want to create, and then it will generate the boilerplate code for you. + +## Origin story + +The idea for this tool originated from [scikit-lego #660](https://github.com/koaning/scikit-lego/pull/660){:target="_blank"}, which I cannot better explain than quoting the PR description itself: + +> So the story goes as the following: +> +> - The CI/CD fails for scikit-learn==1.5rc1 because of a change in the `check_estimator` internals +> - In the [scikit-learn issue](https://github.com/scikit-learn/scikit-learn/issues/28966){:target="_blank"} I got a better picture of how to run test for compatible components +> - In particular, [rolling your own estimator](https://scikit-learn.org/dev/developers/develop.html#rolling-your-own-estimator){:target="_blank"} suggests to use [`parametrize_with_checks`](https://scikit-learn.org/dev/modules/generated/sklearn.utils.estimator_checks.parametrize_with_checks.html#sklearn.utils.estimator_checks.parametrize_with_checks){:target="_blank"}, and of course I thought "that is a great idea to avoid dealing manually with each test" +> - Say no more, I enter a rabbit hole to refactor all our tests - which would be fine +> - Except that these tests failures helped me figure out a few missing parts in the codebase diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..4bcd829 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,34 @@ +# Installation โœจ + +sklearn-smithy is available on [pypi](https://pypi.org/project/sklearn-smithy){:target="_blank"}, so you can install it directly from there: + +```bash +python -m pip install sklearn-smithy +``` + +!!! warning + The minimum Python version supported is 3.10. + +This will make the `smith` command available in your terminal, and you should be able to run the following: + +```bash +smith version +``` + +> sklearn-smithy=... + +## Other installation methods + +=== "pip + source/git" + + ```bash + python -m pip install git+https://github.com/FBruzzesi/sklearn-smithy.git + ``` + +=== "local clone" + + ```bash + git clone https://github.com/FBruzzesi/sklearn-smithy.git + cd sklearn-smithy + python -m pip install . + ``` diff --git a/docs/js/custom.js b/docs/js/custom.js new file mode 100644 index 0000000..13b32fe --- /dev/null +++ b/docs/js/custom.js @@ -0,0 +1,112 @@ +/** + * custom.js + * From https://github.com/tiangolo/typer/blob/master/docs/js/custom.js + * + * @author Sebastiรกn Ramรญrez + * @license MIT + */ +document.querySelectorAll(".use-termynal").forEach(node => { + node.style.display = "block"; + new Termynal(node, { + lineDelay: 500 + }); +}); +const progressLiteralStart = "---> 100%"; +const promptLiteralStart = "$ "; +const customPromptLiteralStart = "# "; +const termynalActivateClass = "termy"; +let termynals = []; + +function createTermynals() { + document + .querySelectorAll(`.${termynalActivateClass} .highlight`) + .forEach(node => { + const text = node.textContent; + const lines = text.split("\n"); + const useLines = []; + let buffer = []; + function saveBuffer() { + if (buffer.length) { + let isBlankSpace = true; + buffer.forEach(line => { + if (line) { + isBlankSpace = false; + } + }); + dataValue = {}; + if (isBlankSpace) { + dataValue["delay"] = 0; + } + if (buffer[buffer.length - 1] === "") { + // A last single
won't have effect + // so put an additional one + buffer.push(""); + } + const bufferValue = buffer.join("
"); + dataValue["value"] = bufferValue; + useLines.push(dataValue); + buffer = []; + } + } + for (let line of lines) { + if (line === progressLiteralStart) { + saveBuffer(); + useLines.push({ + type: "progress" + }); + } else if (line.startsWith(promptLiteralStart)) { + saveBuffer(); + const value = line.replace(promptLiteralStart, "").trimEnd(); + useLines.push({ + type: "input", + value: value + }); + } else if (line.startsWith("// ")) { + saveBuffer(); + const value = "๐Ÿ’ฌ " + line.replace("// ", "").trimEnd(); + useLines.push({ + value: value, + class: "termynal-comment", + delay: 0 + }); + } else if (line.startsWith(customPromptLiteralStart)) { + saveBuffer(); + const promptStart = line.indexOf(promptLiteralStart); + if (promptStart === -1) { + console.error("Custom prompt found but no end delimiter", line) + } + const prompt = line.slice(0, promptStart).replace(customPromptLiteralStart, "") + let value = line.slice(promptStart + promptLiteralStart.length); + useLines.push({ + type: "input", + value: value, + prompt: prompt + }); + } else { + buffer.push(line); + } + } + saveBuffer(); + const div = document.createElement("div"); + node.replaceWith(div); + const termynal = new Termynal(div, { + lineData: useLines, + noInit: true, + lineDelay: 500 + }); + termynals.push(termynal); + }); +} + +function loadVisibleTermynals() { + termynals = termynals.filter(termynal => { + if (termynal.container.getBoundingClientRect().top - innerHeight <= 0) { + termynal.init(); + return false; + } + return true; + }); +} +window.addEventListener("scroll", loadVisibleTermynals); +createTermynals(); +loadVisibleTermynals(); diff --git a/docs/js/termynal.js b/docs/js/termynal.js new file mode 100644 index 0000000..909c3b2 --- /dev/null +++ b/docs/js/termynal.js @@ -0,0 +1,264 @@ +/** + * termynal.js + * A lightweight, modern and extensible animated terminal window, using + * async/await. + * + * @author Lines Montani + * @version 0.0.1 + * @license MIT + */ + +'use strict'; + +/** Generate a terminal widget. */ +class Termynal { + /** + * Construct the widget's settings. + * @param {(string|Node)=} container - Query selector or container element. + * @param {Object=} options - Custom settings. + * @param {string} options.prefix - Prefix to use for data attributes. + * @param {number} options.startDelay - Delay before animation, in ms. + * @param {number} options.typeDelay - Delay between each typed character, in ms. + * @param {number} options.lineDelay - Delay between each line, in ms. + * @param {number} options.progressLength - Number of characters displayed as progress bar. + * @param {string} options.progressChar โ€“ Character to use for progress bar, defaults to โ–ˆ. + * @param {number} options.progressPercent - Max percent of progress. + * @param {string} options.cursor โ€“ Character to use for cursor, defaults to โ–‹. + * @param {Object[]} lineData - Dynamically loaded line data objects. + * @param {boolean} options.noInit - Don't initialise the animation. + */ + constructor(container = '#termynal', options = {}) { + this.container = (typeof container === 'string') ? document.querySelector(container) : container; + this.pfx = `data-${options.prefix || 'ty'}`; + this.originalStartDelay = this.startDelay = options.startDelay + || parseFloat(this.container.getAttribute(`${this.pfx}-startDelay`)) || 600; + this.originalTypeDelay = this.typeDelay = options.typeDelay + || parseFloat(this.container.getAttribute(`${this.pfx}-typeDelay`)) || 90; + this.originalLineDelay = this.lineDelay = options.lineDelay + || parseFloat(this.container.getAttribute(`${this.pfx}-lineDelay`)) || 1500; + this.progressLength = options.progressLength + || parseFloat(this.container.getAttribute(`${this.pfx}-progressLength`)) || 40; + this.progressChar = options.progressChar + || this.container.getAttribute(`${this.pfx}-progressChar`) || 'โ–ˆ'; + this.progressPercent = options.progressPercent + || parseFloat(this.container.getAttribute(`${this.pfx}-progressPercent`)) || 100; + this.cursor = options.cursor + || this.container.getAttribute(`${this.pfx}-cursor`) || 'โ–‹'; + this.lineData = this.lineDataToElements(options.lineData || []); + this.loadLines() + if (!options.noInit) this.init() + } + + loadLines() { + // Load all the lines and create the container so that the size is fixed + // Otherwise it would be changing and the user viewport would be constantly + // moving as she/he scrolls + const finish = this.generateFinish() + finish.style.visibility = 'hidden' + this.container.appendChild(finish) + // Appends dynamically loaded lines to existing line elements. + this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData); + for (let line of this.lines) { + line.style.visibility = 'hidden' + this.container.appendChild(line) + } + const restart = this.generateRestart() + restart.style.visibility = 'hidden' + this.container.appendChild(restart) + this.container.setAttribute('data-termynal', ''); + } + + /** + * Initialise the widget, get lines, clear container and start animation. + */ + init() { + /** + * Calculates width and height of Termynal container. + * If container is empty and lines are dynamically loaded, defaults to browser `auto` or CSS. + */ + const containerStyle = getComputedStyle(this.container); + this.container.style.width = containerStyle.width !== '0px' ? + containerStyle.width : undefined; + this.container.style.minHeight = containerStyle.height !== '0px' ? + containerStyle.height : undefined; + + this.container.setAttribute('data-termynal', ''); + this.container.innerHTML = ''; + for (let line of this.lines) { + line.style.visibility = 'visible' + } + this.start(); + } + + /** + * Start the animation and rener the lines depending on their data attributes. + */ + async start() { + this.addFinish() + await this._wait(this.startDelay); + + for (let line of this.lines) { + const type = line.getAttribute(this.pfx); + const delay = line.getAttribute(`${this.pfx}-delay`) || this.lineDelay; + + if (type == 'input') { + line.setAttribute(`${this.pfx}-cursor`, this.cursor); + await this.type(line); + await this._wait(delay); + } + + else if (type == 'progress') { + await this.progress(line); + await this._wait(delay); + } + + else { + this.container.appendChild(line); + await this._wait(delay); + } + + line.removeAttribute(`${this.pfx}-cursor`); + } + this.addRestart() + this.finishElement.style.visibility = 'hidden' + this.lineDelay = this.originalLineDelay + this.typeDelay = this.originalTypeDelay + this.startDelay = this.originalStartDelay + } + + generateRestart() { + const restart = document.createElement('a') + restart.onclick = (e) => { + e.preventDefault() + this.container.innerHTML = '' + this.init() + } + restart.href = '#' + restart.setAttribute('data-terminal-control', '') + restart.innerHTML = "restart โ†ป" + return restart + } + + generateFinish() { + const finish = document.createElement('a') + finish.onclick = (e) => { + e.preventDefault() + this.lineDelay = 0 + this.typeDelay = 0 + this.startDelay = 0 + } + finish.href = '#' + finish.setAttribute('data-terminal-control', '') + finish.innerHTML = "fast โ†’" + this.finishElement = finish + return finish + } + + addRestart() { + const restart = this.generateRestart() + this.container.appendChild(restart) + } + + addFinish() { + const finish = this.generateFinish() + this.container.appendChild(finish) + } + + /** + * Animate a typed line. + * @param {Node} line - The line element to render. + */ + async type(line) { + const chars = [...line.textContent]; + line.textContent = ''; + this.container.appendChild(line); + + for (let char of chars) { + const delay = line.getAttribute(`${this.pfx}-typeDelay`) || this.typeDelay; + await this._wait(delay); + line.textContent += char; + } + } + + /** + * Animate a progress bar. + * @param {Node} line - The line element to render. + */ + async progress(line) { + const progressLength = line.getAttribute(`${this.pfx}-progressLength`) + || this.progressLength; + const progressChar = line.getAttribute(`${this.pfx}-progressChar`) + || this.progressChar; + const chars = progressChar.repeat(progressLength); + const progressPercent = line.getAttribute(`${this.pfx}-progressPercent`) + || this.progressPercent; + line.textContent = ''; + this.container.appendChild(line); + + for (let i = 1; i < chars.length + 1; i++) { + await this._wait(this.typeDelay); + const percent = Math.round(i / chars.length * 100); + line.textContent = `${chars.slice(0, i)} ${percent}%`; + if (percent>progressPercent) { + break; + } + } + } + + /** + * Helper function for animation delays, called with `await`. + * @param {number} time - Timeout, in ms. + */ + _wait(time) { + return new Promise(resolve => setTimeout(resolve, time)); + } + + /** + * Converts line data objects into line elements. + * + * @param {Object[]} lineData - Dynamically loaded lines. + * @param {Object} line - Line data object. + * @returns {Element[]} - Array of line elements. + */ + lineDataToElements(lineData) { + return lineData.map(line => { + let div = document.createElement('div'); + div.innerHTML = `${line.value || ''}`; + + return div.firstElementChild; + }); + } + + /** + * Helper function for generating attributes string. + * + * @param {Object} line - Line data object. + * @returns {string} - String of attributes. + */ + _attributes(line) { + let attrs = ''; + for (let prop in line) { + // Custom add class + if (prop === 'class') { + attrs += ` class=${line[prop]} ` + continue + } + if (prop === 'type') { + attrs += `${this.pfx}="${line[prop]}" ` + } else if (prop !== 'value') { + attrs += `${this.pfx}-${prop}="${line[prop]}" ` + } + } + + return attrs; + } +} + +/** +* HTML API: If current script has container(s) specified, initialise Termynal. +*/ +if (document.currentScript.hasAttribute('data-termynal-container')) { + const containers = document.currentScript.getAttribute('data-termynal-container'); + containers.split('|') + .forEach(container => new Termynal(container)) +} diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..e5d2dfd --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,63 @@ +# User Guide ๐Ÿ“š + +As introduced in the [home page](index.md), **sklearn-smithy** is a tool that helps you to forge scikit-learn compatible estimator with ease, and it comes in three flavours. + +Let's see how to use each one of them. + +## Web UI ๐ŸŒ + +The web UI is available at [sklearn-smithy.streamlit.app](https://sklearn-smithy.streamlit.app/){:target="_blank"} allowing you to interact with the tool directly from your browser. + +This option does not require any installation, and it is the most user-friendly way to use the tool if you have access to a browser or do not want to install anything on your machine. + +## CLI โŒจ๏ธ + +Once the library is installed, the `smith` CLI (Command Line Interface) will be available and that is the primary way to interact with the `smithy` package. + +The CLI provides a main command called `forge`, which will prompt a series of question in the terminal, based on which it will generate the code for the estimator. + +!!! warning "Non-interactive mode" + As any CLI, in principle it would be possible to run it in a non-interactive way, however this is not *fully* supported yet and it comes with some risks and limitations. + + The reason for this is that the validation and the parameters interaction happen while prompting the questions one after the other, meaning that the input to one prompt will follow next. + +Let's see an example of how to use `smith forge` command: + +
+ +```console +$ smith forge +# ๐Ÿ How would you like to name the estimator?:$ MightyClassifier +# ๐ŸŽฏ Which kind of estimator is it? (classifier, outlier, regressor, transformer, cluster):$ classifier +# ๐Ÿ“œ Please list the required parameters (comma-separated) []:$ alpha,beta +# ๐Ÿ“‘ Please list the optional parameters (comma-separated) []:$ mu,sigma +# ๐Ÿ“ถ Does the `.fit()` method support `sample_weight`? [y/N]:$ y +# ๐Ÿ“ Is the estimator linear? [y/N]:$ N +# ๐ŸŽฒ Should the estimator implement a `predict_proba` method? [y/N]:$ N +# โ“ Should the estimator implement a `decision_function` method? [y/N]:$ y +# ๐Ÿงช We are almost there... Is there any tag you want to add? (comma-separated) []:$ +# ๐Ÿ“‚ Where would you like to save the class? [mightyclassifier.py]:$ path/to/file.py +Template forged at path/to/file.py +``` + +
+ +Now the estimator template to be filled will be available at the specified path `path/to/file.py`. + +
+ +```console +$ cat path/to/file.py +import numpy as np + +from sklearn.base import BaseEstimator, ClassifierMixin +from sklearn.utils import check_X_y +from sklearn.utils.validation import check_is_fitted, check_array +... +``` + +
+ +## TUI ๐Ÿ’ป + +๐Ÿšง WIP diff --git a/docs/why.md b/docs/why.md new file mode 100644 index 0000000..623a1b3 --- /dev/null +++ b/docs/why.md @@ -0,0 +1,41 @@ +# Whyโ“ + +Writing scikit-learn compatible estimators might be harder than expected. + +While everyone knows about the `fit` and `predict`, there are other behaviours, methods and attributes that +scikit-learn might be expecting from your estimator depending on: + +- The type of estimator you're writing. +- The signature of the estimator. +- The signature of the `.fit(...)` method. + +Scikit-learn Smithy to the rescue: this tool aims to help you crafting your own estimator by asking a few +questions about it, and then generating the boilerplate code. + +In this way you will be able to fully focus on the core implementation logic, and not on nitty-gritty details +of the scikit-learn API. + +## Sanity check + +Once the core logic is implemented, the estimator should be ready to test against the _somewhat official_ +[`parametrize_with_checks`](https://scikit-learn.org/dev/modules/generated/sklearn.utils.estimator_checks.parametrize_with_checks.html#sklearn.utils.estimator_checks.parametrize_with_checks){:target="_blank"} +pytest compatible decorator: + +```py +from sklearn.utils.estimator_checks import parametrize_with_checks + +@parametrize_with_checks([ + YourAwesomeRegressor, + MoreAwesomeClassifier, + EvenMoreAwesomeTransformer, +]) +def test_sklearn_compatible_estimator(estimator, check): + check(estimator) +``` + +and it should be compatible with scikit-learn Pipeline, GridSearchCV, etc. + +## Official guide + +Scikit-learn documentation on how to +[develop estimators](https://scikit-learn.org/dev/developers/develop.html#developing-scikit-learn-estimators){:target="_blank"}. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..1f3f5e4 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,95 @@ +# Project information +site_name: Sklearn Smithy +site_url: https://fbruzzesi.github.io/sklearn-smithy/ +site_author: Francesco Bruzzesi +site_description: Toolkit to forge scikit-learn compatible estimators + +# Repository information +repo_name: FBruzzesi/sklearn-smithy +repo_url: https://github.com/fbruzzesi/sklearn-smithy +edit_uri: edit/main/docs/ + +# Configuration +use_directory_urls: true +theme: + name: material + font: false + palette: + - media: '(prefers-color-scheme: light)' + scheme: default + primary: teal + accent: deep-orange + toggle: + icon: material/brightness-7 + name: Switch to light mode + - media: '(prefers-color-scheme: dark)' + scheme: slate + primary: teal + accent: deep-orange + toggle: + icon: material/brightness-4 + name: Switch to dark mode + features: + - search.suggest + - search.highlight + - search.share + - toc.follow + - content.tabs.link + - content.code.annotate + - content.code.copy + + logo: img/sksmith-logo.png + favicon: img/sksmith-logo.png + +# Plugins +plugins: + - search: + separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])' + +# Customization +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/fbruzzesi + - icon: fontawesome/brands/linkedin + link: https://www.linkedin.com/in/francesco-bruzzesi/ + - icon: fontawesome/brands/python + link: https://pypi.org/project/sklearn-smithy/ + +# Extensions +markdown_extensions: + - abbr + - admonition + - attr_list + - codehilite + - def_list + - footnotes + - md_in_html + - toc: + permalink: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.details + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + +nav: + - Home ๐Ÿ : index.md + - Installation โœจ: installation.md + - Why โ“: why.md + - User Guide ๐Ÿ“š: user-guide.md + - Contributing ๐Ÿ‘: contribute.md + +extra_css: + - css/termynal.css + - css/custom.css +extra_javascript: + - js/termynal.js + - js/custom.js diff --git a/pyproject.toml b/pyproject.toml index ad8af75..d409d1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sklearn-smithy" -version = "0.0.8" +version = "0.0.9" license = {file = "LICENSE"} readme = "README.md" diff --git a/sksmithy/_prompts.py b/sksmithy/_prompts.py index 1789bd4..cd032ac 100644 --- a/sksmithy/_prompts.py +++ b/sksmithy/_prompts.py @@ -9,7 +9,7 @@ PROMPT_PREDICT_PROBA: Final[str] = "๐ŸŽฒ Should the estimator implement a `predict_proba` method?" PROMPT_DECISION_FUNCTION: Final[str] = "โ“ Should the estimator implement a `decision_function` method?" PROMPT_TAGS: Final[str] = ( - "๐Ÿงช We are almost there... Is there any tag you want to add? (comma or space separated)\n" + "๐Ÿงช We are almost there... Is there any tag you want to add? (comma-separated)\n" "To know more about tags, check the documentation at:\n" "https://scikit-learn.org/dev/developers/develop.html#estimator-tags" ) diff --git a/sksmithy/_utils.py b/sksmithy/_utils.py index 287f0cf..e4a211f 100644 --- a/sksmithy/_utils.py +++ b/sksmithy/_utils.py @@ -26,7 +26,7 @@ def render_template( This is achieved in a two steps process: - - Render the jinja template wit hthe input values. + - Render the jinja template using the input values. - Format the string using ruff formatter. !!! warning diff --git a/sksmithy/app.py b/sksmithy/app.py index 3ca36e0..4295782 100644 --- a/sksmithy/app.py +++ b/sksmithy/app.py @@ -55,7 +55,7 @@ st.markdown(""" # Description - Writing a scikit-learn compatible estimators might be harder than expected. + Writing scikit-learn compatible estimators might be harder than expected. While everyone knows about the `fit` and `predict`, there are other behaviours, methods and attributes that scikit-learn might be expecting from your estimator depending on: diff --git a/tests/test_app.py b/tests/test_app.py index 48d8c19..11824e5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -64,15 +64,14 @@ def test_params( ) -> None: """Test required and optional params interaction.""" app.run() - app.text_input(key="required").input(required_).run() - app.text_input(key="optional").input(optional_).run() - app.text_input(key="name").input(name).run() app.selectbox(key="estimator").select(estimator.value).run() + + app.text_input(key="required").input(required_).run() + app.text_input(key="optional").input(optional_).run() if err_msg: assert app.error[0].value == err_msg - # Forge button gets disabled if any error happen assert app.button(key="forge_btn").disabled else: @@ -92,6 +91,8 @@ def test_forge(app: AppTest, name: str, estimator: EstimatorType) -> None: app.text_input(key="name").input(name).run() app.selectbox(key="estimator").select(estimator.value).run() assert not app.button(key="forge_btn").disabled + assert not app.code app.button(key="forge_btn").click().run() assert app.session_state["forge_counter"] == 1 + assert app.code is not None