diff --git a/README.md b/README.md index 5b3e4ef..0c028f7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ basic_auth = BasicAuth() @input(BookCreateSchema, location="form") @output(BookSchema, status_code=201) @expect({401: "Unauthorized", 400: "Invalid file format"}) -@db.transaction async def create_book(req, resp, *, data): """ Create a new Book @@ -35,10 +34,11 @@ async def create_book(req, resp, *, data): """ image = data.pop("image") - await image.asave(f"uploads/{image.filename}") # image is already validated for extension and size. - + await image.asave(f"uploads/{image.filename}") # image is already validated for extension, size and filename. + session = await req.db book = await Book.create(**data, cover=image.filename) + await session.commit() resp.obj = book diff --git a/docs/tour.rst b/docs/tour.rst index 60d45ac..a252060 100644 --- a/docs/tour.rst +++ b/docs/tour.rst @@ -1189,8 +1189,10 @@ Create a record: @app.route("/users", methods=["POST"]) async def create_user(req, resp): - await req.db + session = await req.db user = await User.create(name="Dyne User") + await session.commit() + resp.media = {"id": user.id} Update a record: @@ -1782,7 +1784,6 @@ Use this form when you want **full control** over both the response schema and i pass - Webhooks -------- @@ -2419,7 +2420,7 @@ To provide a title and description for your API, assign a docstring or a configu description = """ User Management API - ------------------- + This API allows for comprehensive management of users and books. **Base URL:** `https://api.example.com/v1` @@ -2487,7 +2488,7 @@ This example demonstrates how the Marshmallow strategy captures a complex schema description = """ User Management API - ------------------- + This API allows for comprehensive management of users and books. **Base URL:** `https://api.example.com/v1` @@ -2748,6 +2749,293 @@ It is important to distinguish between ``req.app.state`` and ``req.state``. you are unsure if a value exists. + +Dyne CLI +-------- + +The Dyne CLI provides a simple interface for running Dyne applications +using Uvicorn as the ASGI server. + +It supports application discovery, debug mode, automatic reload, +and environment-based configuration. + +Installation +^^^^^^^^^^^^ + +The CLI is installed automatically when installing Dyne: + +.. code-block:: bash + + pip install dyne + +Usage +^^^^^ + +Basic usage: + +.. code-block:: bash + + dyne run + +Specify an application explicitly: + +.. code-block:: bash + + dyne --app myapp:app run + +If ``:app`` is omitted, Dyne automatically appends it: + +.. code-block:: bash + + dyne --app myapp run + +This resolves internally to: + +.. code-block:: text + + myapp:app + +Command Structure +^^^^^^^^^^^^^^^^^ + +The CLI is built using Click and supports global options followed by commands: + +.. code-block:: text + + dyne [OPTIONS] COMMAND + +Available Commands +^^^^^^^^^^^^^^^^^^ + +run +""" + +Runs the Dyne application using Uvicorn. + +.. code-block:: bash + + dyne run + +Global Options +^^^^^^^^^^^^^^ + +--app, -a +""""""""" + +Specify the Dyne application import path. + +- Format: ``module:variable`` +- Example: ``myproject.main:app`` +- Defaults to the ``DYNE_APP`` environment variable. +- If not provided, defaults to ``app:app``. + +Example: + +.. code-block:: bash + + dyne --app myproject.main:app run + +--debug +""""""" + +Enable debug mode. + +When enabled: + +- Log level is set to ``debug`` +- Auto-reload is enabled (unless explicitly overridden) +- ``DEBUG=true`` is added to the environment + +Example: + +.. code-block:: bash + + dyne --debug run + +--host +"""""" + +Interface to bind the server to. + +Default: + +.. code-block:: text + + 127.0.0.1 + +Example: + +.. code-block:: bash + + dyne --host 0.0.0.0 run + +--port +"""""" + +Port to bind the server to. + +Default: + +.. code-block:: text + + 8000 + +Example: + +.. code-block:: bash + + dyne --port 9000 run + +--reload / --no-reload +"""""""""""""""""""""" + +Force enable or disable auto-reload. + +If not explicitly set: + +- Reload defaults to the value of ``--debug``. + +Examples: + +.. code-block:: bash + + dyne --reload run + + dyne --no-reload run + +--version +""""""""" + +Display the installed Dyne version. + +.. code-block:: bash + + dyne --version + +Environment Variables +^^^^^^^^^^^^^^^^^^^^^ + +DYNE_APP +"""""""" + +Defines the default application import path. + +Example: + +.. code-block:: bash + + export DYNE_APP=myproject.main:app + dyne run + +DEBUG +""""" + +Automatically set to ``true`` when ``--debug`` is enabled. + +Implementation Notes +^^^^^^^^^^^^^^^^^^^^ + +- The current working directory is automatically added to ``sys.path`` + to ensure local imports resolve correctly. +- Uvicorn is used as the ASGI server. +- Log level automatically switches between ``info`` and ``debug``. + +Examples +^^^^^^^^ + +Run default app: + +.. code-block:: bash + + dyne run + +Run with debug and reload: + +.. code-block:: bash + + dyne --debug run + +Run custom app on all interfaces: + +.. code-block:: bash + + dyne --app main:app --host 0.0.0.0 --port 8080 run + + +Extending the Dyne CLI +---------------------- + +Dyne's CLI is fully extensible. Third-party packages, plugins, or +internal tools can register new commands by importing the ``cli`` +group and attaching commands to it. + +This allows developers to "hook" into Dyne’s CLI without modifying +Dyne’s core source code. + +Basic Example +^^^^^^^^^^^^^ + +A plugin can register a new command like this: + +.. code-block:: python + + from dyne.cli import cli + + @cli.command() + def custom_task(): + """A task added by a plugin.""" + print("Doing something cool!") + +Once this module is imported, the new command becomes available: + +.. code-block:: bash + + dyne custom-task + + +Plugin Design Pattern +^^^^^^^^^^^^^^^^^^^^^ + +A common structure for CLI plugins: + +.. code-block:: text + + myplugin/ + __init__.py + cli.py + +Inside ``cli.py``: + +.. code-block:: python + + from dyne.cli import cli + + @cli.command() + def seed_data(): + """Seed the database.""" + ... + +Then ensure the plugin module is imported during application startup. + +Automatic CLI Plugin Loading +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For larger ecosystems, plugins can be automatically discovered +using Python entry points. This enables zero-configuration CLI +extensions. + +Example ``pyproject.toml`` entry point: + +.. code-block:: toml + + [project.entry-points."dyne.cli"] + myplugin = "myplugin.cli" + +Dyne can then iterate through registered entry points and import them +at startup. + + + Using Requests Test Client -------------------------- diff --git a/pyproject.toml b/pyproject.toml index 6422650..ada5a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [{ name = "Tabot Kevin", email = "tabot.kevin@gmail.com" }] dependencies = [ # Core ASGI / server - "starlette>=0.37.2", + "starlette>=0.37.2,<=0.52.1", "uvicorn[standard]>=0.29.0", # Async file handling "aiofiles>=23.2.1", @@ -26,8 +26,8 @@ dependencies = [ "whitenoise>=6.6.0", "itsdangerous>=2.1.2", "chardet>=5.2.0", - "docopt>=0.6.2", "httpx>=0.28.1", + "click>=8.3.1", ] classifiers = [ @@ -106,6 +106,8 @@ allow-direct-references = true [tool.hatch.build.targets.wheel] packages = ["src/dyne"] +[project.scripts] +dyne = "dyne.cli:cli" # Rye configuration [tool.rye] diff --git a/requirements-dev.lock b/requirements-dev.lock index dc7fe8c..489f418 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -36,12 +36,11 @@ chardet==5.2.0 # via dyne charset-normalizer==3.4.4 # via requests -click==8.1.7 +click==8.3.1 + # via dyne # via uvicorn coverage==7.4.4 # via pytest-cov -docopt==0.6.2 - # via dyne exceptiongroup==1.3.1 # via anyio # via pytest diff --git a/requirements.lock b/requirements.lock index 1c35315..137c781 100644 --- a/requirements.lock +++ b/requirements.lock @@ -34,10 +34,9 @@ chardet==5.2.0 # via dyne charset-normalizer==3.4.4 # via requests -click==8.1.7 - # via uvicorn -docopt==0.6.2 +click==8.3.1 # via dyne + # via uvicorn exceptiongroup==1.3.1 # via anyio graphene==3.4.3 diff --git a/src/dyne/app.py b/src/dyne/app.py index 0f63229..dfb5e0c 100644 --- a/src/dyne/app.py +++ b/src/dyne/app.py @@ -79,7 +79,7 @@ def __init__( self.hsts_enabled = enable_hsts self.cors = cors self.cors_params = cors_params - self.debug = debug + self.debug = self.config.get("DEBUG", default=debug) if not allowed_hosts: allowed_hosts = ["*"] @@ -370,7 +370,7 @@ def serve(self, *, address=None, port=None, **options): if address is None: address = "127.0.0.1" if port is None: - port = 5042 + port = 8000 def spawn(): uvicorn.run(self, host=address, port=port, **options) diff --git a/src/dyne/cli.py b/src/dyne/cli.py index fefd5ca..9724786 100644 --- a/src/dyne/cli.py +++ b/src/dyne/cli.py @@ -1,44 +1,84 @@ -"""Dyne. +import os +import sys -Usage: - dyne - dyne run [--build] [--debug] - dyne build - dyne --version +import click +import uvicorn -Options: - -h --help Show this screen. - -v --version Show version. +__version__ = "2.0.4" -""" -import os +def parse_app_import(app_path: str | None) -> str: + if not app_path: + return "app:app" + if ":" not in app_path: + return f"{app_path}:app" + return app_path -import docopt -__version__ = "2.0.4" +def resolve_reload(debug: bool, reload_flag: bool | None) -> bool: + if reload_flag is not None: + return reload_flag + return debug + + +@click.group() +@click.version_option(version=__version__) +@click.option( + "--app", + "-a", + envvar="DYNE_APP", + help="The Dyne app to run, e.g. 'myapp:app'. Defaults to DYNE_APP env var.", +) +@click.option("--debug", is_flag=True, help="Enable debug mode.") +@click.option("--host", default="127.0.0.1", help="The interface to bind to.") +@click.option("--port", default=8000, type=int, help="The port to bind to.") +@click.option("--reload/--no-reload", default=None, help="Enable/disable reload.") +@click.pass_context +def cli(ctx, app, debug, host, port, reload): + # Ensure current directory is in path for imports + project_root = os.getcwd() + if project_root not in sys.path: + sys.path.insert(0, project_root) + ctx.ensure_object(dict) -def cli(): - args = docopt.docopt( - __doc__, argv=None, help=True, version=__version__, options_first=False - ) + ctx.obj["app"] = parse_app_import(app) + ctx.obj["host"] = host + ctx.obj["port"] = port + ctx.obj["debug"] = debug + ctx.obj["reload"] = resolve_reload(debug, reload) - module = args[""] - build = args["build"] or args["--build"] - run = args["run"] + if debug: + os.environ["DEBUG"] = "true" - if build: - os.system("npm run build") - if run: - split_module = module.split(":") +@cli.command() +@click.pass_context +def run(ctx): + config = ctx.obj + app = config["app"] + host = config["host"] + port = config["port"] + debug = config["debug"] + reload = config["reload"] + log_level = "debug" if debug else "info" - if len(split_module) > 1: - module = split_module[0] - prop = split_module[1] - else: - prop = "api" + click.echo("") + click.secho(f"🚀 Launching Dyne {__version__}", fg="cyan", bold=True) + click.echo(f"App: {app}") + click.echo(f"URL: http://{host}:{port}") + click.echo(f"Debug: {debug}") + click.echo(f"Reload: {reload}") + click.echo("") - app = __import__(module) - getattr(app, prop).run() + try: + uvicorn.run( + app, + host=host, + port=port, + reload=reload, + log_level=log_level, + ) + except Exception as exc: + click.secho(f"Error: {exc}", fg="red") + sys.exit(1) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f9f2a5c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,36 @@ +from unittest.mock import patch + +from click.testing import CliRunner + +from dyne.cli import cli + + +def test_cli_version(): + runner = CliRunner() + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert "2.0.4" in result.output + + +def test_cli_help(): + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Enable debug mode." in result.output + + +def test_run_command_params(): + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert "--host" in result.output + assert "--port" in result.output + + +@patch("uvicorn.run") +def test_run_calls_uvicorn(mock_run): + runner = CliRunner() + runner.invoke(cli, ["--app", "myapp:app", "--port", "9000", "run"]) + + args, kwargs = mock_run.call_args + assert args[0] == "myapp:app" + assert kwargs["port"] == 9000