├── .github ├── dependabot.yml └── workflows │ ├── deploy-docs.yml │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── docs ├── commands.md ├── generators │ ├── component.md │ ├── index.md │ └── project.md └── index.md ├── fastapix ├── __init__.py ├── __main__.py ├── commands │ ├── __init__.py │ ├── environment.py │ ├── middlewares.py │ └── routes.py ├── context.py ├── inference │ ├── __init__.py │ ├── analyzers.py │ └── main.py ├── loader.py ├── main.py └── py.typed ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── commands ├── __init__.py ├── test_env.py ├── test_middlewares.py └── test_routes.py ├── test_analyzers.py └── test_version.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs via GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | if: github.event.repository.fork == false 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v4 15 | with: 16 | python-version: "3.10" 17 | - run: pip install mkdocs-material 18 | - run: mkdocs gh-deploy --force 19 | 20 | env: 21 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | tests: 11 | name: "Python ${{ matrix.python-version }}" 12 | runs-on: ubuntu-latest 13 | 14 | timeout-minutes: 30 15 | strategy: 16 | matrix: 17 | python-version: ["3.7", "3.8", "3.9", "3.10"] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Run pre-commit 26 | if: ${{ matrix.python-version == '3.10' }} 27 | uses: pre-commit/action@v3.0.0 28 | 29 | - uses: actions/cache@v3 30 | with: 31 | path: ~/.cache/pip 32 | key: pip-${{ matrix.python-version }} 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip setuptools wheel 37 | python -m pip install -r requirements.txt 38 | 39 | 40 | - name: Run tests 41 | run: python -m coverage run --branch --parallel-mode -m pytest 42 | 43 | - name: Upload coverage data 44 | uses: actions/upload-artifact@v3 45 | with: 46 | name: coverage-data 47 | path: '.coverage.*' 48 | 49 | coverage: 50 | name: Coverage 51 | runs-on: ubuntu-20.04 52 | needs: tests 53 | steps: 54 | - uses: actions/checkout@v3 55 | 56 | - uses: actions/setup-python@v3 57 | with: 58 | python-version: '3.10' 59 | 60 | - name: Install dependencies 61 | run: python -m pip install --upgrade coverage[toml] 62 | 63 | - name: Download data 64 | uses: actions/download-artifact@v3 65 | with: 66 | name: coverage-data 67 | 68 | - name: Combine coverage and fail if it's <100% 69 | run: | 70 | python -m coverage combine 71 | python -m coverage html --skip-covered --skip-empty 72 | python -m coverage report --fail-under=100 73 | 74 | - name: Upload HTML report 75 | if: ${{ failure() }} 76 | uses: actions/upload-artifact@v3 77 | with: 78 | name: html-report 79 | path: htmlcov 80 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Version to be released." 8 | required: true 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.10" 18 | 19 | - name: Bump to version ${{ github.event.inputs.version }} 20 | run: | 21 | version='${{ github.event.inputs.version }}' 22 | sed -i -E "s/^__version__ = \"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\"/__version__ = \"$version\"/" fastapix/main.py 23 | echo "version=$version" >> $GITHUB_ENV 24 | 25 | - name: Commit version bump 26 | run: | 27 | git config --global user.name 'Github Actions' 28 | git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com' 29 | git add fastapix/main.py 30 | git commit -m "🔖 Release $version" 31 | git push origin main 32 | 33 | - name: Install dependencies 34 | run: python -m pip install --upgrade build twine 35 | 36 | - name: PyPI release 37 | run: | 38 | python -m build 39 | twine upload dist/* 40 | env: 41 | TWINE_USERNAME: __token__ 42 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 43 | 44 | - name: Create a GitHub Release 45 | run: gh release create v$version --generate-notes 46 | env: 47 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.2.0 4 | hooks: 5 | - id: check-toml 6 | - id: check-yaml 7 | - id: check-json 8 | - id: check-added-large-files 9 | - id: debug-statements 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | 13 | - repo: https://github.com/psf/black 14 | rev: 22.3.0 15 | hooks: 16 | - id: black 17 | 18 | - repo: https://github.com/pycqa/isort 19 | rev: 5.10.1 20 | hooks: 21 | - id: isort 22 | 23 | - repo: https://github.com/PyCQA/flake8 24 | rev: 4.0.1 25 | hooks: 26 | - id: flake8 27 | entry: pflake8 28 | additional_dependencies: 29 | - flake8-comprehensions 30 | - pyproject-flake8 31 | 32 | - repo: https://github.com/pre-commit/mirrors-mypy 33 | rev: v0.981 34 | hooks: 35 | - id: mypy 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Marcelo Trylesinski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | FastAPI X 3 |

4 |

5 | 6 | Latest Commit 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | Package version 15 | 16 | 17 | 18 |

19 | 20 | :warning: **This project is still under development.** 21 | 22 | ## Features 23 | 24 | The most important feature are the project and component generators. 25 | 26 | - [X] Show environment variables the project is reading - `fastapix env`. 27 | - [X] Show application routes. - `fastapix routes`. 28 | - [X] Show application middlewares. - `fastapix middlewares`. 29 | - [ ] Project generator - `fastapix new app`. 30 | - [ ] Generate a new project. 31 | - [ ] Generate Dockerfile. 32 | - [ ] Component generator - `fastapix new component`. 33 | - [ ] Generate a new component based on the current project structure. 34 | - [ ] Plugin system. People will be able to create their own commands. 35 | - [ ] Embed Flake8-FastAPI - `fastapix lint`. 36 | - [ ] Embed formatter - `fastapix format`. 37 | - [ ] Show information about the project - `fastapix info`. 38 | 39 | ## Installation 40 | 41 | ```bash 42 | pip install fastapix 43 | ``` 44 | 45 | ## License 46 | 47 | This project is licensed under the terms of the MIT license. 48 | -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapix/3ec58527e9d17f39a671b94cb3483367ed7421ba/docs/commands.md -------------------------------------------------------------------------------- /docs/generators/component.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapix/3ec58527e9d17f39a671b94cb3483367ed7421ba/docs/generators/component.md -------------------------------------------------------------------------------- /docs/generators/index.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapix/3ec58527e9d17f39a671b94cb3483367ed7421ba/docs/generators/index.md -------------------------------------------------------------------------------- /docs/generators/project.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapix/3ec58527e9d17f39a671b94cb3483367ed7421ba/docs/generators/project.md -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | **Documentation**: View it on [website](https://kludex.github.io/fastapix/) 4 | 5 | **Source Code**: View it on [Github](https://github.com/kludex/fastapix/) 6 | 7 | --- 8 | 9 | **FastAPI X** is a CLI tool to manage your FastAPI projects. 10 | 11 | ## Features 🚀 12 | 13 | - **Generate a new FastAPI project** 14 | - **Generate components to your project** 15 | 16 | ## Installation ✍️ 17 | 18 | **FastAPI X** can be installed by running `pip install fastapix`. 19 | 20 | ## Usage 📖 21 | 22 | **FastAPI X** is a CLI tool, so you can run it by typing `fastapix` in your terminal. 23 | -------------------------------------------------------------------------------- /fastapix/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapix/3ec58527e9d17f39a671b94cb3483367ed7421ba/fastapix/__init__.py -------------------------------------------------------------------------------- /fastapix/__main__.py: -------------------------------------------------------------------------------- 1 | from fastapix.main import app # pragma: no cover 2 | 3 | if __name__ == "__main__": # pragma: no cover 4 | app() 5 | -------------------------------------------------------------------------------- /fastapix/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapix/3ec58527e9d17f39a671b94cb3483367ed7421ba/fastapix/commands/__init__.py -------------------------------------------------------------------------------- /fastapix/commands/environment.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command that prints out the environment variables that are used. 3 | """ 4 | import io 5 | import json 6 | from inspect import getmembers, isclass 7 | from pathlib import Path 8 | from typing import Type 9 | 10 | import libcst as cst 11 | import libcst.matchers as m 12 | import typer 13 | from pydantic import BaseSettings 14 | from pytablewriter.style import Style 15 | from pytablewriter.writer import MarkdownTableWriter 16 | 17 | from fastapix.context import Context 18 | from fastapix.loader import import_from_filename 19 | 20 | app = typer.Typer(name="env", help="Print out the environment variables used.") 21 | 22 | 23 | # TODO: Add template option. 24 | # TODO: Use rich if output is stdout. 25 | @app.callback(invoke_without_command=True) # type: ignore[misc] 26 | def main( 27 | ctx: Context, 28 | output: Path = typer.Option(Path("/dev/stdout"), help="Output filename."), 29 | ) -> None: 30 | """Print out the environment variables used.""" 31 | if ctx.obj.structure.settings is None: # pragma: no cover 32 | ctx.obj.console.print("No settings found.") 33 | raise typer.Exit(2) 34 | 35 | module = import_from_filename(ctx.obj.structure.settings.filename) 36 | 37 | for name, value in getmembers(module): 38 | if isclass(value) and issubclass(value, BaseSettings) and value != BaseSettings: 39 | show_environment(settings_class=value, output=output) 40 | 41 | 42 | def show_environment(settings_class: Type[BaseSettings], output: Path) -> None: 43 | table = [] 44 | 45 | schema_json = json.loads(settings_class.schema_json()) 46 | properties = schema_json["properties"] 47 | required = schema_json.get("required", []) 48 | 49 | for key, value in properties.items(): 50 | required_column = "✅" if key in required else "❌" 51 | table.append( 52 | [ 53 | key, 54 | value.get("description", "-"), 55 | value["type"], 56 | str(value.get("default", "-")), 57 | required_column, 58 | ] 59 | ) 60 | 61 | writer = MarkdownTableWriter( 62 | headers=["Name", "Description", "Type", "Default", "Required"], 63 | value_matrix=table, 64 | margin=1, 65 | ) 66 | writer.set_style(2, Style(align="center")) 67 | writer.set_style(3, Style(align="center")) 68 | writer.set_style(4, Style(align="center")) 69 | writer.stream = io.StringIO() 70 | writer.write_table() 71 | 72 | with output.open("w") as fp: 73 | fp.write(writer.stream.getvalue()) 74 | 75 | 76 | # TODO: Use CST instead of loading the module. 77 | class BaseSettingsVisitor(cst.CSTVisitor): # type: ignore[misc] # pragma: no cover 78 | def __init__(self) -> None: 79 | self.inside_settings = False 80 | 81 | def visit_ClassDef(self, node: cst.ClassDef) -> None: 82 | for base in node.bases: 83 | if m.matches(base, m.Arg(value=m.Name("BaseSettings"))): 84 | self.inside_settings = True 85 | 86 | def leave_ClassDef(self, original_node: cst.ClassDef) -> None: 87 | self.inside_settings = False 88 | -------------------------------------------------------------------------------- /fastapix/commands/middlewares.py: -------------------------------------------------------------------------------- 1 | from inspect import getmembers 2 | from typing import Union 3 | 4 | import typer 5 | from fastapi import FastAPI 6 | from rich.table import Table 7 | 8 | from fastapix.context import Context 9 | from fastapix.loader import import_from_filename 10 | 11 | app = typer.Typer(name="middlewares", help="List FastAPI middlewares.") 12 | 13 | 14 | @app.callback(invoke_without_command=True) # type: ignore[misc] 15 | def middlewares(ctx: Context) -> None: 16 | if ctx.obj.structure.app is None: # pragma: no cover 17 | ctx.obj.console.print("No app found.") 18 | raise typer.Exit(2) 19 | 20 | module = import_from_filename(ctx.obj.structure.app.filename) 21 | app: Union[FastAPI, None] = None 22 | # TODO: Support factory functions. 23 | for name, value in getmembers(module): 24 | if isinstance(value, FastAPI): 25 | app = value 26 | 27 | # TODO: PR welcome to add test. 28 | if app is None: # pragma: no cover 29 | raise RuntimeError("Could not find FastAPI instance.") 30 | 31 | headers = ("middleware", "parameter", "value") 32 | middlewares = [ 33 | ( 34 | middleware.cls.__name__, 35 | "\n".join(str(key) for key in middleware.options.keys()), 36 | "\n".join(str(value) for value in middleware.options.values()), 37 | ) 38 | for middleware in app.user_middleware 39 | ] 40 | for middleware in app.user_middleware: 41 | print(middleware.cls, middleware.options) 42 | table = Table(show_header=True, header_style="bold magenta") 43 | for column in headers: 44 | table.add_column(column) 45 | for middleware in middlewares: 46 | table.add_row(*middleware) 47 | ctx.obj.console.print(table) 48 | -------------------------------------------------------------------------------- /fastapix/commands/routes.py: -------------------------------------------------------------------------------- 1 | from inspect import getmembers 2 | from typing import Union 3 | 4 | import typer 5 | from fastapi import FastAPI 6 | from rich.table import Table 7 | 8 | from fastapix.context import Context 9 | from fastapix.loader import import_from_filename 10 | 11 | app = typer.Typer(name="routes", help="List FastAPI routes.") 12 | 13 | 14 | @app.callback(invoke_without_command=True) # type: ignore[misc] 15 | def routes(ctx: Context) -> None: 16 | if ctx.obj.structure.app is None: # pragma: no cover 17 | ctx.obj.console.print("No app found.") 18 | raise typer.Exit(2) 19 | 20 | module = import_from_filename(ctx.obj.structure.app.filename) 21 | app: Union[FastAPI, None] = None 22 | # TODO: Support factory functions. 23 | for name, value in getmembers(module): 24 | if isinstance(value, FastAPI): 25 | app = value 26 | 27 | # TODO: PR welcome to add test. 28 | if app is None: # pragma: no cover 29 | raise RuntimeError("Could not find FastAPI instance.") 30 | 31 | headers = ("name", "path", "methods") 32 | routes: list[tuple[str, str, str]] = [] 33 | for route in app.routes: 34 | name = str(getattr(route, "name")) 35 | path = str(getattr(route, "path")) 36 | methods = sorted(getattr(route, "methods", None) or {}) 37 | routes.append((name, path, str(methods))) 38 | routes.sort(key=lambda x: x[1]) 39 | table = Table(show_header=True, header_style="bold magenta") 40 | for column in headers: 41 | table.add_column(column) 42 | for route in routes: 43 | table.add_row(*route) 44 | ctx.obj.console.print(table) 45 | -------------------------------------------------------------------------------- /fastapix/context.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Union 3 | 4 | from rich.console import Console 5 | from typer import Context as _Context 6 | 7 | 8 | @dataclass 9 | class Component: 10 | filename: str 11 | content: str 12 | 13 | 14 | @dataclass 15 | class ProjectStructure: 16 | settings: Union[Component, None] 17 | app: Union[Component, None] 18 | 19 | 20 | @dataclass 21 | class ContextObject: 22 | console: Console 23 | structure: ProjectStructure 24 | 25 | 26 | class Context(_Context): # type: ignore[misc] 27 | obj: ContextObject 28 | -------------------------------------------------------------------------------- /fastapix/inference/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapix.inference.main import infer_project_structure as infer_project_structure 2 | -------------------------------------------------------------------------------- /fastapix/inference/analyzers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class Analyzer: 5 | def match(self, content: str) -> bool: 6 | raise NotImplementedError # pragma: no cover 7 | 8 | 9 | class SettingsAnalyzer(Analyzer): 10 | def match(self, content: str) -> bool: 11 | if re.search(r"class .+\(BaseSettings\):", content): 12 | return True 13 | return False 14 | 15 | 16 | class ApplicationAnalyzer(Analyzer): 17 | def match(self, content: str) -> bool: 18 | """Check if the content contains a FastAPI instance. 19 | 20 | This is a very naive implementation, and it's expected to fail. 21 | """ 22 | regex = "|".join([r"=\s*FastAPI\(\)", r"return FastAPI\("]) 23 | if re.search(regex, content): 24 | return True 25 | return False 26 | -------------------------------------------------------------------------------- /fastapix/inference/main.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | from typing import Union 4 | 5 | from rich.console import Console 6 | 7 | from fastapix.context import Component, ProjectStructure 8 | from fastapix.inference.analyzers import ApplicationAnalyzer, SettingsAnalyzer 9 | 10 | # TODO: Limit the number of files. If surpass the threshold, ask for manual input. 11 | 12 | 13 | def infer_project_structure(console: Console) -> ProjectStructure: 14 | python_pattern = os.getcwd() + "/**/*.py" 15 | 16 | settings_analyzer = SettingsAnalyzer() 17 | settings: Union[Component, None] = None 18 | 19 | app_analyzer = ApplicationAnalyzer() 20 | app: Union[Component, None] = None 21 | 22 | filenames = iter(glob.glob(python_pattern, recursive=True)) 23 | filename = next(filenames, None) 24 | while filename and (app is None or settings is None): 25 | with open(filename, "r") as f: 26 | content = f.read() 27 | if settings_analyzer.match(content): # pragma: no cover 28 | settings = Component(filename=filename, content=content) 29 | if app_analyzer.match(content): # pragma: no cover 30 | app = Component(filename=filename, content=content) 31 | filename = next(filenames, None) 32 | 33 | return ProjectStructure(settings=settings, app=app) 34 | -------------------------------------------------------------------------------- /fastapix/loader.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | import sys 3 | from types import ModuleType 4 | 5 | 6 | def import_from_filename(filename: str) -> ModuleType: 7 | """Import a module from a filename.""" 8 | spec = importlib.util.spec_from_file_location("-", filename) 9 | if spec is None or spec.loader is None: 10 | raise RuntimeError("Could not load settings file.") # pragma: no cover 11 | 12 | module = importlib.util.module_from_spec(spec) 13 | sys.modules["module"] = module 14 | spec.loader.exec_module(module) 15 | 16 | return module 17 | -------------------------------------------------------------------------------- /fastapix/main.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from pathlib import Path 3 | 4 | import typer 5 | from appdirs import user_cache_dir 6 | from rich.console import Console 7 | 8 | from fastapix.commands.environment import app as env_app 9 | from fastapix.commands.middlewares import app as middlewares_app 10 | from fastapix.commands.routes import app as routes_app 11 | from fastapix.context import Context, ContextObject 12 | from fastapix.inference import infer_project_structure 13 | 14 | __version__ = "0.1.0" 15 | 16 | 17 | app = typer.Typer(name="FastAPI X", help="Manage your FastAPI project.") 18 | app.add_typer(env_app) 19 | app.add_typer(routes_app) 20 | app.add_typer(middlewares_app) 21 | 22 | 23 | def version_callback(value: bool) -> None: 24 | if value: 25 | typer.echo( 26 | "Running FastAPI X {} with {} {} on {}.".format( 27 | __version__, 28 | platform.python_implementation(), 29 | platform.python_version(), 30 | platform.system(), 31 | ) 32 | ) 33 | raise typer.Exit(0) 34 | 35 | 36 | @app.callback() # type: ignore[misc] 37 | def main( 38 | ctx: Context, 39 | version: bool = typer.Option( 40 | None, 41 | "--version", 42 | callback=version_callback, 43 | is_eager=True, 44 | help="Show version and exit.", 45 | ), 46 | ) -> None: 47 | console = Console() 48 | 49 | cache_dir = Path(user_cache_dir("fastapix")) 50 | cache_dir.mkdir(exist_ok=True) 51 | 52 | # TODO: Create an entry per project. Each entry that represents a file should have 53 | # a hash. 54 | 55 | project_structure = infer_project_structure(console) 56 | ctx.obj = ContextObject(console=console, structure=project_structure) 57 | -------------------------------------------------------------------------------- /fastapix/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapix/3ec58527e9d17f39a671b94cb3483367ed7421ba/fastapix/py.typed -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FastAPI X 2 | theme: 3 | name: material 4 | palette: 5 | primary: "blue" 6 | accent: "orange" 7 | font: 8 | text: "Ubuntu" 9 | repo_name: Kludex/fastapix 10 | repo_url: https://github.com/Kludex/fastapix 11 | 12 | nav: 13 | - FastAPI X: index.md 14 | - Commands: commands.md 15 | - Generators: 16 | - Generators: generators/index.md 17 | - Project: generators/project.md 18 | - Component: generators/component.md 19 | 20 | markdown_extensions: 21 | - toc: 22 | permalink: true 23 | - markdown.extensions.codehilite: 24 | guess_lang: false 25 | - admonition 26 | - codehilite 27 | - extra 28 | - tables 29 | - smarty 30 | - pymdownx.tabbed 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = 'hatchling.build' 4 | 5 | [project] 6 | name = "fastapix" 7 | description = "FastAPI X is a powerful CLI tool to help you! :rocket:" 8 | readme = "README.md" 9 | authors = [{ name = "Marcelo Trylesinski", email = "marcelotryle@email.com" }] 10 | classifiers = [ 11 | "Development Status :: 3 - Alpha", 12 | "License :: OSI Approved :: MIT License", 13 | "Intended Audience :: Developers", 14 | "Natural Language :: English", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | ] 23 | license = "MIT" 24 | requires-python = ">=3.7" 25 | dependencies = [ 26 | "typer>=0.6.1", 27 | "rich>=12.6.0", 28 | "appdirs>=1.4.4", 29 | "libcst>=0.4.7", 30 | "pytablewriter>=0.64.2", 31 | ] 32 | optional-dependencies = {} 33 | dynamic = ["version"] 34 | 35 | [tool.hatch.version] 36 | path = "fastapix/main.py" 37 | 38 | [project.urls] 39 | Homepage = "https://github.com/Kludex/fastapix" 40 | Source = "https://github.com/Kludex/fastapix" 41 | Twitter = "https://twitter.com/marcelotryle" 42 | Funding = 'https://github.com/sponsors/Kludex' 43 | 44 | [project.scripts] 45 | fastapix = "fastapix.main:app" 46 | 47 | [tool.mypy] 48 | strict = true 49 | show_error_codes = true 50 | 51 | [tool.flake8] 52 | statistics = true 53 | max-line-length = 88 54 | ignore = ["E203", "E501", "W503"] 55 | per-file-ignores = ["__init__.py:F401"] 56 | 57 | [tool.black] 58 | target-version = ["py37"] 59 | 60 | [tool.isort] 61 | profile = "black" 62 | combine_as_imports = true 63 | 64 | [tool.pytest.ini_options] 65 | addopts = ["--strict-config", "--strict-markers"] 66 | filterwarnings = ["error"] 67 | 68 | [tool.coverage.run] 69 | source_pkgs = ["fastapix", "tests"] 70 | 71 | [tool.coverage.report] 72 | show_missing = true 73 | skip_covered = true 74 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | # Linter & Formatter 4 | isort 5 | flake8 6 | black 7 | mypy 8 | pyproject-flake8 9 | pre-commit 10 | 11 | # Tests 12 | coverage[toml] 13 | pytest 14 | pytest-sugar 15 | dirty-equals 16 | fastapi 17 | 18 | # Build 19 | hatch 20 | 21 | # Docs 22 | mkdocs-material 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from setuptools import setup 4 | 5 | sys.stderr.write( 6 | """ 7 | =============================== 8 | Unsupported installation method 9 | =============================== 10 | fastapix no longer supports installation with `python setup.py install`. 11 | Please use `python -m pip install .` instead. 12 | """ 13 | ) 14 | sys.exit(1) 15 | 16 | 17 | # The below code will never execute, however GitHub is particularly 18 | # picky about where it finds Python packaging metadata. 19 | # See: https://github.com/github/feedback/discussions/6456 20 | # 21 | # To be removed once GitHub catches up. 22 | 23 | setup( 24 | name="fastapix", 25 | install_requires=[], 26 | ) 27 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapix/3ec58527e9d17f39a671b94cb3483367ed7421ba/tests/__init__.py -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapix/3ec58527e9d17f39a671b94cb3483367ed7421ba/tests/commands/__init__.py -------------------------------------------------------------------------------- /tests/commands/test_env.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from pathlib import Path 3 | 4 | from typer.testing import CliRunner 5 | 6 | from fastapix.main import app 7 | 8 | 9 | def test_env_command(tmp_path: Path) -> None: 10 | content = textwrap.dedent( 11 | """ 12 | from pydantic import BaseSettings 13 | 14 | class Settings(BaseSettings): 15 | foo: str = "bar" 16 | bar: int 17 | """ 18 | ) 19 | runner = CliRunner() 20 | with runner.isolated_filesystem(temp_dir=tmp_path) as td: 21 | file = Path(td) / "settings.py" 22 | file.write_text(content) 23 | output = Path(td) / "output.md" 24 | result = runner.invoke(app, ["env", "--output", str(output)]) 25 | assert result.exit_code == 0, result.stdout 26 | with output.open("r") as f: 27 | content = f.read() 28 | expected = ( 29 | "| Name | Description | Type | Default | Required |", 30 | "| ---- | ----------- | :-----: | :-----: | :------: |", 31 | "| foo | - | string | bar | ❌ |", 32 | "| bar | - | integer | - | ✅ |", 33 | ) 34 | assert all(line in content for line in expected) 35 | -------------------------------------------------------------------------------- /tests/commands/test_middlewares.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from pathlib import Path 3 | 4 | from typer.testing import CliRunner 5 | 6 | from fastapix.main import app 7 | 8 | 9 | def test_middlewares_command(tmp_path: Path) -> None: 10 | content = textwrap.dedent( 11 | """ 12 | from fastapi import FastAPI 13 | from fastapi.middleware.cors import CORSMiddleware 14 | 15 | app = FastAPI() 16 | app.add_middleware( 17 | CORSMiddleware, 18 | allow_origins=["*"], 19 | allow_credentials=True, 20 | allow_methods=["*"], 21 | allow_headers=["*"], 22 | ) 23 | """ 24 | ) 25 | runner = CliRunner() 26 | with runner.isolated_filesystem(temp_dir=tmp_path) as td: 27 | file = Path(td) / "main.py" 28 | file.write_text(content) 29 | result = runner.invoke(app, ["middlewares"]) 30 | assert result.exit_code == 0, result.stdout 31 | expected = textwrap.dedent( 32 | """ 33 | ┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓ 34 | ┃ middleware ┃ parameter ┃ value ┃ 35 | ┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩ 36 | │ CORSMiddleware │ allow_origins │ ['*'] │ 37 | │ │ allow_credentials │ True │ 38 | │ │ allow_methods │ ['*'] │ 39 | │ │ allow_headers │ ['*'] │ 40 | └────────────────┴───────────────────┴───────┘ 41 | """ 42 | ) 43 | assert expected in result.stdout, result.stdout 44 | -------------------------------------------------------------------------------- /tests/commands/test_routes.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from pathlib import Path 3 | 4 | from typer.testing import CliRunner 5 | 6 | from fastapix.main import app 7 | 8 | 9 | def test_routes_command(tmp_path: Path) -> None: 10 | content = textwrap.dedent( 11 | """ 12 | from fastapi import FastAPI 13 | 14 | app = FastAPI() 15 | 16 | @app.get("/") 17 | def index(): 18 | return {"message": "Hello World!"} 19 | """ 20 | ) 21 | runner = CliRunner() 22 | with runner.isolated_filesystem(temp_dir=tmp_path) as td: 23 | file = Path(td) / "main.py" 24 | file.write_text(content) 25 | result = runner.invoke(app, ["routes"]) 26 | assert result.exit_code == 0, result.stdout 27 | expected = textwrap.dedent( 28 | """┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ 29 | ┃ name ┃ path ┃ methods ┃ 30 | ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ 31 | │ index │ / │ ['GET'] │ 32 | │ swagger_ui_html │ /docs │ ['GET', 'HEAD'] │ 33 | │ swagger_ui_redirect │ /docs/oauth2-redirect │ ['GET', 'HEAD'] │ 34 | │ openapi │ /openapi.json │ ['GET', 'HEAD'] │ 35 | │ redoc_html │ /redoc │ ['GET', 'HEAD'] │ 36 | └─────────────────────┴───────────────────────┴─────────────────┘ 37 | """ 38 | ) 39 | assert expected in result.stdout 40 | -------------------------------------------------------------------------------- /tests/test_analyzers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapix.inference.analyzers import ApplicationAnalyzer, SettingsAnalyzer 4 | 5 | 6 | @pytest.mark.parametrize( # type: ignore[misc] 7 | "content, expected", 8 | [ 9 | ("class Settings(BaseSettings):", True), 10 | ("class Potato(Settings):", False), 11 | ], 12 | ) 13 | def test_settings_analyzer(content: str, expected: bool) -> None: 14 | analyzer = SettingsAnalyzer() 15 | assert analyzer.match(content) == expected 16 | 17 | 18 | @pytest.mark.parametrize( # type: ignore[misc] 19 | "content, expected", 20 | [ 21 | ("app = FastAPI()", True), 22 | ("return FastAPI(", True), 23 | ("def potato() -> FastAPI:", False), 24 | ], 25 | ) 26 | def test_application_analyzer(content: str, expected: bool) -> None: 27 | analyzer = ApplicationAnalyzer() 28 | assert analyzer.match(content) == expected 29 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from typer.testing import CliRunner 2 | 3 | from fastapix.main import app 4 | 5 | runner = CliRunner() 6 | 7 | 8 | def test_version() -> None: 9 | result = runner.invoke(app, ["--version"]) 10 | assert result.exit_code == 0 11 | assert "FastAPI X" in result.stdout 12 | --------------------------------------------------------------------------------