From 0b6337babe7a37e8e5bbc520d09dfb539f3ecafb Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sun, 5 Jan 2025 00:00:00 +0000 Subject: [PATCH 01/11] build: add renovate.json --- renovate.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..22a9943 --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"] +} From 038b9bc67abbe33f13f627463abd67dcdbabbcd7 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sun, 5 Jan 2025 00:00:00 +0000 Subject: [PATCH 02/11] build: apply project template --- .copier-answers.yml | 5 +- .envrc | 2 +- .gitea/workflows/build-deploy-image.yaml | 10 +- .../build-publish-python-package.yaml | 12 +- .python-version | 1 + Dockerfile | 2 +- compose.yaml.tpl | 309 +++++++++--------- pyproject.toml | 22 +- tests/conftest.py | 2 - uv.lock | 30 +- 10 files changed, 200 insertions(+), 195 deletions(-) create mode 100644 .python-version diff --git a/.copier-answers.yml b/.copier-answers.yml index 8e549b1..a75b271 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,9 +1,8 @@ # Changes here will be overwritten by Copier -_commit: 8af3bf2 -_src_path: ../../templates/copier-f2dv-project +_commit: 0537098 +_src_path: ssh://git@git.local.hostbutter.net/fresh2dev/copier-f2dv-project.git author_email: hello@f2dv.com author_name: Donald Mellenbruch -docs_slides_url: https://www.f2dv.com/s/argparse-tui docs_url: https://www.f2dv.com/r/argparse-tui funding_url: https://www.f2dv.com/fund home_domain: f2dv.com diff --git a/.envrc b/.envrc index 473fdf2..6c82dd5 100644 --- a/.envrc +++ b/.envrc @@ -1,2 +1,2 @@ -uv sync --python='3.9' --all-extras +uv sync --all-extras # --upgrade . ./.venv/bin/activate diff --git a/.gitea/workflows/build-deploy-image.yaml b/.gitea/workflows/build-deploy-image.yaml index 02d1d58..00f5217 100644 --- a/.gitea/workflows/build-deploy-image.yaml +++ b/.gitea/workflows/build-deploy-image.yaml @@ -23,7 +23,7 @@ jobs: # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb # - uses: ./ - - uses: https://gitea.local.hostbutter.net/fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb + - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 with: github-token: ${{ secrets.EGET_GITHUB_TOKEN }} @@ -83,7 +83,9 @@ jobs: ### Checkout # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb # - uses: ./ - - uses: https://gitea.local.hostbutter.net/fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb + - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 + with: + github-token: ${{ secrets.EGET_GITHUB_TOKEN }} - name: Dump github context run: echo "$GITHUB_CONTEXT" @@ -144,7 +146,9 @@ jobs: ### Checkout # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb # - uses: ./ - - uses: https://gitea.local.hostbutter.net/fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb + - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 + with: + github-token: ${{ secrets.EGET_GITHUB_TOKEN }} - if: > endsWith(github.repository, '/hostbutter') diff --git a/.gitea/workflows/build-publish-python-package.yaml b/.gitea/workflows/build-publish-python-package.yaml index b0baf4a..112b575 100644 --- a/.gitea/workflows/build-publish-python-package.yaml +++ b/.gitea/workflows/build-publish-python-package.yaml @@ -21,7 +21,7 @@ jobs: runs-on: 'ubuntu-latest' strategy: matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] max-parallel: 3 fail-fast: true steps: @@ -35,7 +35,9 @@ jobs: # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb # - uses: ./ - - uses: https://gitea.local.hostbutter.net/fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb + - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 + with: + github-token: ${{ secrets.EGET_GITHUB_TOKEN }} - name: Set up Python v${{ matrix.python-version }} uses: actions/setup-python@v5 @@ -44,7 +46,7 @@ jobs: - name: Test Python Project using Python v${{ matrix.python-version }} run: | - tox --workdir ${{ runner.temp }}/tox -e py + tox --workdir ${{ runner.temp }}/tox -e py${{ matrix.python-version }} build-publish-python: # needs: ['setup'] @@ -61,7 +63,9 @@ jobs: # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb # - uses: ./ - - uses: https://gitea.local.hostbutter.net/fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb + - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 + with: + github-token: ${{ secrets.EGET_GITHUB_TOKEN }} - name: Test Python Project run: | diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/Dockerfile b/Dockerfile index 4827565..2701f52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13.0-slim-bullseye as build +FROM python:3.13-slim-bullseye as build RUN apt-get update \ && apt-get install --upgrade -y git build-essential gcc libssl-dev libffi-dev python3-dev WORKDIR /workspace diff --git a/compose.yaml.tpl b/compose.yaml.tpl index d6012c5..bb8fb1e 100644 --- a/compose.yaml.tpl +++ b/compose.yaml.tpl @@ -1,163 +1,156 @@ -<{{- $vars := (ds "vars") }}> -<{{/* defineDatasource "vault" (printf "vault+https:///secret/data/releases/%s/%s" $vars.hostbutterSiteName $vars.releaseName) */}}> -# Pull vault secrets like: <{{/* index (ds "vault").data "my-file.txt" | base64.Decode */}}> - -global: - butterStack: - compose: - services: - <{{$vars.releaseName}}>: - labels: - kompose.controller.type: "deployment" # 'deployment' (default) or 'statefulset' - # kompose.serviceaccount-name: "<{{$vars.releaseName}}>" - image: '<{{ $vars.registry }}>/<{{ getenv "CI_PROJECT_PATH" | required "Missing required env var: CI_PROJECT_PATH" }}>:<{{ getenv "CI_COMMIT_SHORT_SHA" | required "Missing required env var: CI_COMMIT_SHORT_SHA" }}>' - ports: ["<{{ $vars.svcPort }}>"] - configs: - - source: <{{$vars.releaseName}}>-nginx-config - target: '/etc/nginx/conf.d/default.conf' - # secrets: - # - source: <{{$vars.releaseName}}>-secrets - # target: '/app/creds.json' - # environment: - # TZ: <{{/* $vars.tz */}}> - # PUID: <{{/* $vars.puid */}}> - # PGID: <{{/* $vars.pgid */}}> - # PLAIN_SECRET: <{{/* index (ds "vault").data "sekret.txt" | base64.Decode */}}> - # USERNAME: "secretKeyRef://<{{$vars.releaseName}}>-env" - # PASSWORD: "secretKeyRef://<{{$vars.releaseName}}>-env/APP_PASSWORD" - # volumes: - # - <{{$vars.releaseName}}>-data:/data:ro - # - <{{$vars.releaseName}}>-config:/app/config - - # <{{$vars.releaseName}}>-redis: - # ports: ['6379'] - # image: 'docker.io/library/redis:7' - +--- +compose: + services: + <{{$vars.releaseName}}>: + labels: + kompose.controller.type: "deployment" # 'deployment' (default) or 'statefulset' + # kompose.serviceaccount-name: "<{{$vars.releaseName}}>" + image: '<{{ $vars.registry }}>/<{{ getenv "CI_PROJECT_PATH" | required "Missing required env var: CI_PROJECT_PATH" }}>:<{{ getenv "CI_COMMIT_SHORT_SHA" | required "Missing required env var: CI_COMMIT_SHORT_SHA" }}>' + ports: ["<{{ $vars.svcPort }}>"] configs: - <{{$vars.releaseName}}>-nginx-config: - file: ./releases.yaml - # <{{$vars.releaseName}}>-configs: - # file: ./releases.yaml + - source: <{{$vars.releaseName}}>-nginx-config + target: '/etc/nginx/conf.d/default.conf' # secrets: - # <{{$vars.releaseName}}>-secrets: - # file: ./releases.yaml + # - source: <{{$vars.releaseName}}>-secrets + # target: '/app/creds.json' + # environment: + # TZ: <{{/* $vars.tz */}}> + # PUID: <{{/* $vars.puid */}}> + # PGID: <{{/* $vars.pgid */}}> + # PLAIN_SECRET: <{{/* index (ds "vault").data "sekret.txt" | base64.Decode */}}> + # USERNAME: "secretKeyRef://<{{$vars.releaseName}}>-env" + # PASSWORD: "secretKeyRef://<{{$vars.releaseName}}>-env/APP_PASSWORD" # volumes: - # <{{$vars.releaseName}}>-data: - # external: true + # - <{{$vars.releaseName}}>-data:/data:ro + # - <{{$vars.releaseName}}>-config:/app/config - customObjects: - <{{- $releaseHostName := printf "%s.%s" $vars.subdomain $vars.domain }}> - <{{- $releaseUrl := printf "%s%s" $releaseHostName $vars.routePrefix }}> - - - apiVersion: traefik.io/v1alpha1 - kind: IngressRoute - metadata: - name: <{{ $vars.releaseName }}> - annotations: - gethomepage.dev/enabled: "true" - gethomepage.dev/href: "https://<{{ $releaseUrl }}>" - gethomepage.dev/description: <{{ $vars.releaseName }}> - gethomepage.dev/group: Other - gethomepage.dev/icon: kubernetes.png - gethomepage.dev/name: <{{ $vars.releaseName }}> - gethomepage.dev/weight: 10 # optional - # gethomepage.dev/instance: "public" # optional - # gethomepage.dev/app: "<{{ $vars.releaseName }}>" # optional, Use pod-selector instead. - gethomepage.dev/pod-selector: >- - app.kubernetes.io/instance in (<{{ $vars.releaseName }}>) - # gethomepage.dev/widget.type: "emby" - # gethomepage.dev/widget.url: "<{{ $releaseUrl }}>" - labels: {} - spec: - tls: {} - entryPoints: ["websecure"] - routes: - - match: 'Host(`<{{ $releaseHostName }}>`) && PathPrefix(`<{{ $vars.routePrefix }}>`)' - kind: Rule - services: - - name: <{{ $vars.releaseName }}> - port: <{{ $vars.svcPort }}> - middlewares: - - name: traefik-default-headers - namespace: default - # - name: traefik-basicauth - # namespace: default - # - name: <{{ $vars.releaseName }}>-strip-prefix - # namespace: default - # - - # apiVersion: traefik.io/v1alpha1 - # kind: Middleware - # metadata: - # name: "<{{ $vars.releaseName }}>-strip-prefix" - # spec: - # stripPrefix: - # forceSlash: false - # prefixes: - # - "<{{ $vars.routePrefix }}>" - - - apiVersion: v1 - kind: ConfigMap - metadata: - name: "<{{ $vars.releaseName }}>-nginx-config" - data: - default.conf: | - server { - listen <{{ $vars.svcPort }}>; - absolute_redirect off; + # <{{$vars.releaseName}}>-redis: + # ports: ['6379'] + # image: 'docker.io/library/redis:7' + + configs: + <{{$vars.releaseName}}>-nginx-config: + file: ./releases.yaml + # <{{$vars.releaseName}}>-configs: + # file: ./releases.yaml + # secrets: + # <{{$vars.releaseName}}>-secrets: + # file: ./releases.yaml + # volumes: + # <{{$vars.releaseName}}>-data: + # external: true + +customObjects: +<{{- $releaseHostName := printf "%s.%s" $vars.subdomain $vars.domain }}> +<{{- $releaseUrl := printf "%s%s" $releaseHostName $vars.routePrefix }}> +- + apiVersion: traefik.io/v1alpha1 + kind: IngressRoute + metadata: + name: <{{ $vars.releaseName }}> + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: "https://<{{ $releaseUrl }}>" + gethomepage.dev/description: <{{ $vars.releaseName }}> + gethomepage.dev/group: 'K8S Services' + gethomepage.dev/icon: kubernetes.png + gethomepage.dev/name: <{{ $vars.releaseName }}> + gethomepage.dev/weight: 10 # optional + # gethomepage.dev/instance: "public" # optional + # gethomepage.dev/app: "<{{ $vars.releaseName }}>" # optional, Use pod-selector instead. + gethomepage.dev/pod-selector: 'io.kompose.service in (<{{ $vars.releaseName }}>)' + # OR: gethomepage.dev/pod-selector: 'app.kubernetes.io/name in (<{{ $vars.releaseName }}>), app.kubernetes.io/instance in (<{{ $vars.releaseName }}>)' + # gethomepage.dev/widget.type: "emby" + # gethomepage.dev/widget.url: "<{{ $releaseUrl }}>" + labels: {} + spec: + tls: {} + entryPoints: ["websecure"] + routes: + - match: 'Host(`<{{ $releaseHostName }}>`) && PathPrefix(`<{{ $vars.routePrefix }}>`)' + kind: Rule + services: + - name: <{{ $vars.releaseName }}> + port: <{{ $vars.svcPort }}> + middlewares: + # - name: authelia + # namespace: traefik + # - name: <{{ $vars.releaseName }}>-strip-prefix + # namespace: default +# - +# apiVersion: traefik.io/v1alpha1 +# kind: Middleware +# metadata: +# name: "<{{ $vars.releaseName }}>-strip-prefix" +# spec: +# stripPrefix: +# forceSlash: false +# prefixes: +# - "<{{ $vars.routePrefix }}>" +- + apiVersion: v1 + kind: ConfigMap + metadata: + name: "<{{ $vars.releaseName }}>-nginx-config" + data: + default.conf: | + server { + listen <{{ $vars.svcPort }}>; + absolute_redirect off; - location <{{ $vars.routePrefix }}> { - alias /usr/share/nginx/html/; - } - } - ### - # - - # kind: PersistentVolumeClaim - # apiVersion: v1 - # metadata: - # name: "<{{ $vars.releaseName }}>-data" - # annotations: {} - # # nfs.io/path: "/" - # labels: {} - # spec: - # # storageClassName: - # accessModes: - # - ReadWriteMany - # resources: - # requests: - # storage: 1Mi - ### - # - - # apiVersion: v1 - # kind: ServiceAccount - # metadata: - # name: <{{ $vars.releaseName }}> - # secrets: - # - name: <{{ $vars.releaseName }}> - # - - # apiVersion: rbac.authorization.k8s.io/v1 - # kind: ClusterRole - # metadata: - # name: <{{ $vars.releaseName }}> - # rules: - # - apiGroups: - # - "" - # resources: - # - namespaces - # - pods - # - nodes - # verbs: - # - get - # - list - # - - # apiVersion: rbac.authorization.k8s.io/v1 - # kind: ClusterRoleBinding - # metadata: - # name: <{{ $vars.releaseName }}> - # roleRef: - # apiGroup: rbac.authorization.k8s.io - # kind: ClusterRole - # name: <{{ $vars.releaseName }}> - # subjects: - # - kind: ServiceAccount - # name: <{{ $vars.releaseName }}> - # namespace: default + location <{{ $vars.routePrefix }}> { + alias /usr/share/nginx/html/; + } + } +### +# - +# kind: PersistentVolumeClaim +# apiVersion: v1 +# metadata: +# name: "<{{ $vars.releaseName }}>-data" +# annotations: {} +# # nfs.io/path: "/" +# labels: {} +# spec: +# # storageClassName: +# accessModes: +# - ReadWriteMany +# resources: +# requests: +# storage: 1Mi +### +# - +# apiVersion: v1 +# kind: ServiceAccount +# metadata: +# name: <{{ $vars.releaseName }}> +# secrets: +# - name: <{{ $vars.releaseName }}> +# - +# apiVersion: rbac.authorization.k8s.io/v1 +# kind: ClusterRole +# metadata: +# name: <{{ $vars.releaseName }}> +# rules: +# - apiGroups: +# - "" +# resources: +# - namespaces +# - pods +# - nodes +# verbs: +# - get +# - list +# - +# apiVersion: rbac.authorization.k8s.io/v1 +# kind: ClusterRoleBinding +# metadata: +# name: <{{ $vars.releaseName }}> +# roleRef: +# apiGroup: rbac.authorization.k8s.io +# kind: ClusterRole +# name: <{{ $vars.releaseName }}> +# subjects: +# - kind: ServiceAccount +# name: <{{ $vars.releaseName }}> +# namespace: default diff --git a/pyproject.toml b/pyproject.toml index 3ee8016..6906d77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ ] [project.optional-dependencies] +# extras = [] [dependency-groups] dev = [ @@ -98,10 +99,10 @@ select = [ "E", # pycodestyle errors "EM", # flake8-errmsg "F", # pyflakes - "FIX", # pyflakes + "FAST", # FastAPI "FLY", # pyflakes "G", # flake8-logging-format - "I", # flake8-logging-format + "I", # isort "ISC", # flake8-implicit-str-concat "PERF", # flake8-pie "PIE", # flake8-pie @@ -119,27 +120,19 @@ select = [ "TRY", # tryceratops "UP", # pyupgrade "W", # pycodestyle warnings - # "ANN", # flake8-annotations - # "D", # pydocstyle - # "ERA", # eradicate - # "I", # isort (missing-required-import) ] ignore = [ "E501", # line too long, handled by black + "PLR0904", # too many class methods "PLR0912", # too many branches "PLR0913", # too many arguments "PLR0915", # too many statements "PLR0917", # too many positional arguments + "PLR1702", # too many nested blocks "PLR2004", # magic value used in comparison ] -fixable = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "COM", # commas - "F401", # unused-imports - "I001", # unsorted-imports -] -unfixable = [] +# fixable = [] +# unfixable = [] [tool.tox] legacy_tox_ini = """ @@ -148,6 +141,7 @@ minversion = 4.0 [testenv] passenv = * recreate = true +# extras = extras dependency_groups = tests commands = python -m pytest {posargs} --cov=./src --cov-report=term diff --git a/tests/conftest.py b/tests/conftest.py index 79de926..f777945 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os import re import shutil diff --git a/uv.lock b/uv.lock index 2669cc1..47c6bc9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -requires-python = ">=3.9.1" +requires-python = ">=3.9.0" [[package]] name = "anyio" @@ -27,7 +27,6 @@ wheels = [ [[package]] name = "argparse-tui" -version = "0.2.4" source = { editable = "." } dependencies = [ { name = "textual" }, @@ -45,6 +44,7 @@ tests = [ { name = "mockish" }, { name = "packaging" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-custom-exit-code" }, { name = "pytest-env" }, @@ -70,6 +70,7 @@ tests = [ { name = "mockish", editable = "../mockish" }, { name = "packaging", specifier = "==23.*" }, { name = "pytest", specifier = "==8.*" }, + { name = "pytest-asyncio", specifier = "==0.24.*" }, { name = "pytest-cov", specifier = "==5.*" }, { name = "pytest-custom-exit-code", specifier = "==0.3.*" }, { name = "pytest-env", specifier = "==1.*" }, @@ -476,7 +477,7 @@ name = "ipykernel" version = "6.29.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "appnope", marker = "platform_system == 'Darwin'" }, + { name = "appnope", marker = "sys_platform == 'darwin'" }, { name = "comm" }, { name = "debugpy" }, { name = "ipython" }, @@ -732,7 +733,6 @@ wheels = [ [[package]] name = "mockish" -version = "0.1.2" source = { editable = "../mockish" } dependencies = [ { name = "httpx" }, @@ -796,7 +796,7 @@ name = "pdbp" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "pygments" }, { name = "tabcompleter" }, ] @@ -924,6 +924,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, ] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, +] + [[package]] name = "pytest-cov" version = "5.0.0" @@ -1204,7 +1216,7 @@ name = "tabcompleter" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyreadline3", marker = "platform_system == 'Windows'" }, + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/73/1a/ed3544579628c5709bae6fae2255e94c6982a9ff77d42d8ba59fd2f3b21a/tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814", size = 10431 } wheels = [ @@ -1222,7 +1234,7 @@ wheels = [ [[package]] name = "textual" -version = "0.85.2" +version = "0.89.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", extra = ["linkify", "plugins"] }, @@ -1230,9 +1242,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/69/8b2c90ef5863b67f2adb067772b259412130a10c7080e1fede39c6245f73/textual-0.85.2.tar.gz", hash = "sha256:2a416995c49d5381a81d0a6fd23925cb0e3f14b4f239ed05f35fa3c981bb1df2", size = 1462599 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/cb/b3ff0e45d812997a527cb581a4cd602f0b28793450aa26201969fd6ce42c/textual-0.89.1.tar.gz", hash = "sha256:66befe80e2bca5a8c876cd8ceeaf01752267b6b1dc1d0f73071f1f1e15d90cc8", size = 1517074 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/f0/29bd25c7cd53f2b53bc0205a936b5d3a37c88a70bb91037c939d313d8462/textual-0.85.2-py3-none-any.whl", hash = "sha256:9ccdeb6b8a6a0ff72d497f714934f2e524f2eb67783b459fb08b1339ee537dc0", size = 614939 }, + { url = "https://files.pythonhosted.org/packages/8e/02/650adf160774a43c206011d23283d568d2dbcd43cf7b40dff0a880885b47/textual-0.89.1-py3-none-any.whl", hash = "sha256:0a5d214df6e951b4a2c421e13d0b608482882471c1e34ea74a3631adede8054f", size = 656019 }, ] [[package]] From 8d730e149b166bbd95790ac6f33761b81a9d35c9 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sat, 1 Feb 2025 00:00:00 +0000 Subject: [PATCH 03/11] feat: support `VersionAction` and Yapx's `DummyArgAction` --- src/argparse_tui/argparse.py | 96 +++++++++++++++++++++++------------- src/argparse_tui/tui.py | 2 +- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/src/argparse_tui/argparse.py b/src/argparse_tui/argparse.py index 236895f..6a5f1d6 100644 --- a/src/argparse_tui/argparse.py +++ b/src/argparse_tui/argparse.py @@ -40,10 +40,10 @@ def process_command( param_types: dict[str, type[Any]] | None = getattr(parser, "_dest_type", None) for param in parser._actions: - if isinstance(param, TuiAction) or argparse.SUPPRESS in [ - param.help, - param.default, - ]: + if ( + isinstance(param, (TuiAction, argparse._HelpAction)) + or param.help is argparse.SUPPRESS + ): continue if isinstance(param, argparse._SubParsersAction): @@ -65,28 +65,34 @@ def process_command( if param_types: param_type = param_types.get(param.dest, param.type) - if param_type is None and param.default is not None: - param_type = type(param.default) + param_default_value: Any = param.default + if param_default_value is argparse.SUPPRESS: + param_default_value = None + if param_type is None and param_default_value is not None: + param_type = type(param_default_value) + + is_passthru: bool = False is_counting: bool = False is_multiple: bool = False is_flag: bool = False - opts: list[str] = param.option_strings + opts: list[str] = list(param.option_strings) secondary_opts: list[str] = [] if isinstance(param, argparse._CountAction): is_counting = True - elif isinstance(param, argparse._StoreConstAction): + elif isinstance( + param, + (argparse._StoreConstAction, argparse._VersionAction), + ): is_flag = True - elif ( - sys.version_info >= (3, 9) - and isinstance(param, argparse.BooleanOptionalAction) - ) or type(param).__name__ == "BooleanOptionalAction": - # check the type by name, because 'BooleanOptionalAction' - # is often manually backported to Python versions < 3.9. - if param_type is None: - param_type = bool + elif type(param).__name__ == "DummyArgAction": + # Dummy args are specific to `yapx` + is_passthru = True + elif (isinstance(param, argparse.BooleanOptionalAction)) or type( + param, + ).__name__ == "BooleanOptionalAction": is_flag = True if hasattr(param, "_negation_option_strings"): @@ -102,36 +108,60 @@ def process_command( ] secondary_opts = [x for x in param.option_strings if x not in opts] + if is_flag and param_type is None: + param_type = bool + nargs: int = ( 0 - if param.nargs is None and is_flag + if param.nargs in {None, argparse.SUPPRESS} and is_flag else 1 if param.nargs is None or param.nargs == "?" else -1 - if param.nargs in ["+", "*", argparse.REMAINDER] + if ( + is_passthru + or param.nargs + in { + "+", + "*", + argparse.REMAINDER, + argparse.ONE_OR_MORE, + argparse.ZERO_OR_MORE, + } + ) else int(param.nargs) ) - multi_value: bool = nargs < 0 or nargs > 1 + # Does this single parameter accept multiple values? + # e.g., `--foo bar baz buz` + multi_value: bool = is_passthru or nargs < 0 or nargs > 1 + # Can this parameter be specific multiple times? + # e.g., `--foo bar --foo baz --foo buz` if isinstance(param, argparse._AppendAction) and nargs <= 1: # TODO: support 'append' action params with nargs > 1. is_multiple = True - # look for these "tags" in the help text: "secret" + # Look for these "tags" in the help text: "secret" # if present, set variables and remove from the help text. is_secret: bool = False param_help: str | None = param.help if param_help: - param_help = param_help.replace("%(default)s", str(param.default)) + param_help = param_help.replace("%(default)s", str(param_default_value)) is_secret = "" in param_help is_required: bool = ( param.required - and param.default is None - and param.nargs not in ["?", "*", argparse.REMAINDER] + and param_default_value is None + and param.nargs + not in {"?", "*", argparse.REMAINDER, argparse.ZERO_OR_MORE} and nargs != 0 ) + param_value = ( + value_overrides.pop("__unknown_args__", None) + if is_passthru + else value_overrides.pop(param.dest, None) + ) + if param.option_strings: option_data = OptionSchema( name=opts, @@ -140,8 +170,8 @@ def process_command( counting=is_counting, secondary_opts=secondary_opts, required=is_required, - default=param.default, - value=value_overrides.get(param.dest), + default=param_default_value, + value=param_value, help=param_help, choices=param.choices, multiple=is_multiple, @@ -156,8 +186,8 @@ def process_command( name=param.dest, type=param_type, required=is_required, - default=param.default, - value=value_overrides.get(param.dest), + default=param_default_value, + value=param_value, help=param_help, choices=param.choices, multiple=is_multiple, @@ -210,8 +240,8 @@ def build_tui( ``` """ - subcmd_args: list[str] - parsed_args: dict[str, str] + subcmd_args: list[str] = [] + parsed_args: dict[str, str] = {} if cli_args: # Make all args optional @@ -260,11 +290,9 @@ def _set_actions_optional( ) with suppress(SystemExit): - namespace, _unknown_args = parser_copy.parse_known_args(cli_args) - parsed_args = vars(namespace) - else: - subcmd_args = [] - parsed_args = {} + namespace, unknown_args = parser_copy.parse_known_args(cli_args) + parsed_args: dict[str, str | list[str]] = vars(namespace) + parsed_args["__unknown_args__"] = unknown_args schemas = introspect_argparse_parser( parser, diff --git a/src/argparse_tui/tui.py b/src/argparse_tui/tui.py index 0de644f..1c47127 100644 --- a/src/argparse_tui/tui.py +++ b/src/argparse_tui/tui.py @@ -6,7 +6,7 @@ from contextlib import suppress from pathlib import Path from subprocess import run -from typing import Any, Optional +from typing import Any from collections.abc import Sequence from webbrowser import open as open_url From 48e2280e119e6cfc22e8455a02bf565183cbcb4b Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sat, 1 Feb 2025 00:00:00 +0000 Subject: [PATCH 04/11] feat: preserve argparse arg group titles and order --- src/argparse_tui/argparse.py | 23 +++++++++++++--- src/argparse_tui/schemas.py | 11 +++++--- src/argparse_tui/widgets/form.py | 46 ++++++++++++++++++++++++-------- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/argparse_tui/argparse.py b/src/argparse_tui/argparse.py index 6a5f1d6..2d16f06 100644 --- a/src/argparse_tui/argparse.py +++ b/src/argparse_tui/argparse.py @@ -36,10 +36,18 @@ def process_command( parent=parent, ) - # this is specific to yapx. + # This is specific to yapx. param_types: dict[str, type[Any]] | None = getattr(parser, "_dest_type", None) - for param in parser._actions: + param_groups: list[tuple[int, str, argparse.Action]] = [ + (i, (x.title or "Untitled").title(), action) + for i, x in enumerate(parser._action_groups) + for action in x._group_actions + ] + + for i, param_group in enumerate(param_groups): + param_group_weight, param_group_title, param = param_group + if ( isinstance(param, (TuiAction, argparse._HelpAction)) or param.help is argparse.SUPPRESS @@ -145,7 +153,10 @@ def process_command( is_secret: bool = False param_help: str | None = param.help if param_help: - param_help = param_help.replace("%(default)s", str(param_default_value)) + param_help = param_help.replace( + "%(default)s", + str(param_default_value), + ) is_secret = "" in param_help is_required: bool = ( @@ -178,6 +189,9 @@ def process_command( multi_value=multi_value, nargs=nargs, secret=is_secret, + weight=i, + group_title=param_group_title, + group_weight=param_group_weight, ) cmd_data.options.append(option_data) @@ -194,6 +208,9 @@ def process_command( multi_value=multi_value, nargs=nargs, secret=is_secret, + weight=i, + group_title=param_group_title, + group_weight=param_group_weight, ) cmd_data.arguments.append(argument_data) diff --git a/src/argparse_tui/schemas.py b/src/argparse_tui/schemas.py index f8acc0c..1b6273c 100644 --- a/src/argparse_tui/schemas.py +++ b/src/argparse_tui/schemas.py @@ -1,10 +1,10 @@ from __future__ import annotations import uuid +from collections.abc import Iterable, Sequence from dataclasses import dataclass, field from functools import partial from typing import Any, NewType, Type -from collections.abc import Iterable, Sequence def generate_unique_id(): @@ -44,9 +44,9 @@ def __post_init__(self): @dataclass class ArgumentSchema: name: str | list[str] - type: type[Any] | Sequence[type[Any]] | None = None # noqa: A003 + type: type[Any] | Sequence[type[Any]] | None = None required: bool = False - help: str | None = None # noqa: A003 + help: str | None = None key: str | tuple[str] = field(default_factory=generate_unique_id) default: MultiValueParamData | Any | None = None value: MultiValueParamData | Any | None = None @@ -57,6 +57,9 @@ class ArgumentSchema: secret: bool = False read_only: bool = False placeholder: str = "" + weight: int = 0 + group_title: str = "Arguments" + group_weight: int = 0 def __post_init__(self): if not isinstance(self.default, MultiValueParamData): @@ -102,6 +105,8 @@ class OptionSchema(ArgumentSchema): is_flag: bool = False counting: bool = False secondary_opts: list[str] | None = None + group_title: str = "Options" + group_weight: int = 1 @dataclass diff --git a/src/argparse_tui/widgets/form.py b/src/argparse_tui/widgets/form.py index ed3f451..64fe028 100644 --- a/src/argparse_tui/widgets/form.py +++ b/src/argparse_tui/widgets/form.py @@ -1,6 +1,7 @@ from __future__ import annotations import dataclasses +from collections import defaultdict from textual import on from textual.app import ComposeResult @@ -117,18 +118,41 @@ def compose(self) -> ComposeResult: ) if is_inherited: v.border_title += " [dim not bold](inherited)" - if arguments: - yield Label("Arguments", classes="command-form-heading") - for argument in arguments: - controls = ParameterControls(argument, id=argument.key) - if self.first_control is None: - self.first_control = controls - yield controls - if options: - yield Label("Options", classes="command-form-heading") - for option in options: - controls = ParameterControls(option, id=option.key) + param_groups_with_weight: dict[ + str, + tuple[int, list[ArgumentSchema]], + ] = defaultdict(lambda: (0, [])) + + for params_of_type in [arguments, options]: + if params_of_type: + for param in params_of_type: + group_weight, group_params = ( + param_groups_with_weight[param.group_title] + ) + group_params.append(param) + param_groups_with_weight[param.group_title] = ( + max(group_weight, param.group_weight), + group_params, + ) + + # Sort `param_groups` by `group_weight`, + # and sort within `param_groups` by `weight`. + param_groups: dict[str, list[ArgumentSchema]] = { + k: sorted(v[1], key=lambda x: x.weight) + for k, v in sorted( + param_groups_with_weight.items(), + key=lambda kv: kv[1][0], + ) + } + + for group_title, group_params in param_groups.items(): + yield Label(group_title, classes="command-form-heading") + for param in group_params: + controls = ParameterControls( + param, + id=param.key, + ) if self.first_control is None: self.first_control = controls yield controls From 6dee9494c1c4ae06684d5d630ac472d612f13ed8 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Wed, 5 Feb 2025 00:00:00 +0000 Subject: [PATCH 05/11] fix: don't display label for boolean parameters --- src/argparse_tui/widgets/parameter_controls.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/argparse_tui/widgets/parameter_controls.py b/src/argparse_tui/widgets/parameter_controls.py index daff4cf..57ce165 100644 --- a/src/argparse_tui/widgets/parameter_controls.py +++ b/src/argparse_tui/widgets/parameter_controls.py @@ -116,6 +116,8 @@ def compose(self) -> ComposeResult: is_option = isinstance(schema, OptionSchema) nargs = schema.nargs + assert isinstance(argument_type, list) + label = self._make_command_form_control_label( name, argument_type, @@ -130,7 +132,7 @@ def compose(self) -> ComposeResult: # If there are N defaults, we render the "group" N times. # Each group will contain `nargs` widgets. with ControlGroupsContainer(): - if argument_type is not bool: + if not any(x is bool for x in argument_type): yield Label(label, classes="command-form-label") if schema.choices and multiple: From c74e106b350ffbef763fc84ae49cfa3dd938e683 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sat, 15 Mar 2025 00:00:00 +0000 Subject: [PATCH 06/11] chore: bump `textual>=2.1.2,<3` and refactor --- pyproject.toml | 3 +-- src/argparse_tui/schemas.py | 15 ++++++++++++--- src/argparse_tui/tui.py | 7 +++---- src/argparse_tui/widgets/parameter_controls.py | 7 ++++--- uv.lock | 14 ++++++-------- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6906d77..c5e5bd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,7 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "typing-extensions; python_version<'3.10'", - "textual>=0.61.0,<1", + "textual>=2.1.2,<3", ] [project.optional-dependencies] diff --git a/src/argparse_tui/schemas.py b/src/argparse_tui/schemas.py index 1b6273c..c5b5c35 100644 --- a/src/argparse_tui/schemas.py +++ b/src/argparse_tui/schemas.py @@ -4,7 +4,9 @@ from collections.abc import Iterable, Sequence from dataclasses import dataclass, field from functools import partial -from typing import Any, NewType, Type +from typing import Any, NewType + +from textual.content import Content def generate_unique_id(): @@ -46,7 +48,7 @@ class ArgumentSchema: name: str | list[str] type: type[Any] | Sequence[type[Any]] | None = None required: bool = False - help: str | None = None + help: Content.ContentType | None = None key: str | tuple[str] = field(default_factory=generate_unique_id) default: MultiValueParamData | Any | None = None value: MultiValueParamData | Any | None = None @@ -62,6 +64,9 @@ class ArgumentSchema: group_weight: int = 0 def __post_init__(self): + if self.help and isinstance(self.help, str): + self.help = Content.from_markup("$text", text=self.help) + if not isinstance(self.default, MultiValueParamData): self.default = MultiValueParamData.process_cli_option(self.default) @@ -112,13 +117,17 @@ class OptionSchema(ArgumentSchema): @dataclass class CommandSchema: name: CommandName - docstring: str | None = None + docstring: Content.ContentType | None = None key: str = field(default_factory=generate_unique_id) options: list[OptionSchema] = field(default_factory=list) arguments: list[ArgumentSchema] = field(default_factory=list) subcommands: dict[CommandName, CommandSchema] = field(default_factory=dict) parent: CommandSchema | None = None + def __post_init__(self) -> None: + if self.docstring and isinstance(self.docstring, str): + self.docstring = Content.from_markup("$text", text=self.docstring) + @property def path_from_root(self) -> list[CommandSchema]: node = self diff --git a/src/argparse_tui/tui.py b/src/argparse_tui/tui.py index 1c47127..f6bee96 100644 --- a/src/argparse_tui/tui.py +++ b/src/argparse_tui/tui.py @@ -3,11 +3,12 @@ import os import shlex import sys +from collections.abc import Sequence from contextlib import suppress +from importlib import metadata from pathlib import Path from subprocess import run from typing import Any -from collections.abc import Sequence from webbrowser import open as open_url from rich.console import Console @@ -35,8 +36,6 @@ from .widgets.form import CommandForm from .widgets.multiple_choice import NonFocusableVerticalScroll -from importlib import metadata - class CommandBuilder(Screen[None]): COMPONENT_CLASSES = {"version-string", "prompt", "command-name-syntax"} @@ -197,7 +196,7 @@ def _update_command_description(self, command: CommandSchema) -> None: based on the currently selected node in the command tree.""" description_box = self.query_one("#home-command-description", Static) description_text = command.docstring or "" - description_text = description_text.lstrip() + description_text = str(description_text).lstrip() description_text = f"[b]{command.name if self.is_grouped_cli else self.app_name}[/]\n{description_text}" description_box.update(description_text) diff --git a/src/argparse_tui/widgets/parameter_controls.py b/src/argparse_tui/widgets/parameter_controls.py index 57ce165..7f7de89 100644 --- a/src/argparse_tui/widgets/parameter_controls.py +++ b/src/argparse_tui/widgets/parameter_controls.py @@ -9,6 +9,7 @@ from textual import on from textual.app import ComposeResult from textual.containers import Horizontal, Vertical +from textual.content import Content from textual.css.query import NoMatches from textual.widget import Widget from textual.widgets import ( @@ -68,7 +69,7 @@ def apply_filter(self, filter_query: str) -> bool: Returns: True if the filter matched (and the widget is visible). """ - help_text = getattr(self.schema, "help", "") or "" + help_text: Content.ContentType | None = getattr(self.schema, "help", "") or "" if not filter_query: should_be_visible = True self.display = should_be_visible @@ -83,7 +84,7 @@ def apply_filter(self, filter_query: str) -> bool: name_contains_query = any( filter_query in name.casefold() for name in self.schema.name ) - help_contains_query = filter_query in help_text.casefold() + help_contains_query = filter_query in str(help_text).casefold() should_be_visible = name_contains_query or help_contains_query self.display = should_be_visible @@ -92,7 +93,7 @@ def apply_filter(self, filter_query: str) -> bool: if help_text: try: help_label = self.query_one(".command-form-control-help-text", Static) - new_help_text = Text(help_text) + new_help_text = Text(str(help_text)) new_help_text.highlight_words( filter_query.split(), "black on yellow", diff --git a/uv.lock b/uv.lock index 47c6bc9..6f6e875 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.9.0" [[package]] @@ -30,7 +31,6 @@ name = "argparse-tui" source = { editable = "." } dependencies = [ { name = "textual" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] [package.dev-dependencies] @@ -54,10 +54,7 @@ tests = [ ] [package.metadata] -requires-dist = [ - { name = "textual", specifier = ">=0.61.0,<1" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, -] +requires-dist = [{ name = "textual", specifier = ">=2.1.2,<3" }] [package.metadata.requires-dev] dev = [ @@ -763,6 +760,7 @@ requires-dist = [ { name = "requests", specifier = "==2.*" }, { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] +provides-extras = ["dev", "docs", "tests"] [[package]] name = "nest-asyncio" @@ -1234,7 +1232,7 @@ wheels = [ [[package]] name = "textual" -version = "0.89.1" +version = "2.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", extra = ["linkify", "plugins"] }, @@ -1242,9 +1240,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/cb/b3ff0e45d812997a527cb581a4cd602f0b28793450aa26201969fd6ce42c/textual-0.89.1.tar.gz", hash = "sha256:66befe80e2bca5a8c876cd8ceeaf01752267b6b1dc1d0f73071f1f1e15d90cc8", size = 1517074 } +sdist = { url = "https://files.pythonhosted.org/packages/41/62/4af4689dd971ed4fb3215467624016d53550bff1df9ca02e7625eec07f8b/textual-2.1.2.tar.gz", hash = "sha256:aae3f9fde00c7440be00e3c3ac189e02d014f5298afdc32132f93480f9e09146", size = 1596600 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/02/650adf160774a43c206011d23283d568d2dbcd43cf7b40dff0a880885b47/textual-0.89.1-py3-none-any.whl", hash = "sha256:0a5d214df6e951b4a2c421e13d0b608482882471c1e34ea74a3631adede8054f", size = 656019 }, + { url = "https://files.pythonhosted.org/packages/07/81/9df1988c908cbba77f10fecb8587496b3dff2838d4510457877a521d87fd/textual-2.1.2-py3-none-any.whl", hash = "sha256:95f37f49e930838e721bba8612f62114d410a3019665b6142adabc14c2fb9611", size = 680148 }, ] [[package]] From 810158639e959108f575a38f52ab3c7dfa24cf15 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sun, 16 Mar 2025 00:00:00 +0000 Subject: [PATCH 07/11] feat: press `q` or `Q` to exit --- src/argparse_tui/tui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/argparse_tui/tui.py b/src/argparse_tui/tui.py index f6bee96..58eab11 100644 --- a/src/argparse_tui/tui.py +++ b/src/argparse_tui/tui.py @@ -56,6 +56,7 @@ class CommandBuilder(Screen[None]): Binding(key="ctrl+s,i,/", action="app.focus('search')", description="Search"), Binding(key="f1", action="about", description="About"), Binding("q", "exit", show=False), + Binding("Q", "exit", show=False), ] def __init__( From 85a18f95d44fd6903a3bd37560edf49ec0fbf56f Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sun, 16 Mar 2025 00:00:00 +0000 Subject: [PATCH 08/11] feat: set default textual theme to `catppuccin-mocha` --- src/argparse_tui/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/argparse_tui/__init__.py b/src/argparse_tui/__init__.py index 24984fb..444eaff 100644 --- a/src/argparse_tui/__init__.py +++ b/src/argparse_tui/__init__.py @@ -1,3 +1,9 @@ +import os + +if "TEXTUAL_THEME" not in os.environ: + os.environ["TEXTUAL_THEME"] = "catppuccin-mocha" + + from .__version__ import __version__ from .argparse import ( TuiAction, @@ -9,11 +15,11 @@ from .tui import Tui __all__ = [ + "Tui", + "TuiAction", "__version__", "add_tui_argument", "add_tui_command", "build_tui", "invoke_tui", - "Tui", - "TuiAction", ] From 79d6753e37b4ce2967065175733e986ccd623411 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sat, 15 Mar 2025 00:00:00 +0000 Subject: [PATCH 09/11] docs: update --- CHANGELOG.md | 81 ++++++++++++++++++++++-------------- src/argparse_tui/argparse.py | 72 ++++++++++++++++++++------------ 2 files changed, 95 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbfdb26..e505c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## 0.4.0 - 2025-03-16 + +**Full Changelog**: https://github.com/fresh2dev/argparse-tui/compare/0.3.1...0.4.0 + +### :clap: Features + +- Preserve argparse arg group titles and order +- Set default textual theme to `catppuccin-mocha` +- Press `q` or `Q` to exit +- Support `VersionAction` and Yapx's `DummyArgAction` + +### :fist: Fixes + +- Don't display label for boolean parameters + +### :metal: Other + +- Bump `textual>=2.1.2,<3` and refactor + ## 0.3.1 - 2024-11-26 **Full Changelog**: https://github.com/fresh2dev/argparse-tui/compare/0.3.0...0.3.1 @@ -75,34 +94,34 @@ All notable changes to this project will be documented in this file. ### :fist: Fixes -- Type lookup when given a partial with kwargs \[8c48e8c\] -- Enforce proper handling of is_flag arguments \[450cc5e\] +- Type lookup when given a partial with kwargs [8c48e8c] +- Enforce proper handling of is_flag arguments [450cc5e] ### :point_right: Changes -- Use more generic type-hints \[016d634\] -- Pin textual \< 1 \[f17b432\] +- Use more generic type-hints [016d634] +- Pin textual < 1 [f17b432] ## 0.2.1 - 2023-08-24 ### :point_right: Changes -- Derive root cmd name from parser.prog \[52d6a65\] -- Remove 'group' label \[45dc5d4\] +- Derive root cmd name from parser.prog [52d6a65] +- Remove 'group' label [45dc5d4] ### :fist: Fixes -- Correct logic for required arguments \[7e818a6\] +- Correct logic for required arguments [7e818a6] ## 0.2.0 - 2023-07-29 ### :clap: Features -- Passthru command-line values to TUI form \[4ed259f\] +- Passthru command-line values to TUI form [4ed259f] ### :point_right: Changes -- *Breaking:* Remove support for mandatory prompts \[76beab4\] +- *Breaking:* Remove support for mandatory prompts [76beab4] ______________________________________________________________________ @@ -110,13 +129,13 @@ ______________________________________________________________________ ### :clap: Features -- Copy command to clipboard before running \[1d03f57\] +- Copy command to clipboard before running [1d03f57] ### :point_right: Changes -- `add_command` will now return the subparser \[5fe982d\] -- Suppress error if clipboard copy fails \[6e00995\] -- Rename example \[bd245fe\] +- `add_command` will now return the subparser [5fe982d] +- Suppress error if clipboard copy fails [6e00995] +- Rename example [bd245fe] ______________________________________________________________________ @@ -124,12 +143,12 @@ ______________________________________________________________________ ### :clap: Features -- `invoke_tui` \[554827b\] +- `invoke_tui` [554827b] ### :fist: Fixes -- Handle argparse.REMAINDER \[5447b92\] -- Positional arguments go after options in the generated command \[0a91a2e\] +- Handle argparse.REMAINDER [5447b92] +- Positional arguments go after options in the generated command [0a91a2e] ______________________________________________________________________ @@ -137,32 +156,32 @@ ______________________________________________________________________ ### :fist: Fixes -- Only accept root ArgumentParser in `add_tui_command` \[6f124f0\] -- Correct image name in docker-compose \[bcbfcec\] +- Only accept root ArgumentParser in `add_tui_command` [6f124f0] +- Correct image name in docker-compose [bcbfcec] ______________________________________________________________________ ## 0.1.0 - 2023-07-22 -- Init fork as argparse-tui :rocket: \[367bc96\] +- Init fork as argparse-tui :rocket: [367bc96] ### :clap: Features -- Argparse support \[394d4dc\] -- Ctrl+y to copy command \[99d24f2\] -- Add hacker keybindings \[262f231\] -- Redact sensitive values \[ca0df16\] -- Support click prompt_required \[48809bf\] -- Omit hidden parameters and subcommands \[0a7ed9d\] -- Support typer \[705e432\] -- *Breaking:* Refactor to make click optional \[91db7dd\] +- Argparse support [394d4dc] +- Ctrl+y to copy command [99d24f2] +- Add hacker keybindings [262f231] +- Redact sensitive values [ca0df16] +- Support click prompt_required [48809bf] +- Omit hidden parameters and subcommands [0a7ed9d] +- Support typer [705e432] +- *Breaking:* Refactor to make click optional [91db7dd] ### :fist: Fixes -- Regain feature parity with trogon/main \[ca15753\] -- Fixed command info when no docstring is present \[d434653\] -- Fixed help_text if self.schema.help is none \[f42f995\] +- Regain feature parity with trogon/main [ca15753] +- Fixed command info when no docstring is present [d434653] +- Fixed help_text if self.schema.help is none [f42f995] ### :point_right: Changes -- Generate `post_run_command` on-closed, not on-changed \[afd8883\] +- Generate `post_run_command` on-closed, not on-changed [afd8883] diff --git a/src/argparse_tui/argparse.py b/src/argparse_tui/argparse.py index 2d16f06..dfdb151 100644 --- a/src/argparse_tui/argparse.py +++ b/src/argparse_tui/argparse.py @@ -232,13 +232,17 @@ def build_tui( ) -> App: """Build a Textual UI (TUI) given an argparse parser. + Creates a Textual App that presents a form-based interface for the given + argparse parser. The TUI allows users to interactively set argument values + through a user-friendly interface instead of command line flags. + Args: - parser: ... - cli_args: Arguments parsed for pre-populating the TUI form. - subparser_ignorelist: ... + parser: The argparse parser to build a TUI for + cli_args: Arguments parsed for pre-populating the TUI form fields + subparser_ignorelist: List of subparsers to exclude from the TUI Returns: - a Textualize App + A Textual App instance Examples: ```python @@ -327,10 +331,13 @@ def invoke_tui( ) -> None: """Invoke a Textual UI (TUI) given an argparse parser. + Builds and runs a TUI for the given argparse parser. This is a convenience + function that combines build_tui() and app.run() in one call. + Args: - parser: ... - cli_args: Arguments parsed for pre-populating the TUI form. - subparser_ignorelist: ... + parser: The argparse parser to create a TUI for + cli_args: Arguments parsed for pre-populating the TUI form fields + subparser_ignorelist: List of subparsers to exclude from the TUI Examples: ```python @@ -355,14 +362,18 @@ def invoke_tui( class TuiAction(argparse.Action): """argparse `Action` that will analyze the parser and display a TUI. + When this action is triggered during argument parsing, it will + launch a Textual UI for the parser. This allows adding a '--tui' + flag to any argparse-based CLI to provide an interactive interface. + Args: - option_strings: ... - dest: ... - default: ... - help: ... - const: ... - metavar: ... - parent_parser: ... + option_strings: The command-line flags that trigger this action + dest: The attribute name to store the result in + default: The default value if the argument is not present + help: The help text for this argument + const: The constant value for this action + metavar: The name to use in usage messages + parent_parser: The parent parser if this is in a subparser Examples: ```python @@ -434,15 +445,18 @@ def add_tui_argument( default=argparse.SUPPRESS, **kwargs, ) -> None: - """ + """Add a TUI argument to an existing argparse parser. + + This function adds a flag (like --tui) to the parser that, when specified, + will launch a Textual UI for configuring the command arguments. Args: - parser: the argparse parser to add the argument to. - parent_parser: the parent of the given parser. - option_strings: list of CLI flags that will invoke the TUI - help: ... - default: ... - **kwargs: passed to `parser.add_argument(...)` + parser: The argparse parser to add the argument to + parent_parser: The parent of the given parser, if this is a subparser + option_strings: List of CLI flags that will invoke the TUI (default: --tui) + help: Help text for the TUI argument + default: Default value for the argument + **kwargs: Additional keyword arguments passed to `parser.add_argument(...)` Examples: ```python @@ -475,16 +489,20 @@ def add_tui_command( help: str = "Open Textual UI.", # pylint: disable=redefined-builtin # noqa: A002 **kwargs: Any, ) -> argparse._SubParsersAction: - """ + """Add a TUI subcommand to an existing argparse parser. + + This function adds a subcommand (like 'tui') to the parser that, when invoked, + will launch a Textual UI for configuring the command arguments. This is useful + for CLI applications that want to offer both command-line and TUI interfaces. Args: - parser: the argparse parser - command: name of the CLI command that will invoke the TUI (default=`tui`) - help: help message for the argument - **kwargs: if subparsers do not already exist, create with these kwargs. + parser: The argparse parser to add the subcommand to + command: Name of the CLI command that will invoke the TUI (default=`tui`) + help: Help message for the subcommand + **kwargs: If subparsers do not already exist, create with these kwargs Returns: - The Argparse subparsers action that was discovered or created. + The Argparse subparsers action that was discovered or created Examples: ```python From a0e0ea145b0cf96374855fda1155fb400e4ecd12 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Tue, 18 Mar 2025 00:00:00 +0000 Subject: [PATCH 10/11] build: apply project template --- .butterstack/fresh2-dev/.answers.yml | 5 - .butterstack/fresh2-dev/values.yaml.tpl | 3 - .butterstack/fresh2-dev/vars.yaml | 1 - .butterstack/hostbutter-net/.answers.yml | 5 - .butterstack/hostbutter-net/values.yaml.tpl | 3 - .butterstack/hostbutter-net/vars.yaml | 1 - .copier-answers.common.yml | 13 ++ .copier-answers.mkdocs.yml | 12 ++ .copier-answers.python.yml | 14 ++ .copier-answers.yml | 20 --- .gitea/workflows/build-deploy-image.yaml | 169 ------------------ .../build-publish-python-package.yaml | 90 ---------- .github/FUNDING.yml | 2 +- .../build-publish-python-package.yaml | 68 +++++++ .github/workflows/test-python-package.yaml | 60 +++++++ .gitignore | 43 +++-- .pre-commit-config.yaml | 76 ++++---- Dockerfile | 20 --- LICENSE | 2 +- README.md | 28 ++- README.pypi.md | 2 +- compose.kustomization.yaml | 38 ---- compose.vars.yaml | 3 - compose.yaml.tpl | 156 ---------------- config/overrides/main.html | 2 +- docs/changelog.md | 5 + docs/index.md | 6 + docs/license.md | 6 + docs/page/examples.ipynb | 49 ----- docs/page/related.md | 1 - examples/demo_myke.py | 4 + justfile | 21 +++ mkdocs.yml | 29 +-- pyproject.toml | 65 ++++--- src/argparse_tui/__version__.py | 9 +- src/argparse_tui/_version.pyi | 1 + uv.lock | 90 +++------- wrangler.toml | 13 ++ 38 files changed, 398 insertions(+), 737 deletions(-) delete mode 100644 .butterstack/fresh2-dev/.answers.yml delete mode 100644 .butterstack/fresh2-dev/values.yaml.tpl delete mode 100644 .butterstack/fresh2-dev/vars.yaml delete mode 100644 .butterstack/hostbutter-net/.answers.yml delete mode 100644 .butterstack/hostbutter-net/values.yaml.tpl delete mode 100644 .butterstack/hostbutter-net/vars.yaml create mode 100644 .copier-answers.common.yml create mode 100644 .copier-answers.mkdocs.yml create mode 100644 .copier-answers.python.yml delete mode 100644 .copier-answers.yml delete mode 100644 .gitea/workflows/build-deploy-image.yaml delete mode 100644 .gitea/workflows/build-publish-python-package.yaml create mode 100644 .github/workflows/build-publish-python-package.yaml create mode 100644 .github/workflows/test-python-package.yaml delete mode 100644 Dockerfile delete mode 100644 compose.kustomization.yaml delete mode 100644 compose.vars.yaml delete mode 100644 compose.yaml.tpl delete mode 100644 docs/page/examples.ipynb delete mode 100644 docs/page/related.md create mode 100644 justfile create mode 100644 src/argparse_tui/_version.pyi create mode 100644 wrangler.toml diff --git a/.butterstack/fresh2-dev/.answers.yml b/.butterstack/fresh2-dev/.answers.yml deleted file mode 100644 index b54623a..0000000 --- a/.butterstack/fresh2-dev/.answers.yml +++ /dev/null @@ -1,5 +0,0 @@ -_src_path: /var/home/d/sync/projects/py/hostbutter/src/hostbutter/resources/copier-butterstack -environment: fresh2-dev -flavor: sweet -releaseName: argparse-tui-docs -releaseNamespace: default diff --git a/.butterstack/fresh2-dev/values.yaml.tpl b/.butterstack/fresh2-dev/values.yaml.tpl deleted file mode 100644 index 591f341..0000000 --- a/.butterstack/fresh2-dev/values.yaml.tpl +++ /dev/null @@ -1,3 +0,0 @@ -<{{- $vars := (ds "vars") }}> -<{{/* defineDatasource "vault" (printf "vault+https:///secret/data/releases/%s/%s" $vars.hostbutterSiteName $vars.releaseName) */}}> -# Pull vault secrets like: <{{/* index (ds "vault").data "my-file.txt" | base64.Decode */}}> diff --git a/.butterstack/fresh2-dev/vars.yaml b/.butterstack/fresh2-dev/vars.yaml deleted file mode 100644 index 590b024..0000000 --- a/.butterstack/fresh2-dev/vars.yaml +++ /dev/null @@ -1 +0,0 @@ -# Reusable variables specific to this environment and release. diff --git a/.butterstack/hostbutter-net/.answers.yml b/.butterstack/hostbutter-net/.answers.yml deleted file mode 100644 index 9c4a6a4..0000000 --- a/.butterstack/hostbutter-net/.answers.yml +++ /dev/null @@ -1,5 +0,0 @@ -_src_path: /var/home/d/sync/projects/py/hostbutter/src/hostbutter/resources/copier-butterstack -environment: hostbutter-net -flavor: sweet -releaseName: argparse-tui-docs -releaseNamespace: default diff --git a/.butterstack/hostbutter-net/values.yaml.tpl b/.butterstack/hostbutter-net/values.yaml.tpl deleted file mode 100644 index 591f341..0000000 --- a/.butterstack/hostbutter-net/values.yaml.tpl +++ /dev/null @@ -1,3 +0,0 @@ -<{{- $vars := (ds "vars") }}> -<{{/* defineDatasource "vault" (printf "vault+https:///secret/data/releases/%s/%s" $vars.hostbutterSiteName $vars.releaseName) */}}> -# Pull vault secrets like: <{{/* index (ds "vault").data "my-file.txt" | base64.Decode */}}> diff --git a/.butterstack/hostbutter-net/vars.yaml b/.butterstack/hostbutter-net/vars.yaml deleted file mode 100644 index 590b024..0000000 --- a/.butterstack/hostbutter-net/vars.yaml +++ /dev/null @@ -1 +0,0 @@ -# Reusable variables specific to this environment and release. diff --git a/.copier-answers.common.yml b/.copier-answers.common.yml new file mode 100644 index 0000000..ef0e902 --- /dev/null +++ b/.copier-answers.common.yml @@ -0,0 +1,13 @@ +# Changes here will be overwritten by Copier +_commit: a8f9ddd +_src_path: ssh://git@git.local.hostbutter.net/fresh2dev/copier-python-project.git +docs_engine: mkdocs +docs_route: /r/argparse-tui +home_domain: fresh2.dev +license_type: MIT +project_description: Present your Argparse CLI as a Textual UI (TUI). +project_name: argparse-tui +repo_host: github.com +repo_owner: fresh2dev +repo_ssh_url: git@git.local.hostbutter.net:fresh2dev/argparse-tui.git +template: common diff --git a/.copier-answers.mkdocs.yml b/.copier-answers.mkdocs.yml new file mode 100644 index 0000000..77d7277 --- /dev/null +++ b/.copier-answers.mkdocs.yml @@ -0,0 +1,12 @@ +# Changes here will be overwritten by Copier +_commit: a8f9ddd +_src_path: ssh://git@git.local.hostbutter.net/fresh2dev/copier-python-project.git +docs_engine: mkdocs +docs_route: /r/argparse-tui +home_domain: fresh2.dev +project_description: Present your Argparse CLI as a Textual UI (TUI). +project_name: argparse-tui +repo_host: github.com +repo_owner: fresh2dev +repo_ssh_url: git@git.local.hostbutter.net:fresh2dev/argparse-tui.git +template: mkdocs diff --git a/.copier-answers.python.yml b/.copier-answers.python.yml new file mode 100644 index 0000000..d7d850c --- /dev/null +++ b/.copier-answers.python.yml @@ -0,0 +1,14 @@ +# Changes here will be overwritten by Copier +_commit: a8f9ddd +_src_path: ssh://git@git.local.hostbutter.net/fresh2dev/copier-python-project.git +docs_engine: mkdocs +docs_route: /r/argparse-tui +home_domain: fresh2.dev +package_name: argparse_tui +project_description: Present your Argparse CLI as a Textual UI (TUI). +project_name: argparse-tui +python_version: '3.9' +repo_host: github.com +repo_owner: fresh2dev +repo_ssh_url: git@git.local.hostbutter.net:fresh2dev/argparse-tui.git +template: python diff --git a/.copier-answers.yml b/.copier-answers.yml deleted file mode 100644 index a75b271..0000000 --- a/.copier-answers.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Changes here will be overwritten by Copier -_commit: 0537098 -_src_path: ssh://git@git.local.hostbutter.net/fresh2dev/copier-f2dv-project.git -author_email: hello@f2dv.com -author_name: Donald Mellenbruch -docs_url: https://www.f2dv.com/r/argparse-tui -funding_url: https://www.f2dv.com/fund -home_domain: f2dv.com -home_page: https://www.f2dv.com -is_minimal: false -is_python: true -license_type: MIT -package_name: argparse_tui -project_description: Present your Argparse CLI as a Textual UI (TUI). -project_name: argparse-tui -python_version: '3.9' -repo_name: fresh2dev/argparse-tui -repo_owner: fresh2dev -repo_url: https://www.github.com/fresh2dev/argparse-tui - diff --git a/.gitea/workflows/build-deploy-image.yaml b/.gitea/workflows/build-deploy-image.yaml deleted file mode 100644 index 00f5217..0000000 --- a/.gitea/workflows/build-deploy-image.yaml +++ /dev/null @@ -1,169 +0,0 @@ ---- -name: 'Build and Publish Image' -on: - push: - branches: - - '*' - tags: - - 'v?[0-9]+.*' - -permissions: - contents: write - -jobs: - setup: - runs-on: 'ubuntu-latest' - steps: - ### Checkout - - name: Check out repository code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: 'recursive' - - # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb - # - uses: ./ - - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 - with: - github-token: ${{ secrets.EGET_GITHUB_TOKEN }} - - - run: | - kubectl cluster-info - - # find-dockerfiles: - # needs: ['setup'] - # runs-on: 'ubuntu-latest' - # outputs: - # matrix: ${{ steps.find-dockerfiles.outputs.matrix }} - # steps: - # - name: Check out repository code - # uses: actions/checkout@v4 - # - # - name: Find dockerfiles - # id: find-dockerfiles - # shell: python - # run: | - # import json - # import os - # from pathlib import Path - # base_name = "Dockerfile" - # matrix_includes = json.dumps({ - # "include": [ - # {"dockerfile": x.name, "image_suffix": x.name[len(base_name):]} - # for x in Path.cwd().glob(f"{base_name}*") - # ] - # }, separators=(",", ":")) - # with open(os.environ["GITHUB_OUTPUT"], "a") as f: - # f.write(f"matrix={matrix_includes}\n") - - build-publish-image: - needs: ['setup'] - # needs: ['setup', 'find-dockerfiles'] - runs-on: 'ubuntu-latest' - env: - PRIVATE_REGISTRY_DEV: registry.local.hostbutter.net - PRIVATE_REGISTRY_PROD: registry.fresh2.dev - strategy: - # matrix: ${{ fromJson(needs.find-dockerfiles.outputs.matrix) }} - matrix: - include: - - dockerfile: Dockerfile - image_suffix: "" - # - dockerfile: Dockerfile-docs - # image_suffix: "-docs" - fail-fast: true - steps: - ### Checkout - - name: Check out repository code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: 'recursive' - - ### Checkout - # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb - # - uses: ./ - - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 - with: - github-token: ${{ secrets.EGET_GITHUB_TOKEN }} - - - name: Dump github context - run: echo "$GITHUB_CONTEXT" - shell: bash - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - - - name: Dump env context - run: echo "$env_context" - shell: bash - env: - env_context: ${{ tojson(env) }} - - - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver: kubernetes - driver-opts: | - image=moby/buildkit:master - namespace=default - qemu.install=true - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - file: ${{ matrix.dockerfile }} - push: true - platforms: linux/amd64 #,linux/arm64 - tags: | - ${{ env.PRIVATE_REGISTRY_DEV }}/${{ github.repository }}${{ matrix.image_suffix }}:${{ env.CI_COMMIT_SHORT_SHA }} - cache-from: type=gha,url=${{ env.ACTIONS_CACHE_URL }},token=${{ env.ACTIONS_RUNTIME_TOKEN }} - cache-to: type=gha,mode=max,url=${{ env.ACTIONS_CACHE_URL }},token=${{ env.ACTIONS_RUNTIME_TOKEN }} - - - name: Push image to Prod Registry - if: > - always() - && format('refs/heads/{0}', github.event.repository.default_branch) == github.ref - run: | - docker buildx imagetools create \ - --tag ${{ env.PRIVATE_REGISTRY_PROD }}/${{ github.repository }}${{ matrix.image_suffix }}:$CI_COMMIT_SHORT_SHA \ - ${{ env.PRIVATE_REGISTRY_DEV }}/${{ github.repository }}${{ matrix.image_suffix }}:$CI_COMMIT_SHORT_SHA - - deploy-image: - needs: ['build-publish-image'] - runs-on: 'ubuntu-latest' - env: - HOSTBUTTER_SITE_DEV: hostbutter-net - HOSTBUTTER_SITE_PROD: fresh2-dev - steps: - ### Checkout - - name: Check out repository code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: 'recursive' - - ### Checkout - # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb - # - uses: ./ - - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 - with: - github-token: ${{ secrets.EGET_GITHUB_TOKEN }} - - - if: > - endsWith(github.repository, '/hostbutter') - name: Upgrade Hostbutter - run: | - pipx install -fe . - - - name: Deploy Stack(s) [dev] - run: | - butter --site $HOSTBUTTER_SITE_DEV stack up - - - name: Deploy Stack(s) - if: > - always() - && format('refs/heads/{0}', github.event.repository.default_branch) == github.ref - run: | - butter --site $HOSTBUTTER_SITE_PROD stack up - diff --git a/.gitea/workflows/build-publish-python-package.yaml b/.gitea/workflows/build-publish-python-package.yaml deleted file mode 100644 index 112b575..0000000 --- a/.gitea/workflows/build-publish-python-package.yaml +++ /dev/null @@ -1,90 +0,0 @@ ---- -name: 'Build and Publish Python Package' -on: - push: - branches: - - '*' - tags: - - 'v?[0-9]+.*' - -permissions: - contents: write - -jobs: - - test-python-package: - # needs: ['setup'] - name: Test with Python v${{ matrix.python-version }} - if: > - github.ref_type != 'tag' - && format('refs/heads/{0}', github.event.repository.default_branch) != github.ref - runs-on: 'ubuntu-latest' - strategy: - matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] - max-parallel: 3 - fail-fast: true - steps: - ### Checkout - - name: Check out repository code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: 'recursive' - - - # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb - # - uses: ./ - - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 - with: - github-token: ${{ secrets.EGET_GITHUB_TOKEN }} - - - name: Set up Python v${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Test Python Project using Python v${{ matrix.python-version }} - run: | - tox --workdir ${{ runner.temp }}/tox -e py${{ matrix.python-version }} - - build-publish-python: - # needs: ['setup'] - runs-on: 'ubuntu-latest' - env: - DIST_DIR: ${{ runner.temp }}/dist - steps: - ### Checkout - - name: Check out repository code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: 'recursive' - - # - uses: fresh2dev/hostbutter@01f6cc2ffa2f916e237c458d1641314aed3d15bb - # - uses: ./ - - uses: https://forgejo.local.hostbutter.net/fresh2dev/hostbutter@39d7f8982aa36a400d7457b1dfdfece351788b10 - with: - github-token: ${{ secrets.EGET_GITHUB_TOKEN }} - - - name: Test Python Project - run: | - tox --workdir ${{ runner.temp }}/tox -e py - - - name: Build Python Project - run: | - rm -rf $DIST_DIR - pyproject-build -o $DIST_DIR - twine check --strict $DIST_DIR/* - - - name: Publish Python Project [dev] - run: | - twine upload --verbose --non-interactive --repository dev $DIST_DIR/* - - - name: Publish Python Project - if: > - github.ref_type == 'tag' - run: | - twine upload --verbose --non-interactive --repository codeberg $DIST_DIR/* - twine upload --verbose --non-interactive --repository testpypi $DIST_DIR/* - twine upload --verbose --non-interactive --repository pypi $DIST_DIR/* diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 98e05d7..3b0501f 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -# custom: ["https://www.f2dv.com/fund/"] +custom: ["https://fresh2.dev/fund"] diff --git a/.github/workflows/build-publish-python-package.yaml b/.github/workflows/build-publish-python-package.yaml new file mode 100644 index 0000000..f47d616 --- /dev/null +++ b/.github/workflows/build-publish-python-package.yaml @@ -0,0 +1,68 @@ +--- +name: 'Build and Publish Python Package' +on: + workflow_dispatch: + push: + branches-ignore: ["*"] + tags: ["v?[0-9]+.*"] + pull_request: + +concurrency: + # group: build-${{ github.ref }} + group: build + cancel-in-progress: false + +jobs: + build-publish: + name: build with ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + python-version: + - "3.13" + os: + - ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + lfs: true + submodules: true + set-safe-directory: true + + - run: > + git fetch --prune --tags + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: ${{ matrix.python-version }} + + - run: > + git log --graph --oneline --decorate + + - run: > + uv pip list + + - name: Build the project + run: > + uv build --link-mode copy --no-sources + + - name: Publish (Test) + # env: + # UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TEST_PUBLISH_TOKEN }} + run: > + uv publish --publish-url 'https://test.pypi.org/legacy/' + + - name: Publish + if: > + github.ref_type == 'tag' + # env: + # UV_PUBLISH_TOKEN: ${{ secrets.PYPI_PUBLISH_TOKEN }} + run: > + uv publish diff --git a/.github/workflows/test-python-package.yaml b/.github/workflows/test-python-package.yaml new file mode 100644 index 0000000..dd2cc23 --- /dev/null +++ b/.github/workflows/test-python-package.yaml @@ -0,0 +1,60 @@ +--- +name: 'Run Tests' +on: + workflow_dispatch: + push: + branches-ignore: ["*"] + tags-ignore: ["*"] + pull_request: + +concurrency: + # group: test-${{ github.ref }} + group: test + cancel-in-progress: false + +jobs: + test: + name: test with ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + os: + - ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + lfs: true + submodules: true + set-safe-directory: true + + - run: > + git fetch --prune --tags + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: ${{ matrix.python-version }} + + - name: Install the project + run: > + uv sync --link-mode copy --no-sources --no-editable --all-extras --all-groups + + - name: Run tests + run: > + uv run --no-sync pytest --cov=./src --cov-report=term + + - name: Run doc tests + run: > + uv run --no-sync pytest --markdown-docs -m markdown-docs --suppress-no-test-exit-code ./src diff --git a/.gitignore b/.gitignore index 44d647c..46fa0ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,26 @@ -!**/*.gitkeep +!.gitkeep -/.devbox -/.helmwave -**/secrets -**/*.tmp -**/.vscode -**/.DS_store -/.venv +/.venv/ /requirements.txt -/dist -/build -/public +/dist/ +/build/ +/public/ +/.devbox/ +/.helmwave/ +secrets/ +*.tmp +.vscode/ +.DS_store + +src/**/_version.py *.pyc -**/.ipynb_checkpoints -**/.coverage -**/.hypothesis -**/.tox -**/.idea -**/*.egg-info -**/.mypy_cache -**/__pycache__ -**/.pytest_cache -**/.ropeproject -**/.ruff_cache +.coverage +.ipynb_checkpoints/ +.tox/ +*.egg-info/ +.mypy_cache/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ .aider* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e696157..bbbaef1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,34 +1,50 @@ --- repos: - - repo: 'https://github.com/pre-commit/pre-commit-hooks' - rev: 'v4.5.0' + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 hooks: - - id: 'check-added-large-files' - - id: 'check-executables-have-shebangs' + - id: check-added-large-files + - id: check-executables-have-shebangs # - id: check-json - - id: 'check-shebang-scripts-are-executable' - - id: 'check-merge-conflict' - - id: 'check-symlinks' - - id: 'check-toml' - - id: 'end-of-file-fixer' - - id: 'mixed-line-ending' - - id: 'trailing-whitespace' - # - repo: 'https://github.com/scop/pre-commit-shfmt' - # rev: 'v3.8.0-1' - # hooks: - # - id: 'shfmt' - # args: ['-w', '-s', '-i', '2'] - # - id: 'myke-py-format' - # name: 'myke py format' - # entry: 'myke py format' - # description: 'Format Python project.' - # language: 'python' - # types: ['python'] - # pass_filenames: false - # - id: 'myke-py-check' - # name: 'myke py check --critical' - # entry: 'myke py check --critical' - # description: 'Check Python project for critical errors.' - # language: 'python' - # types: ['python'] - # pass_filenames: false + - id: check-shebang-scripts-are-executable + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + + - repo: local + hooks: + - id: ruff-format + name: Ruff format + language: system + types: [python] + # pass_filenames: false + entry: > + uvx ruff format + + - id: pylint + name: Pylint + language: system + types: [python] + pass_filenames: false + entry: > + uvx --with pylint-venv pylint --init-hook='import pylint_venv; pylint_venv.inithook(force_venv_activation=True)' --disable=R,C,W0511 ./src + + - id: docsig + name: Lint Docstrings + language: system + types: [python] + pass_filenames: false + entry: > + uvx docsig ./src + + # - id: pyrefly + # name: Check Types + # language: system + # types: [python] + # pass_filenames: false + # entry: > + # uvx pyrefly check --output-format min-text ./src diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 2701f52..0000000 --- a/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM python:3.13-slim-bullseye as build -RUN apt-get update \ - && apt-get install --upgrade -y git build-essential gcc libssl-dev libffi-dev python3-dev -WORKDIR /workspace -COPY . /workspace -ENV PYTHONUNBUFFERED=1 -RUN python3 -m pip install 'mkdocs==1.*' \ - 'mkdocs-material>=9.5,<10' \ - 'mkdocs-jupyter' \ - 'mkdocs-include-dir-to-nav' \ - 'mkdocstrings[python]' \ - 'black' \ - 'mkdocs-autorefs' \ - 'pymdown-extensions' \ - 'pygments' - -RUN python3 -m mkdocs build -d public - -FROM nginx:1 -COPY --from=build /workspace/public /usr/share/nginx/html diff --git a/LICENSE b/LICENSE index cc12842..db683ba 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2024 Donald Mellenbruch +Copyright 2025 Donald Mellenbruch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 1d49173..7d4d313 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

argparse-tui

Present your Argparse CLI as a Textual UI (TUI).

-Documentation -| Git Repo +Documentation +| Git Repo

`argparse-tui` is a Python package that can convert your Argparse CLI into a Textual UI (TUI). It is also able to provide a TUI interface to existing command-line apps using Argparse as a declarative DSL. This library is a soft-fork of [Trogon](https://github.com/Textualize/trogon), powered by the [Textual TUI framework](https://github.com/textualize/textual), refactored for use with Python's native *Argparse*, instead of *Click*. @@ -101,25 +101,19 @@ pip install argparse-tui ## Related Projects -- [fresh2dev/TUIview](https://www.f2dv.com/r/tuiview/) is a Python CLI that uses argparse-tui TUIs for existing CLI applications. +- [fresh2dev/TUIview](https://fresh2.dev/r/tuiview/) is a Python CLI that uses argparse-tui TUIs for existing CLI applications. -- [fresh2dev/yapx](https://www.f2dv.com/r/yapx/) is a Python library for building a Python CLI from your existing Python functions, with `argparse-tui` support built-in. +- [fresh2dev/yapx](https://fresh2.dev/r/yapx/) is a Python library for building a Python CLI from your existing Python functions, with `argparse-tui` support built-in. -- [fresh2dev/myke](https://www.f2dv.com/r/myke/) is a Python CLI that builds on argparse-tui and Yapx to serve as a task runner with a CLI and TUI interface. +- [fresh2dev/myke](https://fresh2.dev/r/myke/) is a Python CLI that builds on argparse-tui and Yapx to serve as a task runner with a CLI and TUI interface. ______________________________________________________________________ -[![License](https://img.shields.io/github/license/fresh2dev/argparse-tui?color=blue&style=for-the-badge)](https://www.f2dv.com/r/argparse-tui/license/) -[![GitHub tag (with filter)](https://img.shields.io/github/v/tag/fresh2dev/argparse-tui?filter=!*%5Ba-z%5D*&style=for-the-badge&label=Release&color=blue)](https://www.f2dv.com/r/argparse-tui/changelog/) -[![GitHub last commit (branch)](https://img.shields.io/github/last-commit/fresh2dev/argparse-tui/main?style=for-the-badge&label=updated&color=blue)](https://www.f2dv.com/r/argparse-tui/changelog/) +[![License](https://img.shields.io/github/license/fresh2dev/argparse-tui?color=blue&style=for-the-badge)](https://fresh2.dev/r/argparse-tui/license/) +[![GitHub tag (with filter)](https://img.shields.io/github/v/tag/fresh2dev/argparse-tui?filter=!*%5Ba-z%5D*&style=for-the-badge&label=Release&color=blue)](https://fresh2.dev/r/argparse-tui/changelog/) +[![GitHub last commit (branch)](https://img.shields.io/github/last-commit/fresh2dev/argparse-tui/main?style=for-the-badge&label=updated&color=blue)](https://fresh2.dev/r/argparse-tui/changelog/) [![GitHub Repo stars](https://img.shields.io/github/stars/fresh2dev/argparse-tui?color=blue&style=for-the-badge)](https://star-history.com/#fresh2dev/argparse-tui&Date) - - - - - - - + + + - - diff --git a/README.pypi.md b/README.pypi.md index 391207c..f549332 100644 --- a/README.pypi.md +++ b/README.pypi.md @@ -1 +1 @@ -[https://www.f2dv.com/r/argparse-tui](https://www.f2dv.com/r/argparse-tui) +[https://fresh2.dev/r/argparse-tui](https://fresh2.dev/r/argparse-tui) diff --git a/compose.kustomization.yaml b/compose.kustomization.yaml deleted file mode 100644 index c172808..0000000 --- a/compose.kustomization.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1alpha1 -kind: Component - -# namespace: default -# namePrefix: dev- -# nameSuffix: "-001" -# labels: -# - pairs: -# someName: someValue -# owner: alice -# app: bingo -# includeSelectors: true # <-- false by default -# includeTemplates: true # <-- false by default - -commonAnnotations: - oncallPager: 800-555-1212 - -patches: - - target: - kind: ServiceAccount - patch: |- - - op: add - path: /imagePullSecrets - value: - - name: docker-registry-pull-secrets - -# images: -# - name: nginx -# newName: my.image.registry/nginx -# newTag: 1.4.0 - -# replacements: -# - source: -# kind: Deployment -# fieldPath: metadata.name -# targets: -# - select: -# name: my-resource diff --git a/compose.vars.yaml b/compose.vars.yaml deleted file mode 100644 index 80d1048..0000000 --- a/compose.vars.yaml +++ /dev/null @@ -1,3 +0,0 @@ -subdomain: "www" -routePrefix: "/r/argparse-tui" -svcPort: 80 diff --git a/compose.yaml.tpl b/compose.yaml.tpl deleted file mode 100644 index bb8fb1e..0000000 --- a/compose.yaml.tpl +++ /dev/null @@ -1,156 +0,0 @@ ---- -compose: - services: - <{{$vars.releaseName}}>: - labels: - kompose.controller.type: "deployment" # 'deployment' (default) or 'statefulset' - # kompose.serviceaccount-name: "<{{$vars.releaseName}}>" - image: '<{{ $vars.registry }}>/<{{ getenv "CI_PROJECT_PATH" | required "Missing required env var: CI_PROJECT_PATH" }}>:<{{ getenv "CI_COMMIT_SHORT_SHA" | required "Missing required env var: CI_COMMIT_SHORT_SHA" }}>' - ports: ["<{{ $vars.svcPort }}>"] - configs: - - source: <{{$vars.releaseName}}>-nginx-config - target: '/etc/nginx/conf.d/default.conf' - # secrets: - # - source: <{{$vars.releaseName}}>-secrets - # target: '/app/creds.json' - # environment: - # TZ: <{{/* $vars.tz */}}> - # PUID: <{{/* $vars.puid */}}> - # PGID: <{{/* $vars.pgid */}}> - # PLAIN_SECRET: <{{/* index (ds "vault").data "sekret.txt" | base64.Decode */}}> - # USERNAME: "secretKeyRef://<{{$vars.releaseName}}>-env" - # PASSWORD: "secretKeyRef://<{{$vars.releaseName}}>-env/APP_PASSWORD" - # volumes: - # - <{{$vars.releaseName}}>-data:/data:ro - # - <{{$vars.releaseName}}>-config:/app/config - - # <{{$vars.releaseName}}>-redis: - # ports: ['6379'] - # image: 'docker.io/library/redis:7' - - configs: - <{{$vars.releaseName}}>-nginx-config: - file: ./releases.yaml - # <{{$vars.releaseName}}>-configs: - # file: ./releases.yaml - # secrets: - # <{{$vars.releaseName}}>-secrets: - # file: ./releases.yaml - # volumes: - # <{{$vars.releaseName}}>-data: - # external: true - -customObjects: -<{{- $releaseHostName := printf "%s.%s" $vars.subdomain $vars.domain }}> -<{{- $releaseUrl := printf "%s%s" $releaseHostName $vars.routePrefix }}> -- - apiVersion: traefik.io/v1alpha1 - kind: IngressRoute - metadata: - name: <{{ $vars.releaseName }}> - annotations: - gethomepage.dev/enabled: "true" - gethomepage.dev/href: "https://<{{ $releaseUrl }}>" - gethomepage.dev/description: <{{ $vars.releaseName }}> - gethomepage.dev/group: 'K8S Services' - gethomepage.dev/icon: kubernetes.png - gethomepage.dev/name: <{{ $vars.releaseName }}> - gethomepage.dev/weight: 10 # optional - # gethomepage.dev/instance: "public" # optional - # gethomepage.dev/app: "<{{ $vars.releaseName }}>" # optional, Use pod-selector instead. - gethomepage.dev/pod-selector: 'io.kompose.service in (<{{ $vars.releaseName }}>)' - # OR: gethomepage.dev/pod-selector: 'app.kubernetes.io/name in (<{{ $vars.releaseName }}>), app.kubernetes.io/instance in (<{{ $vars.releaseName }}>)' - # gethomepage.dev/widget.type: "emby" - # gethomepage.dev/widget.url: "<{{ $releaseUrl }}>" - labels: {} - spec: - tls: {} - entryPoints: ["websecure"] - routes: - - match: 'Host(`<{{ $releaseHostName }}>`) && PathPrefix(`<{{ $vars.routePrefix }}>`)' - kind: Rule - services: - - name: <{{ $vars.releaseName }}> - port: <{{ $vars.svcPort }}> - middlewares: - # - name: authelia - # namespace: traefik - # - name: <{{ $vars.releaseName }}>-strip-prefix - # namespace: default -# - -# apiVersion: traefik.io/v1alpha1 -# kind: Middleware -# metadata: -# name: "<{{ $vars.releaseName }}>-strip-prefix" -# spec: -# stripPrefix: -# forceSlash: false -# prefixes: -# - "<{{ $vars.routePrefix }}>" -- - apiVersion: v1 - kind: ConfigMap - metadata: - name: "<{{ $vars.releaseName }}>-nginx-config" - data: - default.conf: | - server { - listen <{{ $vars.svcPort }}>; - absolute_redirect off; - - location <{{ $vars.routePrefix }}> { - alias /usr/share/nginx/html/; - } - } -### -# - -# kind: PersistentVolumeClaim -# apiVersion: v1 -# metadata: -# name: "<{{ $vars.releaseName }}>-data" -# annotations: {} -# # nfs.io/path: "/" -# labels: {} -# spec: -# # storageClassName: -# accessModes: -# - ReadWriteMany -# resources: -# requests: -# storage: 1Mi -### -# - -# apiVersion: v1 -# kind: ServiceAccount -# metadata: -# name: <{{ $vars.releaseName }}> -# secrets: -# - name: <{{ $vars.releaseName }}> -# - -# apiVersion: rbac.authorization.k8s.io/v1 -# kind: ClusterRole -# metadata: -# name: <{{ $vars.releaseName }}> -# rules: -# - apiGroups: -# - "" -# resources: -# - namespaces -# - pods -# - nodes -# verbs: -# - get -# - list -# - -# apiVersion: rbac.authorization.k8s.io/v1 -# kind: ClusterRoleBinding -# metadata: -# name: <{{ $vars.releaseName }}> -# roleRef: -# apiGroup: rbac.authorization.k8s.io -# kind: ClusterRole -# name: <{{ $vars.releaseName }}> -# subjects: -# - kind: ServiceAccount -# name: <{{ $vars.releaseName }}> -# namespace: default diff --git a/config/overrides/main.html b/config/overrides/main.html index 6fc7871..2fe8c3d 100644 --- a/config/overrides/main.html +++ b/config/overrides/main.html @@ -8,7 +8,7 @@ {% block fonts %}