From b53183523bd721695a6d33ba3fb20991306daaa1 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Sun, 7 Apr 2024 20:54:35 -0700 Subject: [PATCH] Add `shell` command to Protean CLI (#402) The `shell` command accepts a domain path as an argument. A domain context is activated, and the domain instance is imported. The shell also preloads all domain elements to allow free-form domain exploration. --- poetry.lock | 248 ++++++++++++++---- pyproject.toml | 2 +- src/protean/cli/__init__.py | 53 +++- src/protean/cli/docs.py | 26 ++ src/protean/domain/__init__.py | 19 ++ src/protean/utils/domain.py | 11 +- tests/cli/test_docs.py | 10 + tests/cli/test_domain_loading.py | 28 +- tests/cli/test_generate_docker_compose.py | 17 +- tests/cli/test_shell.py | 48 ++++ tests/domain/test_domain_shell_context.py | 48 ++++ tests/shared.py | 22 +- .../support/test_domains/test9/publishing.py | 22 ++ 13 files changed, 456 insertions(+), 98 deletions(-) create mode 100644 src/protean/cli/docs.py create mode 100644 tests/cli/test_docs.py create mode 100644 tests/cli/test_shell.py create mode 100644 tests/domain/test_domain_shell_context.py create mode 100644 tests/support/test_domains/test9/publishing.py diff --git a/poetry.lock b/poetry.lock index 53174514..a54d9f3d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -55,6 +55,24 @@ types-python-dateutil = ">=2.8.10" doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + [[package]] name = "autoflake" version = "2.3.1" @@ -810,6 +828,20 @@ six = "*" [package.extras] develop = ["coverage (<5.0.0)", "mock", "pytest (>=3.0.0)", "pytest-cov", "pytest-mock (<3.0.0)", "pytz", "sphinx", "sphinx-rtd-theme"] +[[package]] +name = "executing" +version = "2.0.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.5" +files = [ + {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, + {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + [[package]] name = "filelock" version = "3.13.1" @@ -1040,6 +1072,43 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "ipython" +version = "8.23.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.23.0-py3-none-any.whl", hash = "sha256:07232af52a5ba146dc3372c7bf52a0f890a23edf38d77caef8d53f9cdc2584c1"}, + {file = "ipython-8.23.0.tar.gz", hash = "sha256:7468edaf4f6de3e1b912e57f66c241e6fd3c7099f2ec2136e239e142e800274d"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5.13.0" +typing-extensions = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "stack-data", "typing-extensions"] +kernel = ["ipykernel"] +matplotlib = ["matplotlib"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pickleshare", "pytest (<8)", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] + [[package]] name = "isort" version = "5.13.2" @@ -1083,6 +1152,25 @@ more-itertools = "*" docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + [[package]] name = "jeepney" version = "0.8.0" @@ -1188,21 +1276,6 @@ sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] yaml = ["PyYAML (>=3.10)"] zookeeper = ["kazoo (>=2.8.0)"] -[[package]] -name = "livereload" -version = "2.6.3" -description = "Python LiveReload is an awesome tool for web developers" -optional = false -python-versions = "*" -files = [ - {file = "livereload-2.6.3-py2.py3-none-any.whl", hash = "sha256:ad4ac6f53b2d62bb6ce1a5e6e96f1f00976a32348afedcb4b6d68df2a1d346e4"}, - {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, -] - -[package.dependencies] -six = "*" -tornado = {version = "*", markers = "python_version > \"2.7\""} - [[package]] name = "markdown" version = "3.6" @@ -1330,6 +1403,20 @@ dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] docs = ["alabaster (==0.7.16)", "autodocsumm (==0.2.12)", "sphinx (==7.2.6)", "sphinx-issues (==4.0.0)", "sphinx-version-warning (==1.1.2)"] tests = ["pytest", "pytz", "simplejson"] +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.5" +files = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] + +[package.dependencies] +traitlets = "*" + [[package]] name = "mccabe" version = "0.7.0" @@ -1545,6 +1632,21 @@ files = [ {file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"}, ] +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + [[package]] name = "pathspec" version = "0.12.1" @@ -1556,6 +1658,20 @@ files = [ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + [[package]] name = "pkginfo" version = "1.10.0" @@ -1639,13 +1755,13 @@ virtualenv = ">=20.10.0" [[package]] name = "prompt-toolkit" -version = "3.0.36" +version = "3.0.43" description = "Library for building powerful interactive command lines in Python" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.7.0" files = [ - {file = "prompt_toolkit-3.0.36-py3-none-any.whl", hash = "sha256:aa64ad242a462c5ff0363a7b9cfe696c20d55d9fc60c11fd8e632d064804d305"}, - {file = "prompt_toolkit-3.0.36.tar.gz", hash = "sha256:3e163f254bef5a03b146397d7c1963bd3e2812f0964bb9a24e6ec761fd28db63"}, + {file = "prompt_toolkit-3.0.43-py3-none-any.whl", hash = "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6"}, + {file = "prompt_toolkit-3.0.43.tar.gz", hash = "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d"}, ] [package.dependencies] @@ -1673,6 +1789,31 @@ files = [ {file = "psycopg2-2.9.9.tar.gz", hash = "sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156"}, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "pycodestyle" version = "2.11.1" @@ -2145,17 +2286,20 @@ toml = ["toml"] [[package]] name = "questionary" -version = "2.0.1" +version = "1.10.0" description = "Python library to build pretty command line user prompts ⭐️" optional = false -python-versions = ">=3.8" +python-versions = ">=3.6,<4.0" files = [ - {file = "questionary-2.0.1-py3-none-any.whl", hash = "sha256:8ab9a01d0b91b68444dff7f6652c1e754105533f083cbe27597c8110ecc230a2"}, - {file = "questionary-2.0.1.tar.gz", hash = "sha256:bcce898bf3dbb446ff62830c86c5c6fb9a22a54146f0f5597d3da43b10d8fc8b"}, + {file = "questionary-1.10.0-py3-none-any.whl", hash = "sha256:fecfcc8cca110fda9d561cb83f1e97ecbb93c613ff857f655818839dac74ce90"}, + {file = "questionary-1.10.0.tar.gz", hash = "sha256:600d3aefecce26d48d97eee936fdb66e4bc27f934c3ab6dd1e292c4f43946d90"}, ] [package.dependencies] -prompt_toolkit = ">=2.0,<=3.0.36" +prompt_toolkit = ">=2.0,<4.0" + +[package.extras] +docs = ["Sphinx (>=3.3,<4.0)", "sphinx-autobuild (>=2020.9.1,<2021.0.0)", "sphinx-autodoc-typehints (>=1.11.1,<2.0.0)", "sphinx-copybutton (>=0.3.1,<0.4.0)", "sphinx-rtd-theme (>=0.5.0,<0.6.0)"] [[package]] name = "readme-renderer" @@ -2665,6 +2809,25 @@ postgresql-psycopg2cffi = ["psycopg2cffi"] pymysql = ["pymysql", "pymysql (<1)"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + [[package]] name = "starkbank-ecdsa" version = "2.2.0" @@ -2686,26 +2849,6 @@ files = [ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, ] -[[package]] -name = "tornado" -version = "6.4" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -optional = false -python-versions = ">= 3.8" -files = [ - {file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"}, - {file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"}, - {file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"}, - {file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"}, - {file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"}, -] - [[package]] name = "tox" version = "4.14.2" @@ -2732,6 +2875,21 @@ virtualenv = ">=20.25" docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.25.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13)", "wheel (>=0.42)"] +[[package]] +name = "traitlets" +version = "5.14.2" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.2-py3-none-any.whl", hash = "sha256:fcdf85684a772ddeba87db2f398ce00b40ff550d1528c03c14dbf6a02003cd80"}, + {file = "traitlets-5.14.2.tar.gz", hash = "sha256:8cdd83c040dab7d1dee822678e5f5d100b514f7b72b01615b26fc5718916fdf9"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.1)", "pytest-mock", "pytest-mypy-testing"] + [[package]] name = "twine" version = "5.0.0" @@ -3006,4 +3164,4 @@ sqlite = ["sqlalchemy"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d493e3c95b5fed33090c69c0714d948576111dfa1be0039b466c510927a8af76" +content-hash = "29f75fb447a491bd9fb7e09d3c88fa338614023bfb7aa9b712b73127a6d82345" diff --git a/pyproject.toml b/pyproject.toml index 8e527c44..8e745761 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ docutils = ">=0.20.1" pre-commit = ">=2.16.0" tox = ">=4.14.1" twine = ">=4.0.2" +ipython = "^8.23.0" [tool.poetry.group.test] optional = true @@ -101,7 +102,6 @@ pytest = ">=7.4.3" optional = true [tool.poetry.group.docs.dependencies] -livereload = ">=2.6.3" sphinx = ">=7.2.6" sphinx-tabs = ">=3.4.4" mkdocs-material = "^9.5.15" diff --git a/src/protean/cli/__init__.py b/src/protean/cli/__init__.py index d3e00791..2bd01abe 100644 --- a/src/protean/cli/__init__.py +++ b/src/protean/cli/__init__.py @@ -16,15 +16,19 @@ """ import subprocess +import sys +import typing from enum import Enum from typing import Optional import typer +from IPython.terminal.embed import InteractiveShellEmbed from rich import print from typing_extensions import Annotated +from protean.cli.docs import app as docs_app from protean.cli.generate import app as generate_app from protean.cli.new import new from protean.exceptions import NoDomainException @@ -36,6 +40,7 @@ app.command()(new) app.add_typer(generate_app, name="generate") +app.add_typer(docs_app, name="docs") class Category(str, Enum): @@ -119,17 +124,6 @@ def test( subprocess.call(commands) -@app.command() -def livereload_docs(): - """Run in shell as `protean livereload-docs`""" - from livereload import Server, shell - - server = Server() - server.watch("docs-sphinx/**/*.rst", shell("make html")) - server.watch("./*.rst", shell("make html")) - server.serve(root="build/html", debug=True) - - @app.command() def server( domain_path: Annotated[str, typer.Argument()] = "", @@ -149,3 +143,40 @@ def server( engine = Engine(domain, test_mode=test_mode) engine.run() + + +@app.command() +def shell(domain_path: Annotated[str, typer.Argument()] = ""): + """Run an interactive Python shell in the context of a given + Protean domain. The domain will populate the default + namespace of this shell according to its configuration. + + This is useful for executing small snippets of code + without having to manually configure the application. + + FIXME: Populate context in a decorator like Flask does: + https://github.com/pallets/flask/blob/b90a4f1f4a370e92054b9cc9db0efcb864f87ebe/src/flask/cli.py#L368 + https://github.com/pallets/flask/blob/b90a4f1f4a370e92054b9cc9db0efcb864f87ebe/src/flask/cli.py#L984 + """ + domain = derive_domain(domain_path) + if not domain: + raise NoDomainException( + "Could not locate a Protean domain. You should provide a domain in" + '"PROTEAN_DOMAIN" environment variable or pass a domain file in options ' + 'and a "domain.py" module was not found in the current directory.' + ) + + with domain.domain_context(): + domain.init() + + ctx: dict[str, typing.Any] = {} + ctx.update(domain.make_shell_context()) + + banner = ( + f"Python {sys.version} on {sys.platform}\n" + f" location: {sys.executable}\n" + f"Domain: {domain.domain_name}\n" + ) + ipshell = InteractiveShellEmbed(banner1=banner, user_ns=ctx) + + ipshell() diff --git a/src/protean/cli/docs.py b/src/protean/cli/docs.py new file mode 100644 index 00000000..bf9690d1 --- /dev/null +++ b/src/protean/cli/docs.py @@ -0,0 +1,26 @@ +import subprocess + +import typer + +app = typer.Typer(no_args_is_help=True) + + +@app.callback() +def callback(): + """ + If we want to create a CLI app with one single command but + still want it to be a command/subcommand, we need to add a callback. + + This can be removed when we have more than one command/subcommand. + + https://typer.tiangolo.com/tutorial/commands/one-or-multiple/#one-command-and-one-callback + """ + + +@app.command() +def preview(): + """Run a live preview server""" + try: + subprocess.call(["mkdocs", "serve"]) + except KeyboardInterrupt: + pass diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 215375e6..5061ed0a 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -791,3 +791,22 @@ def get_email_provider(self, provider_name): def send_email(self, email): return self.email_providers.send_email(email) + + def make_shell_context(self): + """Return a dictionary of context variables for a shell session.""" + values = {"domain": self} + + # For each domain element type in Domain Objects, + # Cycle through all values in self.registry._elements[element_type] + # and add each class to the shell context by the key + for element_type in DomainObjects: + values.update( + { + v.name: v.cls + for _, v in self._domain_registry._elements[ + element_type.value + ].items() + } + ) + + return values diff --git a/src/protean/utils/domain.py b/src/protean/utils/domain.py index d54b0c9b..6955f2fd 100644 --- a/src/protean/utils/domain.py +++ b/src/protean/utils/domain.py @@ -1,4 +1,5 @@ import ast +import logging import os import re import sys @@ -9,6 +10,8 @@ from protean import Domain from protean.exceptions import NoDomainException +logger = logging.getLogger(__name__) + def find_domain_in_module(module: ModuleType) -> Domain: """Given a module instance, find an instance of Protean `Domain` class. @@ -152,16 +155,16 @@ def locate_domain(module_name, domain_name, raise_if_not_found=True): try: __import__(module_name) - except ImportError: + except ImportError as exc: # Reraise the ImportError if it occurred within the imported module. # Determine this by checking whether the trace has a depth > 1. if sys.exc_info()[2].tb_next: raise NoDomainException( f"While importing {module_name!r}, an ImportError was" f" raised:\n\n{traceback.format_exc()}" - ) + ) from exc elif raise_if_not_found: - raise NoDomainException(f"Could not import {module_name!r}.") + raise NoDomainException(f"Could not import {module_name!r}.") from exc else: return @@ -189,7 +192,7 @@ def derive_domain(domain_path): domain_import_path = os.environ.get("PROTEAN_DOMAIN") or domain_path if domain_import_path: - print(f"Deriving domain from {domain_import_path}...") + logger.debug("Deriving domain from %s...", domain_import_path) path, name = (re.split(r":(?![\\/])", domain_import_path, 1) + [None])[:2] import_name = prepare_import(path) domain = locate_domain(import_name, name) diff --git a/tests/cli/test_docs.py b/tests/cli/test_docs.py new file mode 100644 index 00000000..43e83357 --- /dev/null +++ b/tests/cli/test_docs.py @@ -0,0 +1,10 @@ +from typer.testing import CliRunner + +from protean.cli.docs import app + + +def test_main(): + runner = CliRunner() + result = runner.invoke(app) + + assert result.exit_code == 0 diff --git a/tests/cli/test_domain_loading.py b/tests/cli/test_domain_loading.py index a866003b..e2234e56 100644 --- a/tests/cli/test_domain_loading.py +++ b/tests/cli/test_domain_loading.py @@ -1,3 +1,5 @@ +"""Test cases for domain loading from various sources""" + import os import sys @@ -5,16 +7,10 @@ import pytest -from typer.testing import CliRunner - from protean import Domain from protean.cli import NoDomainException from protean.utils.domain import derive_domain, find_domain_in_module - - -@pytest.fixture -def runner(): - return CliRunner() +from tests.shared import change_working_directory_to def test_find_domain_in_module(): @@ -62,44 +58,36 @@ def reset_path(self, request): sys.path[:] = original_path os.chdir(cwd) - def change_working_directory_to(self, path): - test_path = ( - Path(__file__) / ".." / ".." / "support" / "test_domains" / path - ).resolve() - - os.chdir(test_path) - sys.path.insert(0, test_path) - def test_loading_domain_named_as_domain(self): - self.change_working_directory_to("test1") + change_working_directory_to("test1") domain = derive_domain("basic") assert domain is not None assert domain.domain_name == "BASIC" def test_loading_domain_under_directory(self): - self.change_working_directory_to("test2") + change_working_directory_to("test2") domain = derive_domain("src/folder") assert domain is not None assert domain.domain_name == "FOLDER" def test_loading_domain_from_module(self): - self.change_working_directory_to("test3") + change_working_directory_to("test3") domain = derive_domain("nested.web") assert domain is not None assert domain.domain_name == "WEB" def test_loading_domain_from_instance(self): - self.change_working_directory_to("test4") + change_working_directory_to("test4") domain = derive_domain("instance:dom2") assert domain is not None assert domain.domain_name == "INSTANCE" def test_loading_domain_from_invalid_module(self): - self.change_working_directory_to("test5") + change_working_directory_to("test5") with pytest.raises(NoDomainException): derive_domain("dummy") diff --git a/tests/cli/test_generate_docker_compose.py b/tests/cli/test_generate_docker_compose.py index 654176b1..fa946e26 100644 --- a/tests/cli/test_generate_docker_compose.py +++ b/tests/cli/test_generate_docker_compose.py @@ -9,27 +9,14 @@ from protean.cli import derive_domain from protean.cli.generate import app, docker_compose +from tests.shared import change_working_directory_to runner = CliRunner() -def change_working_directory_to(path): - """Change working directory to a specific test directory - and add it to the Python path so that the test can import. - - The test directory is expected to be in `support/test_domains`. - """ - test_path = ( - Path(__file__) / ".." / ".." / "support" / "test_domains" / path - ).resolve() - - os.chdir(test_path) - sys.path.insert(0, test_path) - - class TestGenerateDockerCompose: @pytest.fixture(autouse=True) - def reset_path(self, request): + def reset_path(self): """Reset sys.path after every test run""" original_path = sys.path[:] cwd = Path.cwd() diff --git a/tests/cli/test_shell.py b/tests/cli/test_shell.py new file mode 100644 index 00000000..98995b11 --- /dev/null +++ b/tests/cli/test_shell.py @@ -0,0 +1,48 @@ +import os +import sys + +from pathlib import Path + +import pytest + +from typer.testing import CliRunner + +from protean.cli import app +from protean.exceptions import NoDomainException +from tests.shared import change_working_directory_to + +runner = CliRunner() + + +class TestShellCommand: + @pytest.fixture(autouse=True) + def reset_path(self): + """Reset sys.path after every test run""" + original_path = sys.path[:] + cwd = Path.cwd() + + yield + + sys.path[:] = original_path + os.chdir(cwd) + + def test_shell_command_success(self): + change_working_directory_to("test7") + + args = ["shell", "publishing.py"] + + # Run the shell command + result = runner.invoke(app, args) + + # Assertions + print(result.output) + assert result.exit_code == 0 + + def test_shell_command_raises_no_domain_exception_when_no_domain_is_found(self): + change_working_directory_to("test7") + + args = ["shell", "foobar"] + + # Run the shell command and expect it to raise an exception + with pytest.raises(NoDomainException): + runner.invoke(app, args, catch_exceptions=False) diff --git a/tests/domain/test_domain_shell_context.py b/tests/domain/test_domain_shell_context.py new file mode 100644 index 00000000..23290c4b --- /dev/null +++ b/tests/domain/test_domain_shell_context.py @@ -0,0 +1,48 @@ +import os +import sys + +from pathlib import Path + +import pytest + +from protean.utils.domain import derive_domain +from tests.shared import change_working_directory_to + + +class TestDomainShellContext: + @pytest.fixture(autouse=True) + def reset_path(self): + """Reset sys.path after every test run""" + original_path = sys.path[:] + cwd = Path.cwd() + + yield + + sys.path[:] = original_path + os.chdir(cwd) + + def test_return_type(self): + change_working_directory_to("test9") + + domain = derive_domain("publishing:domain") + + assert domain is not None + domain.init() + + context = domain.make_shell_context() + assert isinstance(context, dict), "The method should return a dictionary" + + assert "domain" in context, "The domain itself should be in the context" + assert ( + context["domain"] is domain + ), "The domain in context should be the domain object" + + # Test for elements in the context + assert ( + "Post" in context + and context["Post"] is domain.registry.aggregates["publishing.Post"].cls + ), "`Post` class should be in the context" + assert ( + "Comment" in context + and context["Comment"] is domain.registry.entities["publishing.Comment"].cls + ), "`Comment` class should be in the context" diff --git a/tests/shared.py b/tests/shared.py index 426297bf..c57b8b83 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,13 +1,18 @@ +"""Shared utilities for tests""" + import os +import sys +from pathlib import Path from uuid import UUID import pytest +from protean.domain import Domain -def initialize_domain(file_path): - from protean.domain import Domain +def initialize_domain(file_path): + """Initialize a Protean Domain with configuration from a file""" domain = Domain(__file__, "Tests") # Construct relative path to config file @@ -24,7 +29,20 @@ def initialize_domain(file_path): def assert_str_is_uuid(value: str) -> None: + """Assert that a string is a valid UUID""" try: UUID(value) except ValueError: pytest.fail("Invalid UUID") + + +def change_working_directory_to(path): + """Change working directory to a specific test directory + and add it to the Python path so that the test can import. + + The test directory is expected to be in `support/test_domains`. + """ + test_path = (Path(__file__) / ".." / "support" / "test_domains" / path).resolve() + + os.chdir(test_path) + sys.path.insert(0, test_path) diff --git a/tests/support/test_domains/test9/publishing.py b/tests/support/test_domains/test9/publishing.py new file mode 100644 index 00000000..6f3aa8c3 --- /dev/null +++ b/tests/support/test_domains/test9/publishing.py @@ -0,0 +1,22 @@ +"""A simple domain module with domain elements in the same file""" + +from datetime import datetime + +from protean.domain import Domain +from protean.fields import DateTime, HasMany, Reference, String + +domain = Domain(__file__, "TEST9") + + +@domain.aggregate +class Post: + title = String(max_length=50) + created_on = DateTime(default=datetime.now) + + comments = HasMany("Comment") + + +@domain.entity(aggregate_cls=Post) +class Comment: + content = String(max_length=500) + post = Reference(Post)