diff --git a/.gitignore b/.gitignore index 2a721a287..78881ce13 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ AGENT.md.bak GEMINI.md GEMINI.md.bak # END Ruler Generated Files + +# Air templates tests +mysite \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 92d2baff8..2a02b5872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ keywords = [ ] dependencies = [ - "fastapi>=0.119.1", + "fastapi[standard]>=0.119.1", "itsdangerous>=2.2.0", "Jinja2>=3.1.6", "minify-html>=0.18.1", @@ -97,18 +97,25 @@ dependencies = [ "frozendict>=2.4.7", "pygments>=2.19.2", "selectolax>=0.4.6", + "typer>=0.20.0", + "pydantic-settings>=2.12.0", + "cookiecutter>=2.6.0", + "jinja2-time>=0.2.0", ] # endregion project +[project.scripts] +air = "air.cli:app" + # region ----> dependencies <---- # Runtime extras you want users to install from PyPI -[project.optional-dependencies] +# [project.optional-dependencies] # Make `air[standard]` install FastAPI’s own “standard” extras, # so *users* can do: uv pip install "air[standard]". -standard = [ - # Re-install FastAPI with its “standard” extra. - "fastapi[standard]>=0.119.1", -] +# standard = [ +# # Re-install FastAPI with its “standard” extra. + +# ] # Groups are for contributors; install with: uv sync --group NAME # Developer-only deps under [dependency-groups], diff --git a/src/air/cli.py b/src/air/cli.py new file mode 100644 index 000000000..ff78b2298 --- /dev/null +++ b/src/air/cli.py @@ -0,0 +1,121 @@ +import logging +from datetime import datetime +from importlib.metadata import version +from pathlib import Path +from typing import Annotated + +import typer +from cookiecutter.main import cookiecutter +from fastapi_cli.logging import setup_logging +from rich import print + +logger = logging.getLogger(__name__) + +try: + import uvicorn +except ImportError: # pragma: no cover + uvicorn = None # type: ignore[assignment] + +templates_path = Path(__file__).parent / "templates" + +PYTHON_TO_POSTGRES = { + "int": "INTEGER", + "float": "DOUBLE PRECISION", + "bool": "BOOLEAN", + "str": "VARCHAR(255)", + "bytes": "BYTEA", + "date": "DATE", + "time": "TIME", + "datetime": "TIMESTAMP", + "uuid": "UUID", + "dict": "JSONB", + "list": "JSONB", + "tuple": "JSONB", + "object": "TEXT", + "text": "TEXT", +} + +app = typer.Typer(rich_markup_mode="rich", context_settings={"help_option_names": ["-h", "--help"]}) + + +def version_callback(value: int) -> None: + if value: + print(f"Air version: [green]{version('air')}[/green]") + raise typer.Exit + + +def timestamp_ymd_seconds() -> str: + now = datetime.now() + seconds_of_day = now.hour * 3600 + now.minute * 60 + now.second + return f"{now:%Y%m%d}{seconds_of_day}" + + +@app.callback() +def callback( + version: Annotated[ + bool | None, + typer.Option("--version", help="Show the version and exit.", callback=version_callback), + ] = None, + verbose: int = typer.Option(0, help="Enable verbose output"), +) -> None: + """ + Air CLI - The [bold]air[/bold] command line app. ☁️ + + Manage your [bold]Air[/bold] projects. + + """ + + log_level = logging.DEBUG if verbose else logging.INFO + + setup_logging(level=log_level) + + +@app.command() +def init( + path: Annotated[ + Path, + typer.Argument(help="Path for where the new project will be built."), + ], + template: Annotated[str | None, typer.Option(help="The Cookiecutter-style path to a project template.")] = None, + defaults: Annotated[bool, typer.Option(help="Assume defaults for the project")] = False +) -> None: + """Start new Air project + + Args: + path: Location on the server where the project will be + + Raises: + Abort: If the route name doesn't exist or if the provided parameters + don't match the route's path parameters. + """ + if path.exists(): + print(f"[bold red]ERROR: '{path}' already exists![/bold red]") + print(f"[red]Air will not overwrite '{path}'.[/red]") + raise typer.Abort + + if template is None: + project_template_path = templates_path / "init" + cookiecutter(str(project_template_path), extra_context={"name": path.name}, no_input=defaults) + else: + cookiecutter(template) + print(f"Created project {path.name} at '{path.expanduser()}/'") + + +@app.command() +def resource( + action: Annotated[ + str, + typer.Argument(help="Resource command, coming soon!"), + ], + template: Annotated[str | None, typer.Option(help="The Cookiecutter-style path to a project template.")] = None, +) -> None: + """Build or update resource + + Args: + action: Subcommand for resource controls + + Raises: + Abort: For any errors. + """ + print('Not yet implemented') + raise typer.Abort \ No newline at end of file diff --git a/src/air/templates/__init__.py b/src/air/templates/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/__init__.py b/src/air/templates/init/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/cookiecutter.json b/src/air/templates/init/cookiecutter.json new file mode 100644 index 000000000..d41f32d17 --- /dev/null +++ b/src/air/templates/init/cookiecutter.json @@ -0,0 +1,6 @@ +{ + "name": "mysite", + "orm": [ + "PostgreSQL: asyncpg+dbmate" + ] +} \ No newline at end of file diff --git a/src/air/templates/init/{{cookiecutter.name}}/README.md b/src/air/templates/init/{{cookiecutter.name}}/README.md new file mode 100644 index 000000000..554e7ae05 --- /dev/null +++ b/src/air/templates/init/{{cookiecutter.name}}/README.md @@ -0,0 +1,13 @@ +# {{cookiecutter.name}} + +An Air-powered project. + +## Setup + +{% if cookiecutter.orm == "PostgreSQL: asyncpg+dbmate" %} +This project uses the [asyncpg](https://pypi.org/project/asyncpg/) library to connect with PostgreSQL. Queries to the database will be written as PostgreSQL-flavored SQL. As `asyncpg` isn't ideal for database schema changes, this project uses [dbmate](https://github.com/amacneil/dbmate) to manage database migrations. + +TODO add getting the basic environment ready + +Instructions for installing dbmate: https://github.com/amacneil/dbmate?tab=readme-ov-file#installation +{% endif %} \ No newline at end of file diff --git a/src/air/templates/init/{{cookiecutter.name}}/__init__.py b/src/air/templates/init/{{cookiecutter.name}}/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/{{cookiecutter.name}}/app/__init__.py b/src/air/templates/init/{{cookiecutter.name}}/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/{{cookiecutter.name}}/app/routes/__init__.py b/src/air/templates/init/{{cookiecutter.name}}/app/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/{{cookiecutter.name}}/config.py b/src/air/templates/init/{{cookiecutter.name}}/config.py new file mode 100644 index 000000000..ae084dffc --- /dev/null +++ b/src/air/templates/init/{{cookiecutter.name}}/config.py @@ -0,0 +1,11 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Environment variable specification""" + + DEBUG: bool = True + DATABASE_URL: str = "" + + +settings = Settings() diff --git a/src/air/templates/init/{{cookiecutter.name}}/db/__init__.py b/src/air/templates/init/{{cookiecutter.name}}/db/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/{{cookiecutter.name}}/db/migrations/__init__.py b/src/air/templates/init/{{cookiecutter.name}}/db/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/{{cookiecutter.name}}/db/schema/__init__.py b/src/air/templates/init/{{cookiecutter.name}}/db/schema/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/{{cookiecutter.name}}/main.py b/src/air/templates/init/{{cookiecutter.name}}/main.py new file mode 100644 index 000000000..012329ba7 --- /dev/null +++ b/src/air/templates/init/{{cookiecutter.name}}/main.py @@ -0,0 +1,11 @@ +import air + +app = air.Air() + + +@app.page +def index() -> air.BaseTag: + return air.layouts.mvpcss( + air.Title("{{cookiecutter.name}} home page"), + air.H1("{{cookiecutter.name}} home page"), + ) diff --git a/src/air/templates/init/{{cookiecutter.name}}/public/__init__.py b/src/air/templates/init/{{cookiecutter.name}}/public/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/{{cookiecutter.name}}/scripts/__init__.py b/src/air/templates/init/{{cookiecutter.name}}/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/air/templates/init/{{cookiecutter.name}}/tests/__init__.py b/src/air/templates/init/{{cookiecutter.name}}/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 000000000..6396faeea --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,21 @@ +from pathlib import Path +from shutil import rmtree +from typer.testing import CliRunner + +from air.cli import app + + +runner = CliRunner() + + +def _mysite_cleanup(): + if Path('mysite').exists(): + rmtree(Path('mysite')) + + +def test_init_success(): + _mysite_cleanup() + result = runner.invoke(app, ["init", "mysite", "--defaults"]) + assert result.exit_code == 0 + assert result.output == "Created project mysite at 'mysite/'\n" + _mysite_cleanup() \ No newline at end of file diff --git a/uv.lock b/uv.lock index e0c3b061e..88a97cbc0 100644 --- a/uv.lock +++ b/uv.lock @@ -7,22 +7,21 @@ name = "air" version = "0.42.0" source = { editable = "." } dependencies = [ - { name = "fastapi" }, + { name = "cookiecutter" }, + { name = "fastapi", extra = ["standard"] }, { name = "frozendict" }, { name = "itsdangerous" }, { name = "jinja2" }, + { name = "jinja2-time" }, { name = "lxml" }, { name = "minify-html" }, { name = "nh3" }, + { name = "pydantic-settings" }, { name = "pygments" }, { name = "python-multipart" }, { name = "rich" }, { name = "selectolax" }, -] - -[package.optional-dependencies] -standard = [ - { name = "fastapi", extra = ["standard"] }, + { name = "typer" }, ] [package.dev-dependencies] @@ -103,20 +102,22 @@ test = [ [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.119.1" }, - { name = "fastapi", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.119.1" }, + { name = "cookiecutter", specifier = ">=2.6.0" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.119.1" }, { name = "frozendict", specifier = ">=2.4.7" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "jinja2-time", specifier = ">=0.2.0" }, { name = "lxml", specifier = ">=6.0.1" }, { name = "minify-html", specifier = ">=0.18.1" }, { name = "nh3", specifier = ">=0.3.2" }, + { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pygments", specifier = ">=2.19.2" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "rich", specifier = ">=14.2.0" }, { name = "selectolax", specifier = ">=0.4.6" }, + { name = "typer", specifier = ">=0.20.0" }, ] -provides-extras = ["standard"] [package.metadata.requires-dev] dev = [ @@ -233,6 +234,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, ] +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + [[package]] name = "asttokens" version = "3.0.1" @@ -287,6 +301,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "binaryornot" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/fe/7ebfec74d49f97fc55cd38240c7a7d08134002b1e14be8c3897c0dd5e49b/binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061", size = 371054, upload-time = "2017-08-03T15:55:25.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/7e/f7b6f453e6481d1e233540262ccbfcf89adcd43606f44a028d7f5fae5eb2/binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4", size = 9006, upload-time = "2017-08-03T15:55:31.23Z" }, +] + [[package]] name = "bleach" version = "6.3.0" @@ -475,6 +501,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] +[[package]] +name = "cookiecutter" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "binaryornot" }, + { name = "click" }, + { name = "jinja2" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/17/9f2cd228eb949a91915acd38d3eecdc9d8893dde353b603f0db7e9f6be55/cookiecutter-2.6.0.tar.gz", hash = "sha256:db21f8169ea4f4fdc2408d48ca44859349de2647fbe494a9d6c3edfc0542c21c", size = 158767, upload-time = "2024-02-21T18:02:41.949Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d9/0137658a353168ffa9d0fc14b812d3834772040858ddd1cb6eeaf09f7a44/cookiecutter-2.6.0-py3-none-any.whl", hash = "sha256:a54a8e37995e4ed963b3e82831072d1ad4b005af736bb17b99c2cbd9d41b6e2d", size = 39177, upload-time = "2024-02-21T18:02:39.569Z" }, +] + [[package]] name = "coverage" version = "7.12.0" @@ -974,6 +1019,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jinja2-time" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/7c/ee2f2014a2a0616ad3328e58e7dac879251babdb4cb796d770b5d32c469f/jinja2-time-0.2.0.tar.gz", hash = "sha256:d14eaa4d315e7688daa4969f616f226614350c48730bfa1692d2caebd8c90d40", size = 5701, upload-time = "2016-06-08T23:36:52.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/a1/d44fa38306ffa34a7e1af09632b158e13ec89670ce491f8a15af3ebcb4e4/jinja2_time-0.2.0-py2.py3-none-any.whl", hash = "sha256:d3eab6605e3ec8b7a0863df09cc1d23714908fa61aa6986a845c20ba488b4efa", size = 6360, upload-time = "2016-06-08T23:36:48.197Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -1853,6 +1911,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2045,6 +2117,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2437,6 +2521,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + [[package]] name = "tinycss2" version = "1.4.0" @@ -2621,6 +2714,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/0a/e324e17a0407dfe2459ecd8c467b0b3953ec5c553bd552949fdc238bec91/typos-1.40.0-py3-none-win_amd64.whl", hash = "sha256:69c47f0b899bc62d87d6fc431824348782e76dca1867115976915a197b0a1fd2", size = 3254935, upload-time = "2025-11-26T20:54:52.458Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "urllib3" version = "2.5.0"