diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 00000000..b7487aa2 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,191 @@ +name: cicd + +env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + +on: + push: + paths: + - "python/**" + - "cli/**" + - ".github/workflows/cicd.yml" + +permissions: + id-token: write + contents: write + +jobs: + lint-sdk: + name: Lint python SDK + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + cache: "pip" + - run: pip install .[dev] + - run: python -m ruff check ./python + - run: python -m mypy --strict ./python + test-sdk: + name: Unit test python SDK + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + cache: "pip" + - run: pip install -e .[dev] + - run: coverage run -m pytest -vv ./python/tests/ + - run: coverage report | grep 'TOTAL' | awk '{print "COVERAGE_PCT=" $4}' >> $GITHUB_ENV + - name: Publish SDK test coverage + uses: schneegans/dynamic-badges-action@v1.7.0 + with: + auth: ${{ secrets.GIST_ACCESS_TOKEN }} + gistID: a9b9bfdfa0620696fba9e76223790f53 + filename: sdk-coverage.json + label: SDK coverage + message: ${{ env.COVERAGE_PCT }} + minColorRange: 50 + maxColorRange: 80 + valColorRange: ${{ env.COVERAGE_PCT }} + build: + timeout-minutes: 15 + runs-on: ubuntu-latest + name: Build package + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + cache: "pip" + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + cache: false + - run: python3 -m pip install build>=1.2.1 + - run: make package + - uses: actions/upload-artifact@v4 + with: + name: package + path: ./dist/* + test-cli-windows: + name: Test CLI on windows + needs: + - build + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + cache: "pip" + - uses: actions/download-artifact@v4 + id: download + with: + name: package + path: ./dist + - run: cmd /r dir /b /a-d dist > files.txt + - run: | + $files = Get-Content files.txt + foreach ($file in $files) { + pip install dist/$file + } + - run: numerous --help + lint-cli: + name: Lint CLI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache: false + - name: Lint (golangci-lint) + uses: golangci/golangci-lint-action@v4 + with: + version: v1.56.2 + working-directory: cli + args: --config=../.golangci.yml --verbose + - name: Install gofumpt + shell: bash + run: | + wget https://github.com/mvdan/gofumpt/releases/download/v0.6.0/gofumpt_v0.6.0_linux_amd64 + mv gofumpt_v0.6.0_linux_amd64 gofumpt + chmod +x gofumpt + mv gofumpt /usr/local/bin + - name: Check gofumpt formatting + shell: bash + run: | + unformatted_files=$(gofumpt -l .) + if [[ "$unformatted_files" != "" ]]; then + echo "Some files do not adhere to gofumpt formatting:" + echo "$unformatted_files" + exit 1 + fi + test-cli: + name: Unit test CLI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + cache: false + - name: Tests + working-directory: cli + run: go test -coverprofile=c.out ./... + - name: Extract Test Coverage Percentage + working-directory: cli + run: go tool cover -func c.out | fgrep total | awk '{print "COVERAGE_PCT=" $3}' >> $GITHUB_ENV + - name: Publish Test Coverage + uses: schneegans/dynamic-badges-action@v1.7.0 + with: + auth: ${{ secrets.GIST_ACCESS_TOKEN }} + gistID: a9b9bfdfa0620696fba9e76223790f53 + filename: cli-coverage.json + label: CLI coverage + message: ${{ env.COVERAGE_PCT }} + minColorRange: 50 + maxColorRange: 80 + valColorRange: ${{ env.COVERAGE_PCT }} + release: + timeout-minutes: 15 + runs-on: ubuntu-latest + name: Release + needs: + - lint-cli + - test-cli + - test-sdk + - lint-sdk + - build + - test-cli-windows + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + cache: "pip" + - uses: actions/download-artifact@v4 + id: download + with: + name: package + path: ./dist + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + cache: false + - run: pip install python-semantic-release==9.6.0 twine==5.0.0 + - run: semantic-release version --commit --tag --push + - run: semantic-release publish + - run: twine upload --non-interactive dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ec9d35ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,178 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +cli/build/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +lcov.info + + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Editor specific files +.vscode + +# Makefile rules +.lint-*.txt + +# Autogenerated documentation sources +docs/source/api + +# CLI codebase +cli/build +!cli/internal/gql/build +cli/cli.db +cli/c.out diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..08f6e2f1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,27 @@ +# https://golangci-lint.run/usage/configuration/ +run: + timeout: 5m + +linters-settings: + nlreturn: + block-size: 2 + testifylint: + disable: + - require-error + +linters: + enable: + - gocritic + - gomnd + - misspell + - nlreturn + - perfsprint + - predeclared + - stylecheck + - testifylint + - thelper + - usestdlibvars + - whitespace + +issues: + max-same-issues: 10 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..cc360361 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +default_stages: ["pre-commit", "pre-push"] +default_install_hook_types: [pre-commit, pre-push] +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.4.1 + hooks: + # Run the linter. + - id: ruff + args: [--fix] + # Run the formatter. + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + entry: mypy python + pass_filenames: false + additional_dependencies: + - "pytest-asyncio" + - repo: local + hooks: + - id: pytest-check + stages: [pre-push] + types: [python] + name: pytest-check + entry: python -m pytest -v python/tests/ + language: system + pass_filenames: false + always_run: true + - repo: local + hooks: + - id: golangci-lint + name: golangci-lint + language: system + stages: [pre-commit, pre-push] + entry: bash -c 'cd cli && golangci-lint run --allow-parallel-runners' + - repo: https://github.com/Bahjat/pre-commit-golang + rev: v1.0.3 + hooks: + - id: gofumpt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..f6e2a6ad --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,93 @@ +# CHANGELOG + + + +## v0.1.0 (2024-05-03) + +### Ci + +* ci: fix twine command arguments ([`29cf529`](https://github.com/numerous-com/numerous-sdk/commit/29cf52900f976b969cf4ea77893a13c7cb00104c)) + +* ci: formatting, install proper python-semantic-release ([`6f3815f`](https://github.com/numerous-com/numerous-sdk/commit/6f3815f7097025eb65c35309382c83c58e119f4a)) + +* ci: setup semantic release publish ([`bbe9154`](https://github.com/numerous-com/numerous-sdk/commit/bbe915428c90458f8cf62d35b59849a3d1f8f8e0)) + + +## v0.0.42 (2024-05-02) + +### Chore + +* chore: fix errors in make clean ([`4a4424a`](https://github.com/numerous-com/numerous-sdk/commit/4a4424a44257c444c6823d92f4a3d0f2d03cf3bf)) + +### Ci + +* ci: fix release build ([`cb99a9c`](https://github.com/numerous-com/numerous-sdk/commit/cb99a9cb2ff1c2dff7b18dfc09db15ab11873cda)) + +* ci: update setup-go version ([`6acabe1`](https://github.com/numerous-com/numerous-sdk/commit/6acabe12ecbd6e4fb85fe3aad70f648aa35ddcff)) + +* ci: fix semantic release ([`4b5e985`](https://github.com/numerous-com/numerous-sdk/commit/4b5e985cd242d1cbb497d298254479edc497d379)) + +* ci: rename publish to Release ([`e5eb4bc`](https://github.com/numerous-com/numerous-sdk/commit/e5eb4bcca58dc0988553c2cd86a85934d43c8138)) + +* ci: setup semantic release ([`b742e40`](https://github.com/numerous-com/numerous-sdk/commit/b742e40a32dd84c0964e97f663926fd879fb7ce7)) + +### Fix + +* fix(cli): improvements to numerous init output and folder creation ([`ae34d0c`](https://github.com/numerous-com/numerous-sdk/commit/ae34d0c114074ddf95c1dac548d522dad91bc010)) + + +## v0.0.41 (2024-05-02) + +### Chore + +* chore: fix Makefiles and add actions ([`2b7f383`](https://github.com/numerous-com/numerous-sdk/commit/2b7f3837fe0daf9e96821e6d7f029575e221d847)) + +### Ci + +* ci: sdk test coverage ([`381a0de`](https://github.com/numerous-com/numerous-sdk/commit/381a0dee47a8ce8f9a6b8f0a96c27076584a0cd0)) + +* ci: publish cli test coverage ([`bc149af`](https://github.com/numerous-com/numerous-sdk/commit/bc149af300216ff4ff06a13c08fe68fc365ae6f7)) + +* ci: fix cli test working dir, only make package twice, and make package venv requirement ([`e7c55b5`](https://github.com/numerous-com/numerous-sdk/commit/e7c55b5e356e13116c0e28b364e59c7ffb4aca63)) + +* ci: fix workflow dependencies ([`61bdfe8`](https://github.com/numerous-com/numerous-sdk/commit/61bdfe85daef60013a31f16e98390b21f1cbb155)) + +* ci: use a single workflow ([`8a73ad2`](https://github.com/numerous-com/numerous-sdk/commit/8a73ad2c01473e8633c6cd1de0824d657b91d701)) + +* ci: fix issues and comment out stuff that doesn't work yet ([`24ed7c2`](https://github.com/numerous-com/numerous-sdk/commit/24ed7c275ac027b7ffab5bfa6c59f247c6e8f7fd)) + +### Documentation + +* docs: update readme ([`4e9184a`](https://github.com/numerous-com/numerous-sdk/commit/4e9184ae3f1dc6820ba728404ec046afbada1ee6)) + +* docs: update README.md and remove CLI readme ([`75b534e`](https://github.com/numerous-com/numerous-sdk/commit/75b534eb3e751f0899b8f267625a938380d36398)) + +### Fix + +* fix: CLI binaries in SDK package ([`af063d3`](https://github.com/numerous-com/numerous-sdk/commit/af063d34158b954362258527a2aa0d3a5448bfc6)) + +### Refactor + +* refactor(cli): restructure appdev code ([`420f639`](https://github.com/numerous-com/numerous-sdk/commit/420f63949bb9ee2500a0d8a43b021eb2d2feeaf2)) + +### Test + +* test(cli): use temp dir in watcher test ([`b931cd1`](https://github.com/numerous-com/numerous-sdk/commit/b931cd124a1f183f82d671fe65ae516475857eb2)) + +### Unknown + +* pre-commit ([`6e9de59`](https://github.com/numerous-com/numerous-sdk/commit/6e9de591698246950b9edae3d2524a19c6dc1c63)) + +* update schema ([`8b687f5`](https://github.com/numerous-com/numerous-sdk/commit/8b687f55ee69577c2654f8e70507cc58d8f1f2a9)) + +* add cli assets ([`3174b0f`](https://github.com/numerous-com/numerous-sdk/commit/3174b0fc22c4610bfc71ca17fe657b24011dd4df)) + +* update cli ([`0148046`](https://github.com/numerous-com/numerous-sdk/commit/0148046124add2dfaf067eddd2b9d6bad3bbc71a)) + +* fix python linting errors ([`ef546e3`](https://github.com/numerous-com/numerous-sdk/commit/ef546e38d9c6717b969a1dc74865916e5ef5295f)) + +* update python sdk ([`e2d031e`](https://github.com/numerous-com/numerous-sdk/commit/e2d031e0c706ce51cfc0f018cd434ed0ae3ecb88)) + +* update cli ([`3ce4221`](https://github.com/numerous-com/numerous-sdk/commit/3ce422137818928c8751f5350e88f5f9d55a96ce)) + +* initial commit ([`2dd346b`](https://github.com/numerous-com/numerous-sdk/commit/2dd346b033a8cd0d76f74ed8427c4a81400aedfe)) diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b499612a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +global-include py.typed +include python/src/numerous/cli/build/* \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..e45a77cb --- /dev/null +++ b/Makefile @@ -0,0 +1,127 @@ +SHELL = bash +TARGET_SYSTEMS := darwin windows linux +TARGET_ARCHS := amd64 arm64 + +# CLI related variables +GO_ENV = CGO_ENABLED=0 +GO_BUILD = cd cli && $(GO_ENV) go build + +CLI_DIR=./cli +CLI_BUILD_DIR_NAME=build +CLI_BUILD_DIR=$(CLI_DIR)/$(CLI_BUILD_DIR_NAME) +CLI_SOURCE_FILES=$(shell find $(CLI_DIR) -name *.go -type f) + +CLI_BUILD_TARGETS := $(foreach SYS,$(TARGET_SYSTEMS),$(foreach ARCH,$(TARGET_ARCHS),$(CLI_BUILD_DIR)/$(SYS)_$(ARCH))) + +get_cli_target_from_sdk_binary = $(word 1,$(subst $(SDK_CLI_BINARY_DIR)/,,$(CLI_BUILD_DIR)/$@)) +getsystem = $(word 3,$(subst _, ,$(subst /, ,$@))) +getarch = $(word 4,$(subst _, ,$(subst /, ,$@))) + +GQL_HTTP_URL = https://api.numerous.com/query +GQL_WS_URL = wss://api.numerous.com/query +LDFLAGS = -s -w \ + -X "numerous/cli/internal/gql.httpURL=$(GQL_HTTP_URL)" \ + -X "numerous/cli/internal/gql.wsURL=$(GQL_WS_URL)" + +# Python SDK related variables +SDK_CLI_BINARY_DIR=python/src/numerous/cli/build +SDK_CLI_BINARY_TARGETS := $(foreach SYS,$(TARGET_SYSTEMS),$(foreach ARCH,$(TARGET_ARCHS),$(SDK_CLI_BINARY_DIR)/$(SYS)_$(ARCH))) +SDK_CHECK_VENV=@if [ -z "${VIRTUAL_ENV}" ]; then echo "-- Error: An activated virtual environment is required"; exit 1; fi + +# RULES +.DEFAULT_GOAL := help + +.PHONY: clean test lint dep package sdk-binaries sdk-test sdk-lint sdk-dep cli-test cli-lint cli-dep cli-all cli-build cli-local + +clean: + rm -rf $(CLI_BUILD_DIR) + rm -rf $(SDK_CLI_BINARY_DIR) + rm -rf dist + rm -f .lint-ruff.txt + rm -f .lint-mypy.txt + +package: sdk-binaries + @echo "-- Building SDK package" + python -m build + +test: sdk-test cli-test + +lint: sdk-lint cli-lint + +dep: sdk-dep cli-dep + +sdk-lint: + @echo "-- Running SDK linters" + $(SDK_CHECK_VENV) + ruff check . && echo -n "true" > .lint-ruff.txt || echo -n "false" > .lint-ruff.txt; + mypy --strict . && echo -n "true" > .lint-mypy.txt || echo -n "false" > .lint-mypy.txt; + $$(cat .lint-ruff.txt) && $$(cat .lint-mypy.txt) + +sdk-test: + @echo "-- Running tests for sdk" + $(SDK_CHECK_VENV) + pytest python/tests + +sdk-dep: + @echo "-- Installing SDK dependencies" + $(SDK_CHECK_VENV) + pip install -e .[dev] -q + +sdk-binaries: $(SDK_CLI_BINARY_TARGETS) + +# Directory for CLI binaries, in SDK +$(SDK_CLI_BINARY_DIR): + @echo "-- Creating SDK binary directory" + mkdir $(SDK_CLI_BINARY_DIR) + +# CLI executables in SDK +$(SDK_CLI_BINARY_DIR)/%: $(SDK_CLI_BINARY_DIR) $(CLI_BUILD_DIR)/% + echo "Copying built binary $@" + cp $(get_cli_target_from_sdk_binary) $@ + +# CLI for specific OS/architecture +cli-all: $(CLI_BUILD_TARGETS) + +$(CLI_BUILD_TARGETS): %: $(CLI_SOURCE_FILES) + @echo "-- Building CLI for OS $(getsystem) architecture $(getarch) in $@" + export GOARCH=$(getarch) GOOS=$(getsystem) && $(GO_BUILD) -ldflags '$(LDFLAGS)' -o ../$@ . + +cli-local: + @echo "-- Building local CLI" + $(GO_BUILD) -o $(CLI_BUILD_DIR_NAME)/local . + +cli-build: + @echo "-- Building CLI" + $(GO_BUILD) -ldflags '$(LDFLAGS)' -o $(CLI_BUILD_DIR_NAME)/numerous . + +cli-lint: + @echo "-- Running CLI linters" + cd cli && golangci-lint run + cd cli && gofumpt -l -w . + +cli-test: + @echo "-- Running CLI tests" + cd cli && go test -coverprofile=c.out ./... + +cli-dep: + @echo "-- Installing CLI dependencies" + cd cli && go mod download + cd cli && go mod tidy > /dev/null + +help: + @echo "Make targets (help is default):" + @echo " test Run all tests" + @echo " lint Run all linters" + @echo " dep Install all dependencies" + @echo " package Package the SDK python package including CLI builds" + @echo " sdk-binaries Build CLI binaries in SDK package" + @echo " sdk-test Run SDK tests" + @echo " sdk-lint Run SDK linters" + @echo " sdk-dep Install SDK dependencies" + @echo " cli-test Run CLI tests" + @echo " cli-lint Run CLI linters" + @echo " cli-dep Install CLI dependencies" + @echo " cli-all Build CLI for all systems" + @echo " cli-build Build CLI for current system" + @echo " cli-local Build local CLI for current system" + @echo " help Display this message" diff --git a/README.md b/README.md new file mode 100644 index 00000000..eb2620bb --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +Numerous Software Development Kit +================================= + +💫 The python SDK for developing apps for the numerous platform. + +📥 Simply install the SDK into your python environment with: + + pip install numerous + +🛠 And then you can simply enter the following command, to get a list of possible +commands. + + numerous + +👩🏼🎓 See the [numerous documentation](https://www.numerous.com/docs) for more information! + +Badges +------ + +[![CICD badge](https://github.com/numerous-com/numerous-sdk/actions/workflows/cicd.yml/badge.svg)](https://github.com/numerous-com/numerous-sdk/actions/workflows/cicd.yml) +![cli coverage badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/jfeodor/a9b9bfdfa0620696fba9e76223790f53/raw/cli-coverage.json) +![sdk coverage badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/jfeodor/a9b9bfdfa0620696fba9e76223790f53/raw/sdk-coverage.json) + +Development +=========== + +Most common tasks are defined in the `Makefile`. Use `make help` to get an +overview. + +In order to setup pre-commit hooks, use [pre-commit](https://pre-commit.com/) to +to setup hooks for linters and tests. This requires pre-commit to be installed +of course, and it is included in the python SDK development dependencies. + +To install pre-commit and pre-push hooks + + pre-commit install + +And you can run them on demand + + pre-commit run --all + +Development of python SDK 🐍 +---------------------------- + +Create a virtual environment and activate it + + python -m venv ./venv + ./venv/bin/activate + +Install the package in editable mode (including development dependencies) + + pip install -e ./python[dev] + +Run the tests + + make sdk-test + +And the linters + + make sdk-lint + +Development of go CLI 🐹 +------------------------ + +The numerous CLI enables tool development. + +### Building and running + +To build simply run `make build` without arguments, and the executable is stored +as `cli/build/numerous` + +### Development + +While developing you can run the CLI like below, inside the `cli` folder. + + go run . + + # e.g. + go run . init + go run . dev + +From the root folder, you can lint with: + + make cli-lint + +And you can run tests with + + make cli-test + +### Trying out Numerous app engine development + +In the examples folder are two tools `examples/action.py` (containing +`ActionTool`), and `examples/parameters.py` (containing `ParameterTool`). These +can be used to test the first-class tool development features. + +**Note: You need an activated python environment with the python SDK +installed.** See +[the python sdk development section](#development-of-python-sdk-🐍) for information +about how to install it. + +For example, if you built using `make cli-build`, you can run + +``` +./cli/build/numerous dev examples/numerous/parameters.py:ParameterApp +``` diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 00000000..10ce3ac0 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,4 @@ +build +!internal/gql/build +cli.db +c.out \ No newline at end of file diff --git a/cli/appdev/app_definition.go b/cli/appdev/app_definition.go new file mode 100644 index 00000000..436e1b72 --- /dev/null +++ b/cli/appdev/app_definition.go @@ -0,0 +1,172 @@ +package appdev + +import ( + "errors" + "fmt" + "log/slog" + "strings" +) + +type AppDefinitionElement struct { + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` + Default any `json:"default"` + SliderMinValue float64 `json:"slider_min_value"` + SliderMaxValue float64 `json:"slider_max_value"` + Elements []AppDefinitionElement `json:"elements,omitempty"` + Parent *AppDefinitionElement `json:"-"` // ignore in JSON +} + +func (a AppDefinitionElement) setAsParentOnChildren() { + for i := 0; i < len(a.Elements); i++ { + e := &a.Elements[i] + e.Parent = &a + e.setAsParentOnChildren() + } +} + +func (a AppDefinitionElement) GetPath() []string { + if a.Parent == nil { + return []string{a.Name} + } else { + return append(a.Parent.GetPath(), a.Name) + } +} + +func (a AppDefinitionElement) String() string { + elementsField := "" + if a.Elements != nil { + elements := make([]string, 0) + for _, e := range a.Elements { + elements = append(elements, e.String()) + } + elementsField = fmt.Sprintf(", Elements: {%s}", strings.Join(elements, ", ")) + } + + return fmt.Sprintf("AppElementDefinition{Name: `%s`, Type: `%s`, Default: \"%v\"%s}", a.Name, a.Type, a.Default, elementsField) +} + +func (a AppDefinitionElement) CreateSessionElement() (*AppSessionElement, error) { + sessionElement := AppSessionElement{ + Name: a.Name, + Label: a.Label, + Type: a.Type, + } + + switch a.Type { + case "string": + switch d := a.Default.(type) { + case string: + sessionElement.StringValue.Valid = true + sessionElement.StringValue.String = d + default: + return nil, fmt.Errorf("parameter %s of type %s has invalid default \"%v\"", a.Name, a.Type, d) + } + case "number": + switch d := a.Default.(type) { + case float64: + sessionElement.NumberValue.Valid = true + sessionElement.NumberValue.Float64 = d + default: + return nil, fmt.Errorf("parameter %s of type %s has invalid default \"%v\"", a.Name, a.Type, d) + } + case "container": + sessionElement.Elements = createSessionElements(a.Elements) + case "action": + case "html": + switch d := a.Default.(type) { + case string: + sessionElement.HTMLValue.Valid = true + sessionElement.HTMLValue.String = d + default: + return nil, fmt.Errorf("parameter %s of type %s has invalid default \"%v\"", a.Name, a.Type, d) + } + case "slider": + switch d := a.Default.(type) { + case float64: + sessionElement.SliderValue.Valid = true + sessionElement.SliderValue.Float64 = d + sessionElement.SliderMinValue.Valid = true + sessionElement.SliderMinValue.Float64 = a.SliderMinValue + sessionElement.SliderMaxValue.Valid = true + sessionElement.SliderMaxValue.Float64 = a.SliderMaxValue + default: + return nil, fmt.Errorf("parameter %s of type %s has invalid default \"%v\"", a.Name, a.Type, d) + } + default: + return nil, fmt.Errorf("unexpected element type \"%s\"", a.Type) + } + + return &sessionElement, nil +} + +type AppDefinition struct { + Title string `json:"title"` + Name string `json:"name"` + Elements []AppDefinitionElement `json:"elements"` +} + +var ErrAppDefinitionElementNotFound = errors.New("element definition not found") + +func (ad *AppDefinition) GetElementByPath(path []string) (*AppDefinitionElement, error) { + var element *AppDefinitionElement + elements := ad.Elements + + for _, name := range path { + found := false + for _, e := range elements { + if name != e.Name { + continue + } + + found = true + element = &e + if e.Type == "container" { + elements = e.Elements + } + } + + if !found { + return nil, ErrAppDefinitionElementNotFound + } + } + + if element == nil { + return nil, ErrAppDefinitionElementNotFound + } else { + return element, nil + } +} + +func (ad *AppDefinition) SetElementParents() { + for _, e := range ad.Elements { + e.setAsParentOnChildren() + } +} + +func (ad AppDefinition) CreateSession() AppSession { + return AppSession{ + Title: ad.Title, + Name: ad.Name, + Elements: ad.CreateAppSessionElements(), + } +} + +func (ad AppDefinition) CreateAppSessionElements() []AppSessionElement { + return createSessionElements(ad.Elements) +} + +func createSessionElements(definitionElements []AppDefinitionElement) []AppSessionElement { + elements := []AppSessionElement{} + + for _, def := range definitionElements { + if e, err := def.CreateSessionElement(); err != nil { + slog.Warn("could not create session element", slog.Any("error", err)) + } else { + elements = append(elements, *e) + } + } + + return elements +} diff --git a/cli/appdev/app_definition_test.go b/cli/appdev/app_definition_test.go new file mode 100644 index 00000000..b2cfcbaf --- /dev/null +++ b/cli/appdev/app_definition_test.go @@ -0,0 +1,355 @@ +package appdev + +import ( + "database/sql" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type CreateSessionElementTestCase struct { + name string + def AppDefinitionElement + expected AppSessionElement +} + +var createSessionElementTestCases = []CreateSessionElementTestCase{ + { + name: "string field", + def: AppDefinitionElement{ + Label: "String Label", + Type: "string", + Default: "default string value", + }, + expected: AppSessionElement{ + Label: "String Label", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default string value"}, + }, + }, + { + name: "number field", + def: AppDefinitionElement{ + Label: "Number Label", + Type: "number", + Default: 10.0, + }, + expected: AppSessionElement{ + Label: "Number Label", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + }, + { + name: "slider field", + def: AppDefinitionElement{ + Label: "Slider label", + Type: "slider", + Default: 10.0, + SliderMinValue: 123.0, + SliderMaxValue: 456.0, + }, + expected: AppSessionElement{ + Label: "Slider label", + Type: "slider", + SliderValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + SliderMinValue: sql.NullFloat64{Valid: true, Float64: 123.0}, + SliderMaxValue: sql.NullFloat64{Valid: true, Float64: 456.0}, + }, + }, + { + name: "html field", + def: AppDefinitionElement{ + Label: "HTML label", + Type: "html", + Default: "
default html value
", + }, + expected: AppSessionElement{ + Label: "HTML label", + Type: "html", + HTMLValue: sql.NullString{Valid: true, String: "default html value
"}, + }, + }, + { + name: "container with 1 child", + def: AppDefinitionElement{ + Label: "Container Label", + Type: "container", + Elements: []AppDefinitionElement{ + {Label: "string label", Type: "string", Default: "default string"}, + }, + }, + expected: AppSessionElement{ + Label: "Container Label", + Type: "container", + Elements: []AppSessionElement{ + {Label: "string label", Type: "string", StringValue: sql.NullString{Valid: true, String: "default string"}}, + }, + }, + }, + { + name: "nested container with 1 child", + def: AppDefinitionElement{ + Label: "Container Label", + Type: "container", + Elements: []AppDefinitionElement{ + { + Label: "Nested container Label", + Type: "container", + Elements: []AppDefinitionElement{ + {Type: "string", Default: "default string"}, + }, + }, + }, + }, + expected: AppSessionElement{ + Label: "Container Label", + Type: "container", + Elements: []AppSessionElement{ + { + Label: "Nested container Label", + Type: "container", + Elements: []AppSessionElement{ + {Type: "string", StringValue: sql.NullString{Valid: true, String: "default string"}}, + }, + }, + }, + }, + }, +} + +func TestCreateSessionElement(t *testing.T) { + for _, testcase := range createSessionElementTestCases { + t.Run(testcase.name, func(t *testing.T) { + sess, err := testcase.def.CreateSessionElement() + + assert.NoError(t, err) + assert.Equal(t, testcase.expected, *sess) + }) + } +} + +func TestCreateSessionElementError(t *testing.T) { + createErrorTestTestCases := []AppDefinitionElement{ + {Type: "string", Default: 123.45}, + {Type: "number", Default: "some string value"}, + } + + for _, definition := range createErrorTestTestCases { + definition.Name = "element_name" + testName := fmt.Sprintf("%s element definition with non-%s default returns error", definition.Type, definition.Type) + t.Run(testName, func(t *testing.T) { + sess, err := definition.CreateSessionElement() + + expectedError := fmt.Sprintf("parameter element_name of type %s has invalid default \"%v\"", definition.Type, definition.Default) + assert.Nil(t, sess) + assert.EqualError(t, err, expectedError) + }) + } +} + +type CreateAppSessionTestCase struct { + name string + def AppDefinition + expected AppSession +} + +var createAppSessionTestCases = []CreateAppSessionTestCase{ + { + name: "empty app session", + def: AppDefinition{ + Elements: []AppDefinitionElement{}, + }, + expected: AppSession{ + Elements: []AppSessionElement{}, + }, + }, + { + name: "container with multiple children", + def: AppDefinition{ + Name: "App", + Elements: []AppDefinitionElement{ + { + Name: "container_element", + Type: "container", + Elements: []AppDefinitionElement{ + {Name: "string_element", Type: "string", Default: "default value"}, + {Name: "number_element", Type: "number", Default: 123.45}, + }, + }, + }, + }, + expected: AppSession{ + Name: "App", + Elements: []AppSessionElement{ + { + Name: "container_element", + Type: "container", + Elements: []AppSessionElement{ + { + Name: "string_element", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default value"}, + }, + { + Name: "number_element", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 123.45}, + }, + }, + }, + }, + }, + }, + { + name: "sibling containers", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + {Name: "c1", Type: "container", Elements: []AppDefinitionElement{{Type: "string", Default: "default"}}}, + {Name: "c2", Type: "container", Elements: []AppDefinitionElement{{Type: "string", Default: "default"}}}, + }, + }, + expected: AppSession{ + Elements: []AppSessionElement{ + { + Name: "c1", + Type: "container", + Elements: []AppSessionElement{ + {Type: "string", StringValue: sql.NullString{Valid: true, String: "default"}}, + }, + }, + { + Name: "c2", + Type: "container", + Elements: []AppSessionElement{ + {Type: "string", StringValue: sql.NullString{Valid: true, String: "default"}}, + }, + }, + }, + }, + }, +} + +func TestCreateAppSessionReturnsExpected(t *testing.T) { + for _, testcase := range createAppSessionTestCases { + t.Run(testcase.name, func(t *testing.T) { + s := testcase.def.CreateSession() + assert.Equal(t, testcase.expected, s) + }) + } +} + +type GetElementByPathTestCase struct { + name string + def AppDefinition + path []string + expected *AppDefinitionElement +} + +func TestAppDefinitionGetElementByPath(t *testing.T) { + testCases := []GetElementByPathTestCase{ + { + name: "returns root element", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + { + Name: "elem", + Type: "string", + Default: "default string", + }, + }, + }, + path: []string{"elem"}, + expected: &AppDefinitionElement{ + Name: "elem", + Type: "string", + Default: "default string", + }, + }, + { + name: "returns nested element", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + { + Name: "cont", + Type: "container", + Elements: []AppDefinitionElement{ + { + Name: "child", + Type: "number", + Default: 1.2, + }, + }, + }, + }, + }, + path: []string{"cont", "child"}, + expected: &AppDefinitionElement{ + Name: "child", + Type: "number", + Default: 1.2, + }, + }, + } + + for _, testcase := range testCases { + elem, err := testcase.def.GetElementByPath(testcase.path) + assert.NoError(t, err) + assert.Equal(t, testcase.expected, elem) + } +} + +type GetElementByPathErrorTestCase struct { + name string + def AppDefinition + path []string + err error +} + +var errorTestCases = []GetElementByPathErrorTestCase{ + { + name: "returns error for non-existing root element", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + { + Name: "elem", + Type: "number", + Default: 1.2, + }, + }, + }, + path: []string{"cont", "child"}, + err: ErrAppDefinitionElementNotFound, + }, + { + name: "returns error for non-existing nested element", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + { + Name: "cont", + Type: "container", + Elements: []AppDefinitionElement{ + { + Name: "child", + Type: "number", + Default: 1.2, + }, + }, + }, + }, + }, + path: []string{"cont", "non-existing"}, + err: ErrAppDefinitionElementNotFound, + }, +} + +func TestAppDefinitionGetElementByPathError(t *testing.T) { + for _, testcase := range errorTestCases { + t.Run(testcase.name, func(t *testing.T) { + elem, err := testcase.def.GetElementByPath(testcase.path) + assert.ErrorIs(t, err, ErrAppDefinitionElementNotFound) + assert.Nil(t, elem) + }) + } +} diff --git a/cli/appdev/app_session.go b/cli/appdev/app_session.go new file mode 100644 index 00000000..189e6222 --- /dev/null +++ b/cli/appdev/app_session.go @@ -0,0 +1,133 @@ +package appdev + +import ( + "database/sql" + "errors" + "fmt" + "strconv" + "time" + + "gorm.io/gorm" +) + +type AppSession struct { + gorm.Model + Title string + Name string + CreatedAt time.Time + Elements []AppSessionElement + ClientID string +} + +var ErrAppSessionElementNotFound = errors.New("element not found") + +func (s AppSession) GetElementByID(elementID string) (*AppSessionElement, error) { + for _, e := range s.Elements { + if strconv.FormatUint(uint64(e.ID), 10) == elementID { + return &e, nil + } + } + + return nil, fmt.Errorf("no element with ID \"%s\"", elementID) +} + +func (s AppSession) GetElementByPath(elementPath []string) (*AppSessionElement, error) { + var element *AppSessionElement = nil + for _, name := range elementPath { + if e := s.getElementByNameAndParent(name, element); e == nil { + return nil, ErrAppSessionElementNotFound + } else { + element = e + } + } + + if element != nil { + return element, nil + } else { + return nil, ErrAppSessionElementNotFound + } +} + +func (s AppSession) getElementByNameAndParent(name string, parent *AppSessionElement) *AppSessionElement { + for _, e := range s.Elements { + if e.Name != name { + continue + } + + noParent := !e.ParentID.Valid && parent == nil + if noParent { + return &e + } + + matchingParent := e.ParentID.Valid && parent != nil && e.ParentID.String == strconv.FormatUint(uint64(parent.ID), 10) + if matchingParent { + return &e + } + } + + return nil +} + +type AppSessionElement struct { + gorm.Model + AppSessionID uint + ParentID sql.NullString + Name string + Label string + Type string + NumberValue sql.NullFloat64 + StringValue sql.NullString + SliderValue sql.NullFloat64 + HTMLValue sql.NullString + Elements []AppSessionElement `gorm:"foreignKey:ParentID"` + SliderMinValue sql.NullFloat64 + SliderMaxValue sql.NullFloat64 +} + +func (s AppSession) GetAllChildren() []AppSessionElement { + children := make([]AppSessionElement, 0) + + for _, e := range s.Elements { + children = append(children, e.GetAllChildren()...) + } + + return children +} + +func (s AppSessionElement) GetAllChildren() []AppSessionElement { + children := make([]AppSessionElement, 0) + + for _, e := range s.Elements { + e.ParentID = sql.NullString{Valid: true, String: strconv.FormatUint(uint64(s.ID), 10)} + children = append(children, e) + children = append(children, e.GetAllChildren()...) + } + + return children +} + +func (s AppSession) GetParentOf(e *AppSessionElement) *AppSessionElement { + if !e.ParentID.Valid { + return nil + } + + for _, p := range s.Elements { + if strconv.FormatUint(uint64(p.ID), 10) == e.ParentID.String { + return &p + } + } + + return nil +} + +func (s AppSession) GetPath(e AppSessionElement) []string { + var path []string + + elem := &e + for elem != nil { + path = append([]string{elem.Name}, path...) + elem = s.GetParentOf(elem) + } + + return path +} diff --git a/cli/appdev/app_session_test.go b/cli/appdev/app_session_test.go new file mode 100644 index 00000000..1c4775ad --- /dev/null +++ b/cli/appdev/app_session_test.go @@ -0,0 +1,83 @@ +package appdev + +import ( + "database/sql" + "testing" + + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +func TestGetElementByID(t *testing.T) { + session := AppSession{ + Elements: []AppSessionElement{ + {Model: gorm.Model{ID: 1}, Name: "Elem 1"}, + {Model: gorm.Model{ID: 2}, Name: "Elem 2"}, + {Model: gorm.Model{ID: 3}, Name: "Elem 3"}, + {Model: gorm.Model{ID: 4}, Name: "Elem 4"}, + }, + } + + t.Run("returns expected element", func(t *testing.T) { + wantName := "Elem 2" + + gotElem, gotErr := session.GetElementByID("2") + assert.NoError(t, gotErr) + + assert.NoError(t, gotErr) + assert.Equal(t, wantName, gotElem.Name) + }) + + t.Run("returns error element does not exist", func(t *testing.T) { + _, gotErr := session.GetElementByID("10") + assert.EqualError(t, gotErr, "no element with ID \"10\"") + }) +} + +func TestGetElementByPath(t *testing.T) { + session := AppSession{ + Elements: []AppSessionElement{ + {Model: gorm.Model{ID: 1}, Name: "elem_1"}, + {Model: gorm.Model{ID: 2}, Name: "elem_2"}, + {Model: gorm.Model{ID: 3}, Name: "elem_3", ParentID: sql.NullString{String: "2", Valid: true}}, + {Model: gorm.Model{ID: 4}, Name: "elem_4", ParentID: sql.NullString{String: "3", Valid: true}}, + }, + } + + t.Run("get expected root level element", func(t *testing.T) { + var wantID uint = 1 + gotElem, gotErr := session.GetElementByPath([]string{"elem_1"}) + + assert.NoError(t, gotErr) + if assert.NotNil(t, gotElem) { + assert.Equal(t, wantID, gotElem.ID) + } + }) + + t.Run("cannot get nested element at root", func(t *testing.T) { + _, gotErr := session.GetElementByPath([]string{"elem_3"}) + assert.ErrorIs(t, gotErr, ErrAppSessionElementNotFound) + }) + + t.Run("returns expected child element", func(t *testing.T) { + var wantID uint = 3 + gotElem, gotErr := session.GetElementByPath([]string{"elem_2", "elem_3"}) + + assert.NoError(t, gotErr) + assert.Equal(t, wantID, gotElem.ID) + }) + + t.Run("returns expected nested element", func(t *testing.T) { + var wantID uint = 4 + gotElem, gotErr := session.GetElementByPath([]string{"elem_2", "elem_3", "elem_4"}) + + assert.NoError(t, gotErr) + assert.Equal(t, wantID, gotElem.ID) + }) + + t.Run("does not return child that does not exist", func(t *testing.T) { + gotElem, gotErr := session.GetElementByPath([]string{"elem_2", "elem_3", "elem_4", "non_existing"}) + assert.ErrorIs(t, gotErr, ErrAppSessionElementNotFound) + assert.Nil(t, gotElem) + }) +} diff --git a/cli/appdev/diff.go b/cli/appdev/diff.go new file mode 100644 index 00000000..83b2f4a2 --- /dev/null +++ b/cli/appdev/diff.go @@ -0,0 +1,114 @@ +package appdev + +import ( + "errors" + "log/slog" + "strconv" +) + +// Represents the difference between an existing AppSession, and a new +// AppDefinition. +type AppSessionDifference struct { + Added []AppSessionElement + Removed []AppSessionElement + Updated []AppSessionElement +} + +// Returns the difference between the elements in the provided existing app +// session, and the provided new app definition. +// +// Elements are matched by their paths within the app, where a path is a list of +// element names, ending with the elements own name, preceded by all the parent +// elements' names. +func GetAppSessionDifference(existing AppSession, newDef AppDefinition) AppSessionDifference { + return AppSessionDifference{ + Removed: getRemovedElements(existing, newDef), + Added: getAddedElementsFromTool(existing, newDef), + Updated: getUpdatedElementsFromTool(existing, newDef), + } +} + +func getRemovedElements(session AppSession, newDef AppDefinition) []AppSessionElement { + var removed []AppSessionElement + + for _, sess := range session.Elements { + path := session.GetPath(sess) + _, err := newDef.GetElementByPath(path) + if err != nil { + removed = append(removed, sess) + } + } + + return removed +} + +func getAddedElementsFromTool(session AppSession, newDef AppDefinition) []AppSessionElement { + var added []AppSessionElement + + for _, newDef := range newDef.Elements { + added = append(added, getAddedElementsFromElement(session, newDef)...) + } + + return added +} + +func getAddedElementsFromElement(session AppSession, newDef AppDefinitionElement) []AppSessionElement { + var added []AppSessionElement + + p := newDef.GetPath() + _, err := session.GetElementByPath(p) + + if errors.Is(err, ErrAppSessionElementNotFound) { + newElem, err := newDef.CreateSessionElement() + if err != nil { + slog.Info("could not create new added element", slog.Any("error", err)) + return added + } + + if newDef.Parent != nil { + if parent, err := session.GetElementByPath(newDef.Parent.GetPath()); err == nil { + newElem.ParentID.Valid = true + newElem.ParentID.String = strconv.FormatUint(uint64(parent.ID), 10) + } + } + + newElem.AppSessionID = session.ID + + return append(added, *newElem) + } else { + for _, newChild := range newDef.Elements { + added = append(added, getAddedElementsFromElement(session, newChild)...) + } + + return added + } +} + +func getUpdatedElementsFromTool(session AppSession, newDefApp AppDefinition) []AppSessionElement { + var updated []AppSessionElement + + for _, newDefElem := range newDefApp.Elements { + updated = append(updated, getUpdatedElementsFromElement(session, newDefElem)...) + } + + return updated +} + +func getUpdatedElementsFromElement(session AppSession, newDefElem AppDefinitionElement) []AppSessionElement { + var updated []AppSessionElement + + p := newDefElem.GetPath() + sessionElement, err := session.GetElementByPath(p) + + if err == nil { + if sessionElement.Label != newDefElem.Label { + sessionElement.Label = newDefElem.Label + updated = append(updated, *sessionElement) + } + for _, newChild := range newDefElem.Elements { + updated = append(updated, getUpdatedElementsFromElement(session, newChild)...) + } + } + + return updated +} diff --git a/cli/appdev/diff_test.go b/cli/appdev/diff_test.go new file mode 100644 index 00000000..7bf9c80b --- /dev/null +++ b/cli/appdev/diff_test.go @@ -0,0 +1,348 @@ +package appdev + +import ( + "database/sql" + "testing" + + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +type differenceTestCase struct { + name string + session AppSession + newDef AppDefinition + expectedDiff AppSessionDifference +} + +func TestGetToolSessionDifference(t *testing.T) { + testCases := []differenceTestCase{ + { + name: "element removed", + session: AppSession{ + Model: gorm.Model{ID: 0}, + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "number_element", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + { + Model: gorm.Model{ID: 1}, + Name: "string_element", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "some string value"}, + }, + }, + }, + newDef: AppDefinition{ + Elements: []AppDefinitionElement{ + {Name: "number_element", Type: "number", Default: 123.0}, + }, + }, + expectedDiff: AppSessionDifference{ + Removed: []AppSessionElement{ + { + Model: gorm.Model{ID: 1}, + Name: "string_element", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "some string value"}, + }, + }, + }, + }, + { + name: "element added", + session: AppSession{ + Model: gorm.Model{ID: 0}, + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "number_element", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + }, + }, + newDef: AppDefinition{ + Elements: []AppDefinitionElement{ + {Name: "number_element", Type: "number", Default: 123.0}, + {Name: "string_element", Type: "string", Default: "default string value"}, + }, + }, + expectedDiff: AppSessionDifference{ + Added: []AppSessionElement{ + { + Name: "string_element", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default string value"}, + }, + }, + }, + }, + { + name: "element label updated", + session: AppSession{ + Model: gorm.Model{ID: 0}, + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "number_element", + Label: "Number Label", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + { + Model: gorm.Model{ID: 1}, + Name: "string_element", + Label: "String Label", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default string value"}, + }, + }, + }, + newDef: AppDefinition{ + Elements: []AppDefinitionElement{ + {Name: "number_element", Type: "number", Label: "Number Label", Default: 10.0}, + {Name: "string_element", Type: "string", Label: "Updated String Label", Default: "default string value"}, + }, + }, + expectedDiff: AppSessionDifference{ + Updated: []AppSessionElement{ + { + Model: gorm.Model{ID: 1}, + Name: "string_element", + Type: "string", + Label: "Updated String Label", + StringValue: sql.NullString{Valid: true, String: "default string value"}, + }, + }, + }, + }, + { + name: "nested element added", + session: AppSession{ + Model: gorm.Model{ID: 0}, + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 5}, + Name: "container_element", + Type: "container", + }, + { + Model: gorm.Model{ID: 6}, + Name: "number_element", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + ParentID: sql.NullString{Valid: true, String: "5"}, + }, + }, + }, + newDef: AppDefinition{ + Elements: []AppDefinitionElement{ + {Name: "container_element", Type: "container", Elements: []AppDefinitionElement{ + {Name: "number_element", Type: "number", Default: 123.0}, + {Name: "string_element", Type: "string", Default: "default string value"}, + }}, + }, + }, + expectedDiff: AppSessionDifference{ + Added: []AppSessionElement{ + { + Name: "string_element", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default string value"}, + ParentID: sql.NullString{Valid: true, String: "5"}, + }, + }, + }, + }, + { + name: "nested element label updated", + session: AppSession{ + Model: gorm.Model{ID: 0}, + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 1}, + Name: "container_element", + Type: "container", + }, + { + Model: gorm.Model{ID: 2}, + Name: "nested_container_element", + Type: "container", + ParentID: sql.NullString{Valid: true, String: "1"}, + }, + { + Model: gorm.Model{ID: 3}, + Name: "number_element", + Label: "Number Label", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + ParentID: sql.NullString{Valid: true, String: "1"}, + }, + { + Model: gorm.Model{ID: 4}, + Name: "string_element", + Label: "String Label", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default string value"}, + ParentID: sql.NullString{Valid: true, String: "2"}, + }, + }, + }, + newDef: AppDefinition{ + Elements: []AppDefinitionElement{ + { + Name: "container_element", Type: "container", Elements: []AppDefinitionElement{ + {Name: "number_element", Label: "Updated Number Label", Type: "number", Default: 123.0}, + { + Name: "nested_container_element", Type: "container", Elements: []AppDefinitionElement{ + {Name: "string_element", Label: "Updated String Label", Type: "string", Default: "default string value"}, + }, + }, + }, + }, + }, + }, + expectedDiff: AppSessionDifference{ + Updated: []AppSessionElement{ + { + Model: gorm.Model{ID: 3}, + Name: "number_element", + Label: "Updated Number Label", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + ParentID: sql.NullString{Valid: true, String: "1"}, + }, + { + Model: gorm.Model{ID: 4}, + Name: "string_element", + Label: "Updated String Label", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default string value"}, + ParentID: sql.NullString{Valid: true, String: "2"}, + }, + }, + }, + }, + { + name: "unchanged session with doubled child has empty diff", + session: AppSession{ + Model: gorm.Model{ + ID: 0, + }, + Name: "ContainerTool", + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "my_container", + Type: "container", + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + AppSessionID: 0, + Name: "child", + Type: "string", + StringValue: sql.NullString{String: "", Valid: true}, + }, + }, + }, + { + Model: gorm.Model{ID: 1}, + AppSessionID: 0, + Name: "print_child", + Type: "action", + }, + { + Model: gorm.Model{ID: 2}, + AppSessionID: 0, + ParentID: sql.NullString{String: "0", Valid: true}, + Name: "child", + Type: "string", + StringValue: sql.NullString{String: "", Valid: true}, + }, + }, + }, + newDef: AppDefinition{ + Name: "ContainerTool", + Elements: []AppDefinitionElement{ + { + Name: "my_container", + Type: "container", + Elements: []AppDefinitionElement{ + { + Name: "child", + Type: "string", + Default: "", + }, + }, + }, + { + Name: "print_child", + Type: "action", + }, + }, + }, + expectedDiff: AppSessionDifference{}, + }, + { + name: "container added diff is nested", + session: AppSession{ + Model: gorm.Model{ + ID: 0, + }, + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "action", + Type: "action", + AppSessionID: 0, + }, + }, + }, + newDef: AppDefinition{ + Elements: []AppDefinitionElement{ + { + Name: "container", + Type: "container", + Elements: []AppDefinitionElement{ + {Name: "child", Type: "string", Default: "default"}, + }, + }, + { + Name: "action", + Type: "action", + }, + }, + }, + expectedDiff: AppSessionDifference{ + Added: []AppSessionElement{ + { + Name: "container", + Type: "container", + Elements: []AppSessionElement{ + { + Name: "child", + Type: "string", + StringValue: sql.NullString{String: "default", Valid: true}, + }, + }, + }, + }, + }, + }, + } + + for _, testCase := range testCases { + name := testCase.name + t.Run(name, func(t *testing.T) { + testCase.newDef.SetElementParents() + diff := GetAppSessionDifference(testCase.session, testCase.newDef) + if !assert.Equal(t, testCase.expectedDiff, diff) { + println() + } + }) + } +} diff --git a/cli/appdev/log.go b/cli/appdev/log.go new file mode 100644 index 00000000..8326c016 --- /dev/null +++ b/cli/appdev/log.go @@ -0,0 +1,16 @@ +package appdev + +import ( + "encoding/json" + "log/slog" +) + +func SlogJSON(key string, value any) slog.Attr { + j, err := json.Marshal(value) + + if err != nil { + return slog.Group(key, slog.Any("error", err), slog.Any("value", value)) + } else { + return slog.String(key, string(j)) + } +} diff --git a/cli/appdev/output.go b/cli/appdev/output.go new file mode 100644 index 00000000..963a519c --- /dev/null +++ b/cli/appdev/output.go @@ -0,0 +1,291 @@ +package appdev + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type Output interface { + StartingApp(port string) + StartedApp() + AppModuleNotFound(err error) + AwaitingAppChanges() + ErrorReadingApp(readOutput string, err error) + ErrorParsingApp(err error) + ErrorStartingApp(err error) + ErrorWatchingAppFiles(err error) + FileUpdatedRestartingApp() + ErrorUpdateAddingElement() + ErrorUpdateRemovingElement() + ErrorUpdateUpdatingElement() + ErrorCreatingAppSession(err error) + ErrorGettingAppOutputStream(streamName string) + PrintAppLogLine(line string) + Stopping() + ErrorLoadingApp(appError *ParseAppDefinitionError) +} + +type FmtOutput struct { + appModulePath string + appClassName string +} + +func (o *FmtOutput) StartingApp(port string) { + fmt.Printf("Starting %s:%s at http://localhost:%s\n", o.appModulePath, o.appClassName, port) +} + +func (o *FmtOutput) StartedApp() { + fmt.Printf("Started %s:%s\n", o.appModulePath, o.appClassName) +} + +func (o *FmtOutput) AppModuleNotFound(err error) { + fmt.Printf("Could not find app module %s: %s\n", o.appModulePath, err) +} + +func (o *FmtOutput) AwaitingAppChanges() { + fmt.Printf("Waiting for changes to '%s'...\n", o.appModulePath) +} + +func (o *FmtOutput) ErrorReadingApp(readOutput string, err error) { + fmt.Printf("An error occurred while reading the app definition of %s:%s!\n", o.appModulePath, o.appClassName) +} + +func (o *FmtOutput) ErrorParsingApp(err error) { + fmt.Println("The app definition was not valid.") +} + +func (o *FmtOutput) ErrorStartingApp(err error) { + fmt.Printf("Could not start %s:%s", o.appModulePath, o.appClassName) + fmt.Printf("Error: %s", err) +} + +func (o *FmtOutput) ErrorWatchingAppFiles(err error) { + fmt.Printf("file watcher for %s did not start: %s\n", o.appModulePath, err.Error()) +} + +func (o *FmtOutput) FileUpdatedRestartingApp() { + fmt.Printf("watcher: %s updated, restarting app.\n", o.appModulePath) +} + +func (o *FmtOutput) ErrorUpdateAddingElement() { + fmt.Println("Could not update app, error adding elements.") +} + +func (o *FmtOutput) ErrorUpdateRemovingElement() { + fmt.Println("Could not update app, error removing elements.") +} + +func (o *FmtOutput) ErrorUpdateUpdatingElement() { + fmt.Println("Could not update app, error updating elements.") +} + +func (o *FmtOutput) ErrorCreatingAppSession(err error) { + fmt.Printf("Could not create app session: %s\n", err) +} + +func (o *FmtOutput) ErrorGettingAppOutputStream(streamName string) { + fmt.Printf("%s:%s> Could not access stream %s in app\n", o.appModulePath, o.appClassName, streamName) +} + +func (o *FmtOutput) PrintAppLogLine(line string) { + fmt.Printf("%s:%s> %s", o.appModulePath, o.appClassName, line) +} + +func (o *FmtOutput) Stopping() { + fmt.Println("Stopping development server") +} + +func (o *FmtOutput) ErrorLoadingApp(appError *ParseAppDefinitionError) { + switch { + case appError.ModuleNotFound != nil: + fmt.Printf("The module '%s' imported in your python code was not found\n", appError.ModuleNotFound.Module) + fmt.Println("Common reasons for this include:") + fmt.Println(" * Some of your external dependencies have not been installed") + fmt.Println(" * You have not activated the correct virtual environment") + fmt.Println(" * Or there might be an error in your import") + case appError.AppNotFound != nil: + fmt.Printf("The app '%s' was not found in the specified module '%s'\n", appError.AppNotFound.App, o.appModulePath) + if len(appError.AppNotFound.FoundApps) > 0 { + fmt.Println("The following apps were found in the module") + for _, app := range appError.AppNotFound.FoundApps { + fmt.Println(" * ", app) + } + } else { + fmt.Println("We found no defined apps in that module, are you sure it is the correct one?") + } + case appError.Syntax != nil: + fmt.Printf("A syntax error occurred, loading your app module '%s'\n", o.appModulePath) + fmt.Println("Syntex error message:", appError.Syntax.Msg) + fmt.Printf("The error occurred at line %d, column %d:\n", appError.Syntax.Pos.Line, appError.Syntax.Pos.Offset) + fmt.Println(appError.Syntax.Context) + case appError.Unknown != nil: + fmt.Printf("An unhandled exception of type '%s' was raised loading '%s'\n", appError.Unknown.Typename, o.appModulePath) + fmt.Println(appError.Unknown.Traceback) + } +} + +var ( + ColorOK = lipgloss.Color("#23DD65") + ColorLifecycle = lipgloss.Color("#2365DD") + ColorError = lipgloss.Color("#FF2323") + ColorNotice = lipgloss.Color("#DDAA22") +) + +type LipglossOutput struct { + appModulePath string + appClassName string + okStyle lipgloss.Style + lifecycleStyle lipgloss.Style + logNameStyle lipgloss.Style + noticeStyle lipgloss.Style + errorHeaderStyle lipgloss.Style + errorBodyStyle lipgloss.Style + errorContextStyle lipgloss.Style +} + +func NewLipglossOutput(appModulePath string, appClassName string) LipglossOutput { + return LipglossOutput{ + appModulePath: appModulePath, + appClassName: appClassName, + okStyle: lipgloss.NewStyle().Bold(true).Foreground(ColorOK), + lifecycleStyle: lipgloss.NewStyle().Bold(true).Foreground(ColorLifecycle), + logNameStyle: lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, true, false, false).Faint(true), + noticeStyle: lipgloss.NewStyle().Foreground(ColorNotice), + errorHeaderStyle: lipgloss.NewStyle().Bold(true).Foreground(ColorError), + errorBodyStyle: lipgloss.NewStyle().Foreground(ColorError), + errorContextStyle: lipgloss.NewStyle(). + Foreground(ColorError). + BorderForeground(ColorError). + BorderLeft(true). + Border(lipgloss.NormalBorder(), false, false, false, true), + } +} + +func (o *LipglossOutput) printErrorHeader(format string, args ...any) { + o.printStyle(o.errorHeaderStyle, format, args...) +} + +func (o *LipglossOutput) printErrorBody(format string, args ...any) { + o.printStyle(o.errorBodyStyle, format, args...) +} + +func (o *LipglossOutput) printErrorContext(format string, args ...any) { + o.printStyle(o.errorContextStyle, format, args...) +} + +func (o *LipglossOutput) printNotice(format string, args ...any) { + o.printStyle(o.noticeStyle, format, args...) +} + +func (o *LipglossOutput) printOK(format string, args ...any) { + o.printStyle(o.okStyle, format, args...) +} + +func (o *LipglossOutput) printLifecycle(format string, args ...any) { + o.printStyle(o.lifecycleStyle, format, args...) +} + +func (o *LipglossOutput) printStyle(style lipgloss.Style, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + fmt.Println(style.Render(msg)) +} + +func (o *LipglossOutput) StartingApp(port string) { + o.printLifecycle("Starting %s:%s app at http://localhost:%s", o.appModulePath, o.appClassName, port) +} + +func (o *LipglossOutput) StartedApp() { + o.printOK("Started %s:%s", o.appModulePath, o.appClassName) +} + +func (o *LipglossOutput) AppModuleNotFound(err error) { + o.printErrorHeader("Could not find app module %s: %s", o.appModulePath, err) +} + +func (o *LipglossOutput) AwaitingAppChanges() { + o.printLifecycle("Waiting for changes to '%s'...", o.appModulePath) +} + +func (o *LipglossOutput) ErrorReadingApp(readOutput string, err error) { + o.printErrorHeader("An error occurred while reading the app definition of %s:%s: %s", o.appModulePath, o.appClassName, err) + o.printErrorBody("This could be due to a bug in the numerous app engine.") + o.printErrorBody("The following error message was produced, while reading the app:") + for _, line := range strings.Split(readOutput, "\n") { + o.printErrorContext(line) + } +} + +func (o *LipglossOutput) ErrorParsingApp(err error) { + o.printErrorHeader("An error occurred while reading the app definition of %s:%s: %s", o.appModulePath, o.appClassName, err) +} + +func (o *LipglossOutput) ErrorStartingApp(err error) { + o.printErrorHeader("Could not start %s:%s: %s", o.appModulePath, o.appClassName, err.Error()) +} + +func (o *LipglossOutput) ErrorWatchingAppFiles(err error) { + o.printErrorHeader("Error watching '%s' for changes: %s", o.appModulePath, err.Error()) +} + +func (o *LipglossOutput) FileUpdatedRestartingApp() { + o.printNotice("File '%s' changed, restarting app...", o.appModulePath) +} + +func (o *LipglossOutput) ErrorUpdateAddingElement() { + o.printErrorHeader("Could not update app, error adding elements.") +} + +func (o *LipglossOutput) ErrorUpdateRemovingElement() { + o.printErrorHeader("Could not update app, error removing elements.") +} + +func (o *LipglossOutput) ErrorUpdateUpdatingElement() { + o.printErrorHeader("Could not update app, error updating elements.") +} + +func (o *LipglossOutput) ErrorCreatingAppSession(err error) { + o.printErrorHeader("Could not create app session:" + err.Error()) +} + +func (o *LipglossOutput) ErrorGettingAppOutputStream(streamName string) { + o.printErrorHeader("Could not access stream %s running %s:%s\n", streamName, o.appModulePath, o.appClassName) +} + +func (o *LipglossOutput) PrintAppLogLine(line string) { + fmt.Print(o.logNameStyle.Render(fmt.Sprintf("%s:%s", o.appModulePath, o.appClassName))) + fmt.Println(line) +} + +func (o *LipglossOutput) Stopping() { + o.printNotice("Stopping development server") +} + +func (o *LipglossOutput) ErrorLoadingApp(appError *ParseAppDefinitionError) { + switch { + case appError.ModuleNotFound != nil: + o.printErrorHeader("The module '%s' imported in your python code was not found", appError.ModuleNotFound.Module) + o.printNotice("Common reasons for this include:") + o.printNotice(" * Some of your external dependencies have not been installed") + o.printNotice(" * You have not activated the correct virtual environment") + o.printNotice(" * Or there might be an error in your import") + case appError.AppNotFound != nil: + o.printErrorHeader("The app '%s' was not found in the specified module '%s'", appError.AppNotFound.App, o.appModulePath) + if len(appError.AppNotFound.FoundApps) > 0 { + o.printNotice("The following apps were found in the module") + for _, app := range appError.AppNotFound.FoundApps { + o.printNotice(" * %s", app) + } + } else { + o.printNotice("We found no defined apps in that module, are you sure it is the correct one?") + } + case appError.Syntax != nil: + o.printErrorHeader("A syntax error occurred, loading your app module '%s': %s", o.appModulePath, appError.Syntax.Msg) + o.printErrorBody("The error occurred in \"%s\", line %d, column %d:", o.appModulePath, appError.Syntax.Pos.Line, appError.Syntax.Pos.Offset) + o.printErrorContext(appError.Syntax.Context) + case appError.Unknown != nil: + o.printErrorHeader("An unhandled exception of type '%s' was raised loading '%s'", appError.Unknown.Typename, o.appModulePath) + o.printErrorContext(appError.Unknown.Traceback) + } +} diff --git a/cli/appdev/parse.go b/cli/appdev/parse.go new file mode 100644 index 00000000..7d145b07 --- /dev/null +++ b/cli/appdev/parse.go @@ -0,0 +1,60 @@ +package appdev + +import ( + "encoding/json" + "log/slog" +) + +type AppCodeCoordinate struct { + Line uint `json:"line"` + Offset uint `json:"offset"` +} + +type AppSyntaxError struct { + Msg string `json:"msg"` + Context string `json:"context"` + Pos AppCodeCoordinate `json:"pos"` +} + +type AppModuleNotFoundError struct { + Module string `json:"module"` +} + +type AppUnknownError struct { + Typename string `json:"typename"` + Traceback string `json:"traceback"` +} + +type AppNotFoundError struct { + App string `json:"app"` + FoundApps []string `json:"found_apps"` +} + +type ParseAppDefinitionError struct { + AppNotFound *AppNotFoundError `json:"appnotfound"` + Syntax *AppSyntaxError `json:"appsyntax"` + ModuleNotFound *AppModuleNotFoundError `json:"modulenotfound"` + Unknown *AppUnknownError `json:"unknown"` +} + +type ParseAppDefinitionResult struct { + App *AppDefinition `json:"app"` + Error *ParseAppDefinitionError `json:"error"` +} + +func ParseAppDefinition(definition []byte) (ParseAppDefinitionResult, error) { + var result ParseAppDefinitionResult + + if err := json.Unmarshal(definition, &result); err != nil { + slog.Warn( + "Failed to unmarshal app definition", + slog.Any("definition", definition), + ) + + return result, err + } else if result.App != nil { + result.App.SetElementParents() + } + + return result, nil +} diff --git a/cli/appdev/parse_test.go b/cli/appdev/parse_test.go new file mode 100644 index 00000000..8e4ac9c5 --- /dev/null +++ b/cli/appdev/parse_test.go @@ -0,0 +1,167 @@ +package appdev + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseAppDefinitionReturnsResultWithApp(t *testing.T) { + containerDef := AppDefinitionElement{ + Name: "Container", + Type: "container", + } + containerDef.Elements = []AppDefinitionElement{{Name: "Text", Type: "string", Default: "default text", Parent: &containerDef}} + + testCases := []struct { + json string + expected ParseAppDefinitionResult + }{ + { + json: `{"app": {"name": "App Name", "elements": []}}`, + expected: ParseAppDefinitionResult{App: &AppDefinition{Name: "App Name", Elements: []AppDefinitionElement{}}}, + }, + { + json: `{"app": {"name": "App Name", "elements": [{"name": "Text", "type": "string", "default": "default text"}]}}`, + expected: ParseAppDefinitionResult{App: &AppDefinition{Name: "App Name", Elements: []AppDefinitionElement{ + {Name: "Text", Type: "string", Default: "default text"}, + }}}, + }, + { + json: `{"app": {"name": "App Name", "elements": [{"name": "Number", "type": "number", "default": 12.3}]}}`, + expected: ParseAppDefinitionResult{App: &AppDefinition{Name: "App Name", Elements: []AppDefinitionElement{ + {Name: "Number", Type: "number", Default: 12.3}, + }}}, + }, + { + json: `{"app": {"name": "App Name", "elements": [{"name": "Slider", "type": "slider", "default": 1.2, "slider_min_value": 3.4, "slider_max_value": 5.6}]}}`, + expected: ParseAppDefinitionResult{App: &AppDefinition{Name: "App Name", Elements: []AppDefinitionElement{ + {Name: "Slider", Type: "slider", Default: 1.2, SliderMinValue: 3.4, SliderMaxValue: 5.6}, + }}}, + }, + { + json: `{"app": {"name": "App Name", "elements": [{"name": "Html", "type": "html", "default": "default html
"}]}}`, + expected: ParseAppDefinitionResult{App: &AppDefinition{Name: "App Name", Elements: []AppDefinitionElement{ + {Name: "Html", Type: "html", Default: "default html
"}, + }}}, + }, + { + json: `{"app": {"name": "App Name", "elements": [{"name": "Container", "type": "container", "elements": [{"name": "Text", "type": "string", "default": "default text"}]}]}}`, + expected: ParseAppDefinitionResult{App: &AppDefinition{Name: "App Name", Elements: []AppDefinitionElement{containerDef}}}, + }, + { + json: `{"app": {"name": "App Name", "elements": [{"name": "Container", "type": "container", "elements": [{"name": "Text", "type": "string", "default": "default text"}]}, {"name": "Sibling", "type": "action"}]}}`, + expected: ParseAppDefinitionResult{App: &AppDefinition{Name: "App Name", Elements: []AppDefinitionElement{containerDef, {Name: "Sibling", Type: "action"}}}}, + }, + } + + for _, testCase := range testCases { + result, err := ParseAppDefinition([]byte(testCase.json)) + assert.NoError(t, err) + assert.Equal(t, testCase.expected, result) + } +} + +func TestParseAppDefinitionReturnsResultWithError(t *testing.T) { + containerDef := AppDefinitionElement{ + Name: "Container", + Type: "container", + } + containerDef.Elements = []AppDefinitionElement{{Name: "Text", Type: "string", Default: "default text", Parent: &containerDef}} + + testCases := []struct { + json string + expected ParseAppDefinitionResult + }{ + { + json: ` + { + "error": { + "appnotfound": { + "app": "MyApp", + "found_apps": ["MyOtherApp"] + } + } + }`, + expected: ParseAppDefinitionResult{Error: &ParseAppDefinitionError{ + AppNotFound: &AppNotFoundError{ + App: "MyApp", + FoundApps: []string{"MyOtherApp"}, + }, + }}, + }, + { + json: ` + { + "error": { + "modulenotfound": { + "module": "somenotfoundmodule" + } + } + }`, + expected: ParseAppDefinitionResult{Error: &ParseAppDefinitionError{ + ModuleNotFound: &AppModuleNotFoundError{ + Module: "somenotfoundmodule", + }, + }}, + }, + { + json: ` + { + "error": { + "appsyntax": { + "context": "def bla(\n ^", + "msg": "error description", + "pos": {"line": 5, "offset": 7} + } + } + }`, + expected: ParseAppDefinitionResult{Error: &ParseAppDefinitionError{ + Syntax: &AppSyntaxError{ + Msg: "error description", + Context: "def bla(\n ^", + Pos: AppCodeCoordinate{Line: 5, Offset: 7}, + }, + }}, + }, + { + json: ` + { + "error": { + "unknown": { + "typename": "SomeError", + "traceback": "Traceback:\nFile: blabla\nFile: blabla\n" + } + } + }`, + expected: ParseAppDefinitionResult{ + Error: &ParseAppDefinitionError{ + Unknown: &AppUnknownError{ + Typename: "SomeError", + Traceback: "Traceback:\nFile: blabla\nFile: blabla\n", + }, + }, + }, + }, + } + + for _, testCase := range testCases { + result, err := ParseAppDefinition([]byte(testCase.json)) + assert.NoError(t, err) + assert.Equal(t, testCase.expected, result) + } +} + +func TestParseAppDefinitionReturnsError(t *testing.T) { + t.Run("invalid json returns error", func(t *testing.T) { + result, err := ParseAppDefinition([]byte(`{jens: 123}`)) + assert.Equal(t, ParseAppDefinitionResult{}, result) + assert.Error(t, err) + }) + + t.Run("unended json returns error", func(t *testing.T) { + result, err := ParseAppDefinition([]byte(`{"name": "App", "elements": []`)) + assert.Equal(t, ParseAppDefinitionResult{}, result) + assert.Error(t, err) + }) +} diff --git a/cli/appdev/repositories_test.go b/cli/appdev/repositories_test.go new file mode 100644 index 00000000..e3245934 --- /dev/null +++ b/cli/appdev/repositories_test.go @@ -0,0 +1,312 @@ +package appdev + +import ( + "database/sql" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func compareToolSession(t *testing.T, s *AppSession) { + t.Helper() + assert.Equal(t, "Tool", s.Name) + + expectedTextElement := AppSessionElement{ + Model: gorm.Model{ID: 0}, + Name: "text_element", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default"}, + } + + expectedNumberElement := AppSessionElement{ + Model: gorm.Model{ID: 1}, + Name: "number_element", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 1.0}, + } + + assert.Equal(t, []AppSessionElement{expectedTextElement, expectedNumberElement}, s.Elements) +} + +func compareSecondToolSession(t *testing.T, s *AppSession) { + t.Helper() + if s.Name != "SecondTool" { + t.Fatalf("got tool name %s, want %s", s.Name, "Tool") + } + + expectedTextElement := AppSessionElement{ + Model: gorm.Model{ID: 2}, + Name: "second_text_element", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "second default"}, + } + expectedNumberElement := AppSessionElement{ + Model: gorm.Model{ID: 3}, + Name: "second_number_element", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + } + + assert.Equal(t, []AppSessionElement{expectedTextElement, expectedNumberElement}, s.Elements) +} + +func TestToolSessionRepository(t *testing.T) { + def := AppDefinition{ + Name: "Tool", + Elements: []AppDefinitionElement{ + {Name: "text_element", Type: "string", Default: "default"}, + {Name: "number_element", Type: "number", Default: 1.0}, + }, + } + + secondDef := AppDefinition{ + Name: "SecondTool", + Elements: []AppDefinitionElement{ + {Name: "second_text_element", Type: "string", Default: "second default"}, + {Name: "second_number_element", Type: "number", Default: 10.0}, + }, + } + + t.Run("Create returns new ToolSession", func(t *testing.T) { + r := InMemoryAppSessionRepository{} + + s, err := r.Create(def) + require.NoError(t, err) + + compareToolSession(t, s) + }) + + t.Run("Read returns error before Create", func(t *testing.T) { + r := InMemoryAppSessionRepository{} + + if s, err := r.Read(0); err == nil { + t.Fatalf("r.Read(0) = (%#v, nil), want (nil, error)", s) + } else { + require.EqualError(t, err, ErrSessionNotCreated.Error()) + } + }) + + t.Run("Read returns ToolSession after Create", func(t *testing.T) { + r := InMemoryAppSessionRepository{} + + _, err := r.Create(def) + require.NoError(t, err) + + s, err := r.Read(0) + require.NoError(t, err) + + compareToolSession(t, s) + }) + + t.Run("Read returns same ToolSession no matter the ID", func(t *testing.T) { + r := InMemoryAppSessionRepository{} + + _, err := r.Create(def) + require.NoError(t, err) + + for i := uint(0); i < 1000; i += 100 { + s, err := r.Read(i) + require.NoError(t, err) + compareToolSession(t, s) + } + }) + + t.Run("Create overrides existing global ToolSession", func(t *testing.T) { + r := InMemoryAppSessionRepository{} + _, err := r.Create(def) + require.NoError(t, err) + + _, err = r.Create(secondDef) + require.NoError(t, err) + s, err := r.Read(0) + require.NoError(t, err) + + compareSecondToolSession(t, s) + }) + + t.Run("Delete panics", func(t *testing.T) { + r := InMemoryAppSessionRepository{} + + require.Panics(t, func() { + //nolint:errcheck + r.Delete(0) + }) + }) + + t.Run("assigns ids to nested elements", func(t *testing.T) { + r := InMemoryAppSessionRepository{} + + def := AppDefinition{ + Elements: []AppDefinitionElement{ + { + Name: "root", + Type: "container", + Elements: []AppDefinitionElement{ + { + Name: "middle", + Type: "container", + Elements: []AppDefinitionElement{{Name: "leaf", Type: "string", Default: "default"}}, + }, + }, + }, + }, + } + expected := AppSession{ + Model: gorm.Model{ID: 0}, + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "root", + Type: "container", + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 1}, + Name: "middle", + Type: "container", + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 2}, + Name: "leaf", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default"}, + }, + }, + }, + }, + }, + { + Model: gorm.Model{ID: 1}, + Name: "middle", + Type: "container", + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 2}, + Name: "leaf", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default"}, + }, + }, + ParentID: sql.NullString{Valid: true, String: "0"}, + }, + { + Model: gorm.Model{ID: 2}, + Name: "leaf", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default"}, + ParentID: sql.NullString{Valid: true, String: "1"}, + }, + }, + } + + actual, err := r.Create(def) + require.NoError(t, err) + assert.Equal(t, expected, *actual) + }) +} + +type addElementTestCase struct { + name string + def AppDefinition + added AppSessionElement + expected AppSessionElement +} + +var addTestCases = []addElementTestCase{ + { + name: "element added to empty session gets id 0", + def: AppDefinition{}, + added: AppSessionElement{ + Name: "text", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "value"}, + }, + expected: AppSessionElement{ + Model: gorm.Model{ID: 0}, + Name: "text", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "value"}, + }, + }, + { + name: "element added to non-empty session gets expected id", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + {Name: "field1", Type: "string", Default: ""}, + {Name: "field2", Type: "string", Default: ""}, + {Name: "field3", Type: "string", Default: ""}, + }, + }, + added: AppSessionElement{ + Name: "field4", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "value"}, + }, + expected: AppSessionElement{ + Model: gorm.Model{ID: 3}, + Name: "field4", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "value"}, + }, + }, + { + name: "child element expected id", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + {Name: "container", Type: "container", Elements: []AppDefinitionElement{}}, + }, + }, + added: AppSessionElement{ + Name: "child", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "value"}, + ParentID: sql.NullString{Valid: true, String: "0"}, + }, + expected: AppSessionElement{ + Model: gorm.Model{ID: 1}, + Name: "child", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "value"}, + ParentID: sql.NullString{Valid: true, String: "0"}, + }, + }, + { + name: "added container and child have expected ids", + def: AppDefinition{Elements: []AppDefinitionElement{}}, + added: AppSessionElement{ + Name: "container", + Type: "container", + Elements: []AppSessionElement{{ + Name: "child", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "value"}, + }}, + }, + expected: AppSessionElement{ + Model: gorm.Model{ID: 0}, + Name: "container", + Type: "container", + Elements: []AppSessionElement{{ + Model: gorm.Model{ID: 1}, + Name: "child", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "value"}, + ParentID: sql.NullString{Valid: true, String: "0"}, + }}, + }, + }, +} + +func TestAddElement(t *testing.T) { + for _, testcase := range addTestCases { + r := InMemoryAppSessionRepository{} + _, err := r.Create(testcase.def) + require.NoError(t, err) + + actual, err := r.AddElement(testcase.added) + + require.NoError(t, err) + assert.Equal(t, testcase.expected, *actual) + } +} diff --git a/cli/appdev/repository.go b/cli/appdev/repository.go new file mode 100644 index 00000000..1dac3f6a --- /dev/null +++ b/cli/appdev/repository.go @@ -0,0 +1,182 @@ +package appdev + +import ( + "errors" + "log/slog" + "strconv" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ErrRemoveNonExistingElement error = errors.New("cannot remove element that does not exist") + +type AppSessionRepository interface { + Create(definition AppDefinition) (*AppSession, error) + Delete(id uint) error + Read(id uint) (*AppSession, error) + UpdateElement(element AppSessionElement) error + AddElement(element AppSessionElement) (*AppSessionElement, error) + RemoveElement(element AppSessionElement) error +} + +type DBAppSessionRepository struct { + db *gorm.DB +} + +func NewDBAppSessionRepository(db *gorm.DB) *DBAppSessionRepository { + return &DBAppSessionRepository{db: db} +} + +func (r *DBAppSessionRepository) Create(def AppDefinition) (*AppSession, error) { + appSession := def.CreateSession() + if err := r.db.Create(&appSession).First(&appSession).Error; err != nil { + slog.Error(err.Error()) + return nil, errors.New("error creating app session") + } + slog.Debug("Created app session", slog.Any("app session", appSession)) + + return &appSession, nil +} + +func (r *DBAppSessionRepository) Delete(id uint) error { + if err := r.db.Delete(&AppSession{}, id).Error; err != nil { + slog.Error(err.Error()) + return err + } + + return nil +} + +func (r *DBAppSessionRepository) Read(id uint) (*AppSession, error) { + appSession := AppSession{Model: gorm.Model{ID: id}} + if err := r.db.Preload(clause.Associations).First(&appSession).Error; err != nil { + slog.Error(err.Error()) + return nil, err + } + + slog.Debug("Read app session", slog.Any("appSession", appSession)) + + return &appSession, nil +} + +func (r *DBAppSessionRepository) UpdateElement(element AppSessionElement) error { + if err := r.db.Save(&element).Error; err != nil { + slog.Error(err.Error()) + return err + } + + slog.Debug("Update app session element", slog.Any("elementID", element.ID)) + + return nil +} + +func (r *DBAppSessionRepository) AddElement(element AppSessionElement) (*AppSessionElement, error) { + panic("not implemented") +} + +func (r *DBAppSessionRepository) RemoveElement(element AppSessionElement) error { + panic("not implemented") +} + +type InMemoryAppSessionRepository struct { + session *AppSession + nextElementID uint +} + +func (r *InMemoryAppSessionRepository) getNewElementID() uint { + newID := r.nextElementID + r.nextElementID++ + + return newID +} + +func (r *InMemoryAppSessionRepository) Create(definition AppDefinition) (*AppSession, error) { + session := definition.CreateSession() + r.setSessionIDs(session.Elements) + childElements := session.GetAllChildren() + session.Elements = append(session.Elements, childElements...) + r.session = &session + + return &session, nil +} + +func (r *InMemoryAppSessionRepository) setSessionIDs(elements []AppSessionElement) { + for i := 0; i < len(elements); i++ { + e := &elements[i] + e.Model.ID = r.getNewElementID() + if e.Type == "container" { + r.setSessionIDs(e.Elements) + } + } +} + +var ErrSessionNotCreated error = errors.New("session not created yet") + +func (r *InMemoryAppSessionRepository) Delete(id uint) error { + panic("not implemented") +} + +func (r *InMemoryAppSessionRepository) Read(id uint) (*AppSession, error) { + if r.session == nil { + return nil, ErrSessionNotCreated + } else { + return r.session, nil + } +} + +func (r *InMemoryAppSessionRepository) UpdateElement(element AppSessionElement) error { + found := false + var index int + for i, v := range r.session.Elements { + if v.ID == element.ID { + index = i + found = true + } + } + + if found { + r.session.Elements[index] = element + return nil + } else { + return errors.New("tool session element does not exist") + } +} + +func (r *InMemoryAppSessionRepository) AddElement(element AppSessionElement) (*AppSessionElement, error) { + r.assignElementIds(&element) + r.session.Elements = append(r.session.Elements, element) + r.session.Elements = append(r.session.Elements, element.GetAllChildren()...) + + return &element, nil +} + +func (r *InMemoryAppSessionRepository) assignElementIds(element *AppSessionElement) { + element.Model.ID = r.getNewElementID() + for i := 0; i < len(element.Elements); i++ { + e := &element.Elements[i] + e.ParentID.Valid = true + e.ParentID.String = strconv.FormatUint(uint64(element.ID), 10) + r.assignElementIds(e) + } +} + +func (r *InMemoryAppSessionRepository) RemoveElement(element AppSessionElement) error { + newElements := make([]AppSessionElement, 0) + found := false + + for _, v := range r.session.Elements { + if v.ID == element.ID { + found = true + } else { + newElements = append(newElements, v) + } + } + + if !found { + return ErrRemoveNonExistingElement + } else { + r.session.Elements = newElements + return nil + } +} diff --git a/cli/appdev/service.go b/cli/appdev/service.go new file mode 100644 index 00000000..5c0b2e52 --- /dev/null +++ b/cli/appdev/service.go @@ -0,0 +1,293 @@ +package appdev + +import ( + "context" + "fmt" + "log/slog" + "strconv" +) + +const appSessionEventChanBufferSize = 100 + +type AppSessionEvent struct { + AppSessionID string + SourceClientID string + UpdatedElement *AppSessionElement + TriggeredActionElement *AppSessionElement + AddedElement *AppSessionElement + RemovedElement *AppSessionElement +} + +func (a *AppSessionEvent) Type() string { + if a.UpdatedElement != nil { + return "UpdatedElement" + } + + if a.TriggeredActionElement != nil { + return "TriggeredActionElement" + } + + if a.AddedElement != nil { + return "AddedElement" + } + + if a.RemovedElement != nil { + return "RemovedElement" + } + + return "Unknown" +} + +type AppSessionSubscription struct { + ID uint + Channel chan AppSessionEvent + ClientID string +} + +type AppSessionService struct { + subscriptions map[uint]AppSessionSubscription + nextSubscriptionID uint + appSessions AppSessionRepository +} + +type AppSessionElementUpdate struct { + ElementID string + StringValue *string + NumberValue *float64 + HTMLValue *string + SliderValue *float64 +} + +type AppSessionElementResult struct { + Session *AppSession + Element *AppSessionElement +} + +func NewAppSessionService(appSessions AppSessionRepository) AppSessionService { + return AppSessionService{ + appSessions: appSessions, + subscriptions: make(map[uint]AppSessionSubscription), + } +} + +// Update the identified app session with the given update and send updates to +// to all subscribers whose client IDs are different from the specified client ID. +func (s *AppSessionService) UpdateElement(appSessionID uint, clientID string, update AppSessionElementUpdate) (*AppSessionElementResult, error) { + session, err := s.appSessions.Read(appSessionID) + if err != nil { + return nil, err + } + + convertedAppSessionID := strconv.FormatUint(uint64(appSessionID), 10) + if e, err := session.GetElementByID(update.ElementID); err != nil { + slog.Debug("update element not found", slog.Any("id", update.ElementID)) + return nil, err + } else if err := s.updateElement(convertedAppSessionID, clientID, e, update); err != nil { + slog.Debug("update updating error", slog.Any("id", e.ID), slog.String("name", e.Name), slog.String("error", err.Error())) + return nil, err + } else { + slog.Debug("update element complete", slog.Any("id", e.ID), slog.String("name", e.Name)) + return &AppSessionElementResult{ + Session: session, + Element: e, + }, nil + } +} + +func (s *AppSessionService) updateElement(appSessionID string, clientID string, elem *AppSessionElement, update AppSessionElementUpdate) error { + switch elem.Type { + case "string": + if update.StringValue == nil { + return fmt.Errorf("string element %d update missing value", elem.ID) + } + elem.StringValue.String = *update.StringValue + elem.StringValue.Valid = true + case "number": + if update.NumberValue == nil { + return fmt.Errorf("number element %d update missing value", elem.ID) + } + elem.NumberValue.Float64 = *update.NumberValue + elem.NumberValue.Valid = true + case "html": + if update.HTMLValue == nil { + return fmt.Errorf("html element %d update missing value", elem.ID) + } + elem.HTMLValue.Valid = true + elem.HTMLValue.String = *update.HTMLValue + case "slider": + if update.SliderValue == nil { + return fmt.Errorf("slider element %d update missing value", elem.ID) + } + elem.SliderValue.Float64 = *update.SliderValue + elem.SliderValue.Valid = true + default: + return fmt.Errorf("updating %s element %d is unsupported", elem.Type, elem.ID) + } + + if err := s.appSessions.UpdateElement(*elem); err != nil { + return err + } + + event := AppSessionEvent{ + AppSessionID: appSessionID, + SourceClientID: clientID, + UpdatedElement: elem, + } + go s.sendEvent(clientID, event) // TODO: this is only needed for tests, fix + + return nil +} + +// Triggers the specified action in the specified app session, sending a trigger +// event to all subscribers whose client IDs are different from the specified +// client ID. +func (s *AppSessionService) TriggerAction(appSessionID uint, clientID string, actionElementID string) (*AppSessionElementResult, error) { + session, err := s.appSessions.Read(appSessionID) + if err != nil { + return nil, err + } + + convertedAppSessionID := strconv.FormatUint(uint64(appSessionID), 10) + if actionElement, err := session.GetElementByID(actionElementID); err != nil { + return nil, err + } else if actionElement.Type != "action" { + return nil, fmt.Errorf("cannot trigger action for element \"%s\" of type \"%s\"", actionElementID, actionElement.Type) + } else { + slog.Debug("triggering action", slog.Any("triggered action element", actionElementID)) + + event := AppSessionEvent{ + AppSessionID: convertedAppSessionID, + SourceClientID: clientID, + TriggeredActionElement: actionElement, + } + s.sendEvent(clientID, event) + + return &AppSessionElementResult{ + Session: session, + Element: actionElement, + }, nil + } +} + +func (s *AppSessionService) sendEvent(sourceClientID string, event AppSessionEvent) { + sent := false + + for _, s := range s.subscriptions { + if s.ClientID != sourceClientID { + slog.Info("sending event", slog.String("type", event.Type()), slog.String("sourceClientID", sourceClientID), slog.Any("subscriberClientID", s.ClientID)) + s.Channel <- event + sent = true + } + } + + if !sent { + slog.Info("no subscribers found for event", slog.String("type", event.Type()), slog.String("sourceClientID", sourceClientID)) + } +} + +// Subscribe for updates to an app session, for events not sent by the specified +// client ID. +func (s *AppSessionService) Subscribe(ctx context.Context, appSessionID string, clientID string) (chan AppSessionEvent, error) { + subscription := make(chan AppSessionEvent, appSessionEventChanBufferSize) + subID := s.nextSubscriptionID + s.nextSubscriptionID++ + s.addSubscription(clientID, subID, subscription) + + go func() { + <-ctx.Done() + s.removeSubscription(clientID, subID) + }() + + return subscription, nil +} + +func (s *AppSessionService) addSubscription(clientID string, subscriptionID uint, subscription chan AppSessionEvent) { + slog.Info("adding subscription", slog.String("clientID", clientID), slog.Uint64("subscriptionID", uint64(subscriptionID))) + s.subscriptions[subscriptionID] = AppSessionSubscription{ + ID: subscriptionID, + Channel: subscription, + ClientID: clientID, + } +} + +func (s *AppSessionService) removeSubscription(clientID string, subscriptionID uint) { + slog.Info("removing subscription", slog.String("clientID", clientID), slog.Uint64("subscriptionID", uint64(subscriptionID))) + delete(s.subscriptions, subscriptionID) +} + +// Add an element to an existing app session, sending an element added event to +// all subscribers whose client IDs are different from the specified client ID. +func (s *AppSessionService) AddElement(clientID string, element AppSessionElement) (*AppSession, error) { + slog.Debug("client adding element", slog.String("clientID", clientID), slog.Any("element", element)) + if element, err := s.appSessions.AddElement(element); err != nil { + slog.Info("error adding element", slog.Any("element", element), slog.Any("error", err)) + return nil, err + } else if session, err := s.appSessions.Read(element.AppSessionID); err != nil { + slog.Info("error adding element", slog.Any("element", element), slog.Any("error", err)) + return nil, err + } else { + event := AppSessionEvent{ + SourceClientID: clientID, + AppSessionID: strconv.FormatUint(uint64(element.AppSessionID), 10), + AddedElement: element, + } + s.sendEvent(clientID, event) // TODO: this is only needed for tests, fix + + for _, addedChild := range element.GetAllChildren() { + event := AppSessionEvent{ + SourceClientID: clientID, + AppSessionID: strconv.FormatUint(uint64(element.AppSessionID), 10), + AddedElement: &addedChild, + } + s.sendEvent(clientID, event) + } + + return session, nil + } +} + +// Remove an element from an existing app session, sending an element removed +// event to all subscribers whose client IDs are different from the specified +// client ID. +func (s *AppSessionService) RemoveElement(clientID string, element AppSessionElement) (*AppSession, error) { + slog.Debug("client removing element", slog.String("clientID", clientID), slog.Any("element", element)) + if err := s.appSessions.RemoveElement(element); err != nil { + slog.Info("error removing element", slog.Any("element", element), slog.Any("error", err)) + return nil, err + } else if session, err := s.appSessions.Read(element.AppSessionID); err != nil { + slog.Info("error removing element", slog.Any("element", element), slog.Any("error", err)) + return nil, err + } else { + event := AppSessionEvent{ + SourceClientID: clientID, + AppSessionID: strconv.FormatUint(uint64(element.AppSessionID), 10), + RemovedElement: &element, + } + go s.sendEvent(clientID, event) // TODO: consider why this is necessary + + return session, nil + } +} + +// Update an element label in an existing app session, sending an element updated +// event to all subscribers whose client IDs are different from the specified +// client ID. +func (s *AppSessionService) UpdateElementLabel(clientID string, element AppSessionElement) (*AppSession, error) { + slog.Debug("client updating element label", slog.String("clientID", clientID), slog.Any("element", element)) + if err := s.appSessions.UpdateElement(element); err != nil { + slog.Info("error updating element label", slog.Any("element", element), slog.Any("error", err)) + return nil, err + } else if session, err := s.appSessions.Read(element.AppSessionID); err != nil { + slog.Info("error updating element label", slog.Any("element", element), slog.Any("error", err)) + return nil, err + } else { + event := AppSessionEvent{ + SourceClientID: clientID, + AppSessionID: strconv.FormatUint(uint64(element.AppSessionID), 10), + UpdatedElement: &element, + } + s.sendEvent(clientID, event) // TODO: consider if this should be a Goroutine + + return session, nil + } +} diff --git a/cli/appdev/service_test.go b/cli/appdev/service_test.go new file mode 100644 index 00000000..f6d39061 --- /dev/null +++ b/cli/appdev/service_test.go @@ -0,0 +1,507 @@ +package appdev + +import ( + "context" + "database/sql" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +func convertID(id uint) string { + return strconv.FormatUint(uint64(id), 10) +} + +type AddElementTestCase struct { + name string + def AppDefinition + added AppSessionElement + expectedEvents []AppSessionEvent + expectedUpdatedSession AppSession +} + +var addElementTestCases = []AddElementTestCase{ + { + name: "add number element", + def: AppDefinition{}, + added: AppSessionElement{Name: "elem", Type: "number", NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}}, + expectedEvents: []AppSessionEvent{ + { + AppSessionID: "0", + SourceClientID: "addingClient", + AddedElement: &AppSessionElement{ + Name: "elem", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + }, + }, + expectedUpdatedSession: AppSession{ + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "elem", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + }, + }, + }, + { + name: "add element to container", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + { + Name: "container_element", + Type: "container", Elements: []AppDefinitionElement{}, + }, + }, + }, + added: AppSessionElement{ + ParentID: sql.NullString{Valid: true, String: "0"}, + Name: "added_element", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + expectedEvents: []AppSessionEvent{ + { + AppSessionID: "0", + SourceClientID: "addingClient", + AddedElement: &AppSessionElement{ + Model: gorm.Model{ID: 1}, + ParentID: sql.NullString{Valid: true, String: "0"}, + Name: "added_element", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + }, + }, + expectedUpdatedSession: AppSession{ + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "container_element", + Type: "container", + Elements: []AppSessionElement{}, + }, + { + Model: gorm.Model{ID: 1}, + Name: "added_element", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + ParentID: sql.NullString{Valid: true, String: "0"}, + }, + }, + }, + }, + { + name: "add container with child", + def: AppDefinition{Elements: []AppDefinitionElement{}}, + added: AppSessionElement{ + Name: "added_container", + Type: "container", + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 1}, + Name: "added_child", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + }, + }, + expectedEvents: []AppSessionEvent{ + { + AppSessionID: "0", + SourceClientID: "addingClient", + AddedElement: &AppSessionElement{ + Model: gorm.Model{ID: 0}, + Name: "added_container", + Type: "container", + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 1}, + Name: "added_child", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + ParentID: sql.NullString{Valid: true, String: "0"}, + }, + }, + }, + }, + { + AppSessionID: "0", + SourceClientID: "addingClient", + AddedElement: &AppSessionElement{ + Model: gorm.Model{ID: 1}, + ParentID: sql.NullString{Valid: true, String: "0"}, + Name: "added_child", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + }, + }, + }, + expectedUpdatedSession: AppSession{ + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "added_container", + Type: "container", + Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 1}, + Name: "added_child", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + ParentID: sql.NullString{Valid: true, String: "0"}, + }, + }, + }, + { + Model: gorm.Model{ID: 1}, + Name: "added_child", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: 10.0}, + ParentID: sql.NullString{Valid: true, String: "0"}, + }, + }, + }, + }, +} + +func TestAddElementReturnsSession(t *testing.T) { + for _, testcase := range addElementTestCases { + repo := NewMockAppSessionRepository() + service := NewAppSessionService(repo) + _, err := repo.Create(testcase.def) + assert.NoError(t, err) + + updatedSession, err := service.AddElement("addingClient", testcase.added) + assert.NoError(t, err) + + assert.Equal(t, testcase.expectedUpdatedSession, *updatedSession) + } +} + +func TestAddElementEmitsEvent(t *testing.T) { + for _, testcase := range addElementTestCases { + t.Run(testcase.name, func(t *testing.T) { + repo := NewMockAppSessionRepository() + service := NewAppSessionService(repo) + session, err := repo.Create(testcase.def) + assert.NoError(t, err) + appSessionID := strconv.FormatUint(uint64(session.ID), 10) + + ctx, cancel := context.WithCancel(context.Background()) + subscription, err := service.Subscribe(ctx, appSessionID, "subscribingClient") + assert.NoError(t, err) + time.Sleep(time.Microsecond * 100) // wait for subscription to start listening + + _, err = service.AddElement("addingClient", testcase.added) + assert.NoError(t, err) + + for _, expectedEvent := range testcase.expectedEvents { + select { + case ev := <-subscription: + println(ev.AddedElement.Name) + assert.Equal(t, expectedEvent, ev) + case <-time.After(time.Millisecond * 100): + t.Error("timed out waiting for subscription event") + } + } + cancel() + }) + } +} + +type RemoveElementReturnsSessionTestCase struct { + name string + def AppDefinition + removeElementPath []string + expectedUpdatedSession AppSession + expectedRemovedElement AppSessionElement +} + +var removeElementTestCases = []RemoveElementReturnsSessionTestCase{ + { + name: "root element", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + {Name: "text_element", Type: "string", Default: "default"}, + }, + }, + removeElementPath: []string{"text_element"}, + expectedUpdatedSession: AppSession{Elements: []AppSessionElement{}}, + expectedRemovedElement: AppSessionElement{ + Model: gorm.Model{ID: 0}, + Name: "text_element", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default"}, + }, + }, + { + name: "nested element", + def: AppDefinition{ + Elements: []AppDefinitionElement{ + { + Name: "container_element", + Type: "container", + Elements: []AppDefinitionElement{ + {Name: "text_element", Type: "string", Default: "default"}, + }, + }, + }, + }, + removeElementPath: []string{"container_element", "text_element"}, + expectedUpdatedSession: AppSession{Elements: []AppSessionElement{ + { + Model: gorm.Model{ID: 0}, + Name: "container_element", + Type: "container", + Elements: []AppSessionElement{}, + }, + }}, + expectedRemovedElement: AppSessionElement{ + Model: gorm.Model{ID: 1}, + Name: "text_element", + Type: "string", + StringValue: sql.NullString{Valid: true, String: "default"}, + ParentID: sql.NullString{Valid: true, String: "0"}, + }, + }, +} + +func TestRemoveElementReturnsSession(t *testing.T) { + for _, testcase := range removeElementTestCases { + t.Run(testcase.name, func(t *testing.T) { + repo := NewMockAppSessionRepository() + service := NewAppSessionService(repo) + s, err := repo.Create(testcase.def) + assert.NoError(t, err) + toRemove, err := s.GetElementByPath(testcase.removeElementPath) + assert.NoError(t, err) + + if assert.NotNil(t, toRemove) { + updatedSession, err := service.RemoveElement("removingClient", *toRemove) + assert.NoError(t, err) + assert.Equal(t, &testcase.expectedUpdatedSession, updatedSession) + } + }) + } +} + +func TestRemoveElementEmitsEvent(t *testing.T) { + for _, testcase := range removeElementTestCases { + t.Run(testcase.name, func(t *testing.T) { + repo := NewMockAppSessionRepository() + service := NewAppSessionService(repo) + session, err := repo.Create(testcase.def) + assert.NoError(t, err) + toRemove, err := session.GetElementByPath(testcase.removeElementPath) + assert.NoError(t, err) + appSessionID := strconv.FormatUint(uint64(session.ID), 10) + + ctx, cancel := context.WithCancel(context.Background()) + subscription, err := service.Subscribe(ctx, appSessionID, "subscribingClient") + assert.NoError(t, err) + time.Sleep(time.Microsecond * 100) // wait for subscription to start listening + + if !assert.NotNil(t, toRemove) { + cancel() + return + } + + _, err = service.RemoveElement("removingClient", *toRemove) + assert.NoError(t, err) + + select { + case ev := <-subscription: + assert.Equal(t, testcase.expectedRemovedElement, *ev.RemovedElement) + cancel() + case <-time.After(time.Second): + t.Error("timed out waiting for subscription event") + cancel() + } + }) + } +} + +type TestUpdateElementTestCase struct { + name string + definition AppDefinition + elementUpdate AppSessionElementUpdate + expectedResultElement AppSessionElement + elementPath []string +} + +var ( + updateElementStringValue = "updated text" + updateElementNumberValue = 22.22 + updateElementHTMLValue = "updated html value
" + updateElementSliderValue = 33.44 + updateElementTestCases = []TestUpdateElementTestCase{ + { + name: "string element", + definition: AppDefinition{ + Name: "App Name", + Elements: []AppDefinitionElement{ + {Name: "Text", Type: "string", Default: "default text"}, + }, + }, + elementPath: []string{"Text"}, + elementUpdate: AppSessionElementUpdate{StringValue: &updateElementStringValue}, + expectedResultElement: AppSessionElement{ + Name: "Text", + Type: "string", + StringValue: sql.NullString{Valid: true, String: updateElementStringValue}, + }, + }, + { + name: "number element", + definition: AppDefinition{ + Name: "App Name", + Elements: []AppDefinitionElement{ + {Name: "Number", Type: "number", Default: 11.11}, + }, + }, + elementPath: []string{"Number"}, + elementUpdate: AppSessionElementUpdate{NumberValue: &updateElementNumberValue}, + expectedResultElement: AppSessionElement{ + Name: "Number", + Type: "number", + NumberValue: sql.NullFloat64{Valid: true, Float64: updateElementNumberValue}, + }, + }, + { + name: "html element", + definition: AppDefinition{ + Name: "App Name", + Elements: []AppDefinitionElement{ + {Name: "Html", Type: "html", Default: "updated html value
"}, + }, + }, + elementPath: []string{"Html"}, + elementUpdate: AppSessionElementUpdate{HTMLValue: &updateElementHTMLValue}, + expectedResultElement: AppSessionElement{ + Name: "Html", + Type: "html", + HTMLValue: sql.NullString{Valid: true, String: updateElementHTMLValue}, + }, + }, + { + name: "slider element", + definition: AppDefinition{ + Name: "App Name", + Elements: []AppDefinitionElement{ + {Name: "Slider", Type: "slider", Default: 11.22, SliderMinValue: -10.0, SliderMaxValue: 100.0}, + }, + }, + elementPath: []string{"Slider"}, + elementUpdate: AppSessionElementUpdate{SliderValue: &updateElementSliderValue}, + expectedResultElement: AppSessionElement{ + Name: "Slider", + Type: "slider", + SliderValue: sql.NullFloat64{Valid: true, Float64: updateElementSliderValue}, + SliderMinValue: sql.NullFloat64{Valid: true, Float64: -10.0}, + SliderMaxValue: sql.NullFloat64{Valid: true, Float64: 100.0}, + }, + }, + { + name: "element in container", + definition: AppDefinition{ + Name: "App Name", + Elements: []AppDefinitionElement{ + {Name: "Container", Type: "container", Elements: []AppDefinitionElement{ + {Name: "Text", Type: "string", Default: "default text"}, + }}, + }, + }, + elementPath: []string{"Container", "Text"}, + elementUpdate: AppSessionElementUpdate{StringValue: &updateElementStringValue}, + expectedResultElement: AppSessionElement{ + Name: "Text", + Type: "string", + StringValue: sql.NullString{Valid: true, String: updateElementStringValue}, + }, + }, + { + name: "element in nested container", + definition: AppDefinition{ + Name: "App Name", + Elements: []AppDefinitionElement{ + {Name: "Container1", Type: "container", Elements: []AppDefinitionElement{ + {Name: "Container2", Type: "container", Elements: []AppDefinitionElement{ + {Name: "Text", Type: "string", Default: "default text"}, + }}, + }}, + }, + }, + elementPath: []string{"Container1", "Container2", "Text"}, + elementUpdate: AppSessionElementUpdate{StringValue: &updateElementStringValue}, + expectedResultElement: AppSessionElement{ + Name: "Text", + Type: "string", + StringValue: sql.NullString{Valid: true, String: updateElementStringValue}, + }, + }, + } +) + +func TestUpdateElementReturnsResultElement(t *testing.T) { + for _, testcase := range updateElementTestCases { + t.Run(testcase.name, func(t *testing.T) { + repo := NewMockAppSessionRepository() + service := NewAppSessionService(repo) + + session, err := repo.Create(testcase.definition) + assert.NoError(t, err) + + element, err := session.GetElementByPath(testcase.elementPath) + assert.NoError(t, err) + + testcase.expectedResultElement.ID = element.ID + testcase.expectedResultElement.ParentID = element.ParentID + testcase.elementUpdate.ElementID = convertID(element.ID) + result, err := service.UpdateElement(session.ID, "updatingClient", testcase.elementUpdate) + assert.NoError(t, err) + assert.Equal(t, testcase.expectedResultElement, *result.Element) + }) + } +} + +func TestUpdateElementSendsEvent(t *testing.T) { + for _, testcase := range updateElementTestCases { + t.Run(testcase.name, func(t *testing.T) { + repo := NewMockAppSessionRepository() + service := NewAppSessionService(repo) + + session, err := repo.Create(testcase.definition) + assert.NoError(t, err) + + element, err := session.GetElementByPath(testcase.elementPath) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + subscription, err := service.Subscribe(ctx, convertID(element.AppSessionID), "subscribingClient") + assert.NoError(t, err) + time.Sleep(time.Millisecond) + + testcase.expectedResultElement.ID = element.ID + testcase.expectedResultElement.ParentID = element.ParentID + testcase.elementUpdate.ElementID = convertID(element.ID) + _, err = service.UpdateElement(session.ID, "updatingClient", testcase.elementUpdate) + assert.NoError(t, err) + + select { + case ev := <-subscription: + assert.Equal(t, testcase.expectedResultElement, *ev.UpdatedElement) + cancel() + case <-time.After(time.Second): + t.Error("timed out waiting for subscription event") + cancel() + } + }) + } +} diff --git a/cli/appdev/test_repository.go b/cli/appdev/test_repository.go new file mode 100644 index 00000000..39093059 --- /dev/null +++ b/cli/appdev/test_repository.go @@ -0,0 +1,136 @@ +package appdev + +import ( + "errors" + "fmt" + "strconv" +) + +type MockAppSessionRepository struct { + toolSessions map[uint]AppSession + toolSessionElements map[uint]*AppSessionElement + nextToolSessionID uint + nextElementID uint +} + +func NewMockAppSessionRepository() *MockAppSessionRepository { + return &MockAppSessionRepository{ + toolSessions: make(map[uint]AppSession), + toolSessionElements: make(map[uint]*AppSessionElement), + } +} + +func (r *MockAppSessionRepository) Create(def AppDefinition) (*AppSession, error) { + toolSession := def.CreateSession() + toolSession.Model.ID = r.nextToolSessionID + r.addElementIDs(toolSession.Elements) + toolSession.Elements = append(toolSession.Elements, toolSession.GetAllChildren()...) + r.toolSessions[r.nextToolSessionID] = toolSession + r.nextToolSessionID++ + + return &toolSession, nil +} + +func (r *MockAppSessionRepository) addElementIDs(elements []AppSessionElement) { + for i := 0; i < len(elements); i++ { + element := &elements[i] + r.assignElementID(element) + r.toolSessionElements[element.ID] = element + r.addElementIDs(element.Elements) + } +} + +func (r *MockAppSessionRepository) Delete(id uint) error { + delete(r.toolSessions, id) + return nil +} + +func (r *MockAppSessionRepository) Read(id uint) (*AppSession, error) { + if toolSession, ok := r.toolSessions[id]; ok { + return &toolSession, nil + } else { + return nil, errors.New("tool session does not exist") + } +} + +func (r *MockAppSessionRepository) UpdateElement(element AppSessionElement) error { + if elementReference, ok := r.toolSessionElements[element.ID]; ok { + *elementReference = element + return nil + } else { + return errors.New("tool session element does not exist") + } +} + +func (r *MockAppSessionRepository) AddElement(element AppSessionElement) (*AppSessionElement, error) { + if session, ok := r.toolSessions[element.AppSessionID]; !ok { + return nil, fmt.Errorf("cannot add element to tool session %d that does not exist", element.AppSessionID) + } else { + r.assignElementID(&element) + session.Elements = append(session.Elements, element) + session.Elements = append(session.Elements, r.addChildElements(&element)...) + r.toolSessions[session.ID] = session + + return &element, nil + } +} + +func (r *MockAppSessionRepository) assignElementID(element *AppSessionElement) { + element.Model.ID = r.nextElementID + r.nextElementID++ +} + +func (r *MockAppSessionRepository) addChildElements(parent *AppSessionElement) []AppSessionElement { + added := []AppSessionElement{} + + for i := 0; i < len(parent.Elements); i++ { + e := &parent.Elements[i] + e.ParentID.Valid = true + e.ParentID.String = strconv.FormatUint(uint64(parent.ID), 10) + r.assignElementID(e) + added = append(added, *e) + if e.Type == "container" { + added = append(added, r.addChildElements(e)...) + } + } + + return added +} + +func (r *MockAppSessionRepository) RemoveElement(element AppSessionElement) error { + if session, ok := r.toolSessions[element.AppSessionID]; !ok { + return fmt.Errorf("cannot remove element from tool session %d that does not exist", element.AppSessionID) + } else { + newElements, found := removeElement(element, session.Elements) + + if !found { + return ErrRemoveNonExistingElement + } else { + session.Elements = newElements + r.toolSessions[session.ID] = session + + return nil + } + } +} + +func removeElement(removed AppSessionElement, elements []AppSessionElement) ([]AppSessionElement, bool) { + newElements := make([]AppSessionElement, 0) + found := false + + for _, v := range elements { + childNewElements, childFound := removeElement(removed, v.Elements) + v.Elements = childNewElements + if childFound { + found = true + } + + if v.ID == removed.ID { + found = true + } else { + newElements = append(newElements, v) + } + } + + return newElements, found +} diff --git a/cli/appdevsession/apprunner.go b/cli/appdevsession/apprunner.go new file mode 100644 index 00000000..c36bd588 --- /dev/null +++ b/cli/appdevsession/apprunner.go @@ -0,0 +1,175 @@ +package appdevsession + +import ( + "bufio" + "fmt" + "io" + "log/slog" + "os" + "strconv" + + "numerous/cli/appdev" +) + +type appRunner struct { + executor commandExecutor + appSessions appdev.AppSessionRepository + appSessionService appdev.AppSessionService + port string + pythonInterpeterPath string + appModulePath string + appClassName string + exit chan struct{} + output appdev.Output + cmd command +} + +// Runs the app, reading the definition from the app file, killing app process +// if it exists, updating the session (if it exists) with the new definition, +// and launching a python process running the app. +func (r *appRunner) Run() { + defer r.output.AwaitingAppChanges() + + if r.cmd != nil { + if err := r.cmd.Kill(); err != nil { + slog.Info("error killing python app process", slog.String("error", err.Error())) + } + } + + appdef := r.readApp() + if appdef == nil { + r.cmd = nil + return + } + + session, err := r.updateExistingSession(appdef) + if err != nil { + if session, err = r.appSessions.Create(*appdef); err != nil { + r.output.ErrorCreatingAppSession(err) + r.signalExit() + r.cmd = nil + + return + } + slog.Debug("created session", slog.String("name", session.Name), slog.Any("id", session.ID), slog.Int("elements", len(session.Elements))) + } + + r.cmd = r.runApp(*session) +} + +func (r *appRunner) readApp() *appdev.AppDefinition { + cmd := r.executor.Create(r.pythonInterpeterPath, "-m", "numerous", "read", r.appModulePath, r.appClassName) + output, err := cmd.Output() + if err != nil { + slog.Debug("Error reading app definition", slog.String("error", err.Error())) + r.output.ErrorReadingApp(string(output), err) + + return nil + } + + result, err := appdev.ParseAppDefinition(output) + if err != nil { + slog.Warn("Error parsing app definition", slog.String("error", err.Error())) + r.output.ErrorParsingApp(err) + + return nil + } + + if result.App != nil { + slog.Debug("Read app definition", slog.String("name", result.App.Name)) + return result.App + } else if result.Error != nil { + r.output.ErrorLoadingApp(result.Error) + } + + return nil +} + +func (r *appRunner) updateExistingSession(def *appdev.AppDefinition) (*appdev.AppSession, error) { + existingSession, err := r.appSessions.Read(0) + if err != nil { + return nil, err + } + + diff := appdev.GetAppSessionDifference(*existingSession, *def) + slog.Debug("got app session difference", slog.Int("added", len(diff.Added)), slog.Int("removed", len(diff.Removed))) + for _, added := range diff.Added { + if addedSession, err := r.appSessionService.AddElement("server", added); err == nil { + slog.Debug("added element", slog.Any("elementID", added.ID), slog.String("name", added.Name)) + existingSession = addedSession + } else { + slog.Debug("error adding element after update", slog.String("error", err.Error())) + r.output.ErrorUpdateAddingElement() + } + } + + for _, removed := range diff.Removed { + slog.Debug("handling removed element", slog.Any("element", removed)) + if removedSession, err := r.appSessionService.RemoveElement("server", removed); err == nil { + existingSession = removedSession + } else { + slog.Info("error removing element after update", slog.String("error", err.Error())) + r.output.ErrorUpdateRemovingElement() + } + } + + for _, updated := range diff.Updated { + slog.Debug("handling updated element", slog.Any("element", updated)) + if updatedSession, err := r.appSessionService.UpdateElementLabel("server", updated); err == nil { + existingSession = updatedSession + } else { + slog.Info("error updating element after update", slog.String("error", err.Error())) + r.output.ErrorUpdateUpdatingElement() + } + } + + return existingSession, nil +} + +func (r *appRunner) runApp(session appdev.AppSession) command { + cmd := r.executor.Create( + r.pythonInterpeterPath, + "-m", + "numerous", + "run", + "--graphql-url", + fmt.Sprintf("http://localhost:%s/query", r.port), + "--graphql-ws-url", + fmt.Sprintf("ws://localhost:%s/query", r.port), + r.appModulePath, + r.appClassName, + strconv.FormatUint(uint64(session.ID), 10), + ) + cmd.SetEnv("PYTHONUNBUFFERED", "1") + cmd.SetEnv("PATH", os.Getenv("PATH")) + r.wrapAppOutput(cmd.StdoutPipe, "stdout") + r.wrapAppOutput(cmd.StderrPipe, "stderr") + + if err := cmd.Start(); err != nil { + slog.Debug("could not start app", slog.String("error", err.Error())) + r.output.ErrorStartingApp(err) + + return nil + } + r.output.StartedApp() + + return cmd +} + +func (r *appRunner) wrapAppOutput(pipeFunc func() (io.ReadCloser, error), streamName string) { + if reader, err := pipeFunc(); err != nil { + r.output.ErrorGettingAppOutputStream(streamName) + } else { + scanner := bufio.NewScanner(reader) + go func() { + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + r.output.PrintAppLogLine(scanner.Text()) + } + }() + } +} + +func (r *appRunner) signalExit() { + r.exit <- struct{}{} +} diff --git a/cli/appdevsession/command.go b/cli/appdevsession/command.go new file mode 100644 index 00000000..5daa68cf --- /dev/null +++ b/cli/appdevsession/command.go @@ -0,0 +1,66 @@ +package appdevsession + +import ( + "fmt" + "io" + "log/slog" + "os/exec" +) + +type commandExecutor interface { + // Create a command that runs the named command, with the provided arguments. + Create(name string, args ...string) command +} + +type command interface { + // Kill the command + Kill() error + // Start the command + Start() error + // Set an environment variable for the command + SetEnv(string, string) + // Returns a reader for the commands standard output. + StdoutPipe() (io.ReadCloser, error) + // Returns a reader for the commands standard error. + StderrPipe() (io.ReadCloser, error) + // Runs the command, and returns its combined output (both standard output and standard error). + Output() ([]byte, error) +} + +type execCommandExecutor struct{} + +func (e *execCommandExecutor) Create(name string, args ...string) command { + slog.Debug("creating command", slog.String("name", name), slog.Any("args", args)) + cmd := &execCommand{cmd: *exec.Command(name, args...)} + + return cmd +} + +type execCommand struct { + cmd exec.Cmd +} + +func (e *execCommand) Start() error { + slog.Debug("starting command", slog.String("path", e.cmd.Path), slog.Any("args", e.cmd.Args)) + return e.cmd.Start() +} + +func (e *execCommand) Kill() error { + return e.cmd.Process.Kill() +} + +func (e *execCommand) SetEnv(key string, value string) { + e.cmd.Env = append(e.cmd.Env, fmt.Sprintf("%s=%s", key, value)) +} + +func (e *execCommand) StdoutPipe() (io.ReadCloser, error) { + return e.cmd.StdoutPipe() +} + +func (e *execCommand) StderrPipe() (io.ReadCloser, error) { + return e.cmd.StderrPipe() +} + +func (e *execCommand) Output() ([]byte, error) { + return e.cmd.CombinedOutput() +} diff --git a/cli/appdevsession/filewatch.go b/cli/appdevsession/filewatch.go new file mode 100644 index 00000000..9b4789c7 --- /dev/null +++ b/cli/appdevsession/filewatch.go @@ -0,0 +1,115 @@ +package appdevsession + +import ( + "log/slog" + "time" + + "github.com/fsnotify/fsnotify" +) + +type clock interface { + Now() time.Time + Since(time.Time) time.Duration +} + +type timeclock struct{} + +func (c *timeclock) Now() time.Time { + return time.Now() +} + +func (c *timeclock) Since(t time.Time) time.Duration { + return time.Since(t) +} + +type fileWatcherFactory interface { + // Create a FileWatcher + Create() (FileWatcher, error) +} + +type FSNotifyFileWatcherFactory struct{} + +func (f *FSNotifyFileWatcherFactory) Create() (FileWatcher, error) { + if watcher, err := fsnotify.NewBufferedWatcher(1); err != nil { + return nil, err + } else { + return &FSNotifyWatcher{watcher: watcher}, nil + } +} + +type FileWatcher interface { + // Close the file watcher + Close() error + // Add a file to the file watcher + Add(name string) error + // Get a channel of file events noticed by the FileWatcher + GetEvents() chan fsnotify.Event + // Get a channel of errors for the FileWatcher + GetErrors() chan error +} + +type FSNotifyWatcher struct { + watcher *fsnotify.Watcher +} + +func (w *FSNotifyWatcher) Close() error { + return w.watcher.Close() +} + +func (w *FSNotifyWatcher) Add(name string) error { + return w.watcher.Add(name) +} + +func (w *FSNotifyWatcher) GetEvents() chan fsnotify.Event { + return w.watcher.Events +} + +func (w *FSNotifyWatcher) GetErrors() chan error { + return w.watcher.Errors +} + +// Watch the given path for file changes, returning a channel to which is sent +// the file names of updated files. +// +// Updates happening within minInterval time of the last update are ignored. +func WatchAppChanges(path string, watcherFactory fileWatcherFactory, clock clock, minInterval time.Duration) (chan string, error) { + updates := make(chan string) + + watcher, err := watcherFactory.Create() + if err != nil { + return nil, err + } + + err = watcher.Add(path) + if err != nil { + watcher.Close() + return nil, err + } + + go func() { + defer close(updates) + defer watcher.Close() + lastUpdate := clock.Now() + for { + select { + case event, ok := <-watcher.GetEvents(): + if !ok { + return + } + elapsed := clock.Since(lastUpdate) + + if event.Has(fsnotify.Write) && elapsed > minInterval { + lastUpdate = clock.Now() + updates <- event.Name + } + case err, ok := <-watcher.GetErrors(): + if !ok { + return + } + slog.Warn("error watching app code", slog.Any("error", err)) + } + } + }() + + return updates, nil +} diff --git a/cli/appdevsession/filewatch_test.go b/cli/appdevsession/filewatch_test.go new file mode 100644 index 00000000..e5c87ca9 --- /dev/null +++ b/cli/appdevsession/filewatch_test.go @@ -0,0 +1,33 @@ +package appdevsession + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWatchToolChanges(t *testing.T) { + t.Run("sends message on file write", func(t *testing.T) { + dir := t.TempDir() + filepath := dir + "/file.txt" + + err := os.WriteFile(filepath, []byte("content before update\n"), 0o700) + require.NoError(t, err) + + changes, err := WatchAppChanges(filepath, &FSNotifyFileWatcherFactory{}, &timeclock{}, time.Second*0) + require.NoError(t, err) + + //nolint:errcheck + go os.WriteFile(filepath, []byte("content after update 5\n"), 0o700) + + select { + case changedFile := <-changes: + assert.Equal(t, filepath, changedFile) + case <-time.After(time.Second * 4): + assert.FailNow(t, "timed out waiting for file update") + } + }) +} diff --git a/cli/appdevsession/session.go b/cli/appdevsession/session.go new file mode 100644 index 00000000..add27caa --- /dev/null +++ b/cli/appdevsession/session.go @@ -0,0 +1,141 @@ +package appdevsession + +import ( + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "numerous/cli/appdev" + "numerous/cli/assets" + "numerous/cli/server" +) + +type devSession struct { + executor commandExecutor + fileWatcherFactory fileWatcherFactory + clock clock + appSessions appdev.AppSessionRepository + appSessionService appdev.AppSessionService + port string + pythonInterpeterPath string + appModulePath string + appClassName string + exit chan struct{} + server *http.Server + minUpdateInterval time.Duration + output appdev.Output +} + +func CreateAndRunDevSession(pythonInterpreterPath string, modulePath string, className string, port string) { + appSessions := appdev.InMemoryAppSessionRepository{} + output := appdev.NewLipglossOutput(modulePath, className) + session := devSession{ + executor: &execCommandExecutor{}, + fileWatcherFactory: &FSNotifyFileWatcherFactory{}, + clock: &timeclock{}, + appSessions: &appSessions, + appSessionService: appdev.NewAppSessionService(&appSessions), + port: port, + pythonInterpeterPath: pythonInterpreterPath, + appModulePath: modulePath, + appClassName: className, + exit: make(chan struct{}, 1), + minUpdateInterval: time.Second, + output: &output, + } + session.run() +} + +func (d *devSession) run() { + slog.Debug("running session", slog.Any("session", d)) + d.output.StartingApp(d.port) + go d.startServer() + d.setupSystemSignal() + if err := d.validateAppExists(); err != nil { + d.output.AppModuleNotFound(err) + os.Exit(1) + } + + appChanges, err := WatchAppChanges(d.appModulePath, d.fileWatcherFactory, d.clock, d.minUpdateInterval) + if err != nil { + d.output.ErrorWatchingAppFiles(err) + os.Exit(1) + } + + go d.handleAppChanges(appChanges) + + d.awaitExit() +} + +func (d *devSession) handleAppChanges(appChanges chan string) { + run := appRunner{ + executor: d.executor, + appSessions: d.appSessions, + appSessionService: d.appSessionService, + port: d.port, + pythonInterpeterPath: d.pythonInterpeterPath, + appModulePath: d.appModulePath, + appClassName: d.appClassName, + exit: d.exit, + output: d.output, + } + run.Run() + + for { + if _, ok := <-appChanges; !ok { + break + } + + d.output.FileUpdatedRestartingApp() + run.Run() + } +} + +func (d *devSession) signalExit() { + d.exit <- struct{}{} +} + +func (d *devSession) startServer() { + if d.server != nil { + panic("can only run server once per session") + } + registers := []server.HandlerRegister{assets.SPAMRegister} + d.server = server.CreateServer(server.ServerOptions{ + HTTPPort: d.port, + AppSessions: d.appSessions, + AppSessionService: d.appSessionService, + Registers: registers, + GQLPath: "/query", + PlaygroundPath: "/playground", + }) + + d.server.ListenAndServe() //nolint:errcheck +} + +func (d *devSession) validateAppExists() error { + _, err := os.Stat(d.appModulePath) + return err +} + +func (d *devSession) setupSystemSignal() { + systemExitSignal := make(chan os.Signal, 1) + go func() { + signal.Notify(systemExitSignal, syscall.SIGINT, syscall.SIGTERM) + <-systemExitSignal + d.signalExit() + }() +} + +func (d *devSession) awaitExit() { + <-d.exit + d.output.Stopping() + if d.server != nil { + if err := d.server.Close(); err != nil { + slog.Debug("error closing server", slog.String("error", err.Error())) + } + d.server = nil + } +} diff --git a/cli/appdevsession/session_test.go b/cli/appdevsession/session_test.go new file mode 100644 index 00000000..933cd792 --- /dev/null +++ b/cli/appdevsession/session_test.go @@ -0,0 +1,259 @@ +package appdevsession + +import ( + "bytes" + "database/sql" + "io" + "os" + "testing" + "time" + + "numerous/cli/appdev" + + "github.com/fsnotify/fsnotify" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type mockCommandExecutor struct{ mock.Mock } + +func (e *mockCommandExecutor) Create(name string, args ...string) command { + var callArgs []interface{} + callArgs = append(callArgs, name) + for _, a := range args { + callArgs = append(callArgs, a) + } + + mockArgs := e.Called(callArgs...) + + return mockArgs.Get(0).(command) +} + +type mockCommand struct { + mock.Mock + name string +} + +func (e *mockCommand) Kill() error { + args := e.Called() + return args.Error(0) +} + +func (e *mockCommand) Start() error { + args := e.Called() + return args.Error(0) +} + +func (e *mockCommand) SetEnv(key string, value string) { + e.Called() +} + +func (e *mockCommand) StdoutPipe() (io.ReadCloser, error) { + args := e.Called() + return args.Get(0).(io.ReadCloser), args.Error(1) +} + +func (e *mockCommand) StderrPipe() (io.ReadCloser, error) { + args := e.Called() + return args.Get(0).(io.ReadCloser), args.Error(1) +} + +func (e *mockCommand) Output() ([]byte, error) { + args := e.Called() + return args.Get(0).([]byte), args.Error(1) +} + +type MockFileWatcherFactory struct{ mock.Mock } + +func (f *MockFileWatcherFactory) Create() (FileWatcher, error) { + args := f.Called() + return args.Get(0).(FileWatcher), args.Error(1) +} + +type mockFileWatcher struct{ mock.Mock } + +func (w *mockFileWatcher) Close() error { + args := w.Called() + return args.Error(0) +} + +func (w *mockFileWatcher) Add(name string) error { + args := w.Called(name) + return args.Error(0) +} + +func (w *mockFileWatcher) GetEvents() chan fsnotify.Event { + args := w.Called() + return args.Get(0).(chan fsnotify.Event) +} + +func (w *mockFileWatcher) GetErrors() chan error { + args := w.Called() + return args.Get(0).(chan error) +} + +type mockClock struct{ mock.Mock } + +func (c *mockClock) Now() time.Time { + args := c.Called() + return args.Get(0).(time.Time) +} + +func (c *mockClock) Since(t time.Time) time.Duration { + args := c.Called(t) + return args.Get(0).(time.Duration) +} + +func TestRunDevSession(t *testing.T) { + t.Run("calls expected commands", func(t *testing.T) { + appFile, err := os.CreateTemp("", "*-app.py") + require.NoError(t, err) + appFileName := appFile.Name() + appClassName := "App" + + readCmd := mockCommand{} + runCmd := mockCommand{} + executor := mockCommandExecutor{} + sessions := appdev.NewMockAppSessionRepository() + fileWatcher := &mockFileWatcher{} + fileWatcher.On("Add", mock.Anything).Return(nil) + fileWatcher.On("Close").Return(nil) + fileWatcher.On("GetEvents").Return(make(chan fsnotify.Event), nil) + fileWatcher.On("GetErrors").Return(make(chan error), nil) + + fileWatcherFactory := &MockFileWatcherFactory{} + fileWatcherFactory.On("Create").Return(fileWatcher, nil) + session := devSession{ + executor: &executor, + fileWatcherFactory: fileWatcherFactory, + clock: &timeclock{}, + appSessions: sessions, + appSessionService: appdev.NewAppSessionService(sessions), + port: "7001", + appModulePath: appFile.Name(), + appClassName: "App", + pythonInterpeterPath: "python", + exit: make(chan struct{}), + output: &appdev.FmtOutput{}, + } + + output := `{"app": {"title": "app", "elements": [{"name": "text", "type": "string", "label": "Text", "default": "default"}]}}` + readCmd.On("Output").Return([]byte(output), nil) + runCmd.On("SetEnv", mock.Anything).Return() + runCmd.On("StdoutPipe", mock.Anything).Return(io.NopCloser(bytes.NewBuffer(nil)), nil) + runCmd.On("StderrPipe", mock.Anything).Return(io.NopCloser(bytes.NewBuffer(nil)), nil) + runCmd.On("Start").Return(nil) + executor.On("Create", "python", "-m", "numerous", "read", appFileName, appClassName).Return(&readCmd, nil) + executor.On("Create", "python", "-m", "numerous", "run", "--graphql-url", "http://localhost:7001/query", "--graphql-ws-url", "ws://localhost:7001/query", appFileName, appClassName, "0").Return(&runCmd, nil).Run(func(args mock.Arguments) { + session.signalExit() + }) + + session.run() + + executor.AssertExpectations(t) + readCmd.AssertExpectations(t) + runCmd.AssertExpectations(t) + }) + + t.Run("updates session and restarts app when file is updated", func(t *testing.T) { + appFile, err := os.CreateTemp("", "*-app.py") + require.NoError(t, err) + appFileName := appFile.Name() + appClassName := "App" + fileEvents := make(chan fsnotify.Event) + + initialReadCmd := mockCommand{name: "initial read"} + initialRunCmd := mockCommand{name: "initial run"} + updateReadCmd := mockCommand{name: "updated read"} + updateRunCmd := mockCommand{name: "updated run"} + executor := mockCommandExecutor{} + sessions := appdev.NewMockAppSessionRepository() + fileWatcher := &mockFileWatcher{} + fileWatcher.On("Add", mock.Anything).Return(nil) + fileWatcher.On("Close").Return(nil) + fileWatcher.On("GetEvents").Return(fileEvents, nil) + fileWatcher.On("GetErrors").Return(make(chan error), nil) + mockClock := mockClock{} + + fileWatcherFactory := &MockFileWatcherFactory{} + fileWatcherFactory.On("Create").Return(fileWatcher, nil) + session := devSession{ + executor: &executor, + fileWatcherFactory: fileWatcherFactory, + clock: &mockClock, + appSessions: sessions, + appSessionService: appdev.NewAppSessionService(sessions), + port: "7001", + appModulePath: appFile.Name(), + appClassName: "App", + pythonInterpeterPath: "python", + exit: make(chan struct{}), + minUpdateInterval: time.Second, + output: &appdev.FmtOutput{}, + } + + initialDef := ` + { + "app": { + "title": "app", + "elements": [{"name": "text", "type": "string", "label": "Text", "default": "default"}] + } + }` + initialReadCmd.On("Output").Return([]byte(initialDef), nil) + initialRunCmd.On("SetEnv", mock.Anything).Return() + initialRunCmd.On("StdoutPipe", mock.Anything).Return(io.NopCloser(bytes.NewBuffer(nil)), nil) + initialRunCmd.On("StderrPipe", mock.Anything).Return(io.NopCloser(bytes.NewBuffer(nil)), nil) + initialRunCmd.On("Start").Return(nil) + initialRunCmd.On("Kill").Return(nil) + + updatedDef := ` + { + "app": { + "title": "app", + "elements": [{"name": "number", "type": "number", "label": "Number", "default": 12.34}] + } + }` + updateReadCmd.On("Output").Return([]byte(updatedDef), nil) + updateRunCmd.On("SetEnv", mock.Anything).Return() + updateRunCmd.On("StdoutPipe", mock.Anything).Return(io.NopCloser(bytes.NewBuffer(nil)), nil) + updateRunCmd.On("StderrPipe", mock.Anything).Return(io.NopCloser(bytes.NewBuffer(nil)), nil) + updateRunCmd.On("Start").Return(nil) + + mockClock.On("Now", mock.Anything).Return(time.Time{}) + mockClock.On("Since", mock.Anything).Return(2 * session.minUpdateInterval) + + executor.On("Create", "python", "-m", "numerous", "read", appFileName, appClassName).Return(&initialReadCmd, nil).Once() + executor.On("Create", "python", "-m", "numerous", "read", appFileName, appClassName).Return(&updateReadCmd, nil).Once() + executor.On("Create", "python", "-m", "numerous", "run", "--graphql-url", "http://localhost:7001/query", "--graphql-ws-url", "ws://localhost:7001/query", appFileName, appClassName, "0"). + Return(&initialRunCmd, nil). + Once(). + Run(func(args mock.Arguments) { + println("Creating a run command first time!") + fileEvents <- fsnotify.Event{Name: appFileName, Op: fsnotify.Write} + }) + executor.On("Create", "python", "-m", "numerous", "run", "--graphql-url", "http://localhost:7001/query", "--graphql-ws-url", "ws://localhost:7001/query", appFileName, appClassName, "0"). + Return(&updateRunCmd, nil). + Once(). + Run(func(args mock.Arguments) { + println("Creating a run command second time!") + session.signalExit() + }) + + session.run() + close(fileEvents) + + expectedElements := []appdev.AppSessionElement{ + {Model: gorm.Model{ID: 1}, Name: "number", Label: "Number", Type: "number", NumberValue: sql.NullFloat64{Valid: true, Float64: 12.34}, Elements: []appdev.AppSessionElement{}}, + } + if appSession, err := sessions.Read(0); assert.NoError(t, err) { + assert.Equal(t, expectedElements, appSession.Elements) + } + executor.AssertExpectations(t) + initialReadCmd.AssertExpectations(t) + initialRunCmd.AssertExpectations(t) + updateReadCmd.AssertExpectations(t) + updateRunCmd.AssertExpectations(t) + }) +} diff --git a/cli/assets/assets.go b/cli/assets/assets.go new file mode 100644 index 00000000..d70094a9 --- /dev/null +++ b/cli/assets/assets.go @@ -0,0 +1,65 @@ +package assets + +import ( + "embed" + "io" + "io/fs" + "log" + "log/slog" + "net/http" + "os" +) + +//nolint:typecheck +//go:embed spa/** +var spaFS embed.FS + +//go:embed spa/index.html +var spaIndex []byte + +type IndexHandler struct{} + +func (i *IndexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(spaIndex); err != nil { + slog.Info("error serving SPA index", slog.Any("error", err)) + } +} + +func SPAMRegister(mux *http.ServeMux) { + subFS, subErr := fs.Sub(spaFS, "spa") + if subErr != nil { + log.Fatalf("Could create sub filesystem: %s", subFS) + } + + httpFs := http.FS(subFS) + fileServer := http.FileServer(httpFs) + mux.Handle("/assets/", fileServer) + mux.Handle("/vite.svg", fileServer) + mux.Handle("/", &IndexHandler{}) +} + +//go:embed images/placeholder_tool_cover.png +var image embed.FS + +func CopyToolPlaceholderCover(destPath string) error { + srcFile, err := image.Open("images/placeholder_tool_cover.png") + if err != nil { + return err + } + defer srcFile.Close() + + destFile, err := os.Create(destPath) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + if err != nil { + return err + } + + return nil +} diff --git a/cli/assets/images/placeholder_tool_cover.png b/cli/assets/images/placeholder_tool_cover.png new file mode 100644 index 00000000..d86fbc92 Binary files /dev/null and b/cli/assets/images/placeholder_tool_cover.png differ diff --git a/cli/assets/spa/assets/em-fave-jwADWkv5.ico b/cli/assets/spa/assets/em-fave-jwADWkv5.ico new file mode 100644 index 00000000..c3d95ef7 Binary files /dev/null and b/cli/assets/spa/assets/em-fave-jwADWkv5.ico differ diff --git a/cli/assets/spa/assets/index-Bd3b9qkg.js b/cli/assets/spa/assets/index-Bd3b9qkg.js new file mode 100644 index 00000000..dbc7fdf4 --- /dev/null +++ b/cli/assets/spa/assets/index-Bd3b9qkg.js @@ -0,0 +1,797 @@ +var A8=Object.defineProperty;var F8=(e,t,n)=>t in e?A8(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var j8=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Qr=(e,t,n)=>(F8(e,typeof t!="symbol"?t+"":t,n),n);var fpe=j8((Mpe,rb)=>{(function(){try{var e=typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{},t=new Error().stack;t&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[t]="e437091c-7d43-4b33-8525-217c7b1a7269",e._sentryDebugIdIdentifier="sentry-dbid-e437091c-7d43-4b33-8525-217c7b1a7269")}catch{}})();function B3(e,t){for(var n=0;n