diff --git a/.gitignore b/.gitignore
index 555451d..15c8501 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ __pycache__/
# poetry
dist/
poetry.toml
+poetry.lock
.pytest_cache/
# others
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..343c7c4
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,25 @@
+repos:
+- repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: 'v4.4.0'
+ hooks:
+ - id: check-yaml
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ - id: no-commit-to-branch
+
+- repo: https://github.com/pycqa/isort
+ rev: '5.12.0'
+ hooks:
+ - id: isort
+
+- repo: https://github.com/psf/black
+ rev: '23.9.1'
+ hooks:
+ - id: black
+
+- repo: https://github.com/pycqa/flake8
+ rev: '6.1.0'
+ hooks:
+ - id: flake8
+ args: ['--extend-ignore=E203,E501,W503']
+ stages: [pre-push]
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 983fc2e..925ea1e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,11 +3,9 @@
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.tabSize": 4
},
- "black-formatter.args": [
- "--line-length=79"
- ],
"files.exclude": {
- "**/__pycache__": true
+ "**/__pycache__": true,
+ "**/.venv": true
},
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
@@ -15,8 +13,5 @@
"flake8.args": [
"--ignore=E501,W503"
],
- "isort.args": [
- "--profile=black"
- ],
"python.analysis.typeCheckingMode": "strict"
}
diff --git a/README.md b/README.md
index acc41f2..2e721c9 100644
--- a/README.md
+++ b/README.md
@@ -1,141 +1,143 @@
# Flask live tailwindcss
-A simple package for adding a dev server to your flask app that automatically compiles your tailwindcss of the templates on save and reloads your browser to sync the changes.
+A simple package for adding a dev server to your flask app that automatically compiles the tailwindcss of the templates on file save and triggers a browser reload to sync the changes on the fly.
-> **Note:** This package is intended to use with [poetry](https://python-poetry.org/). If you are not using poetry, you can still use this package by installing the dependencies manually.
-
-## Integrate with poetry
-
-### Installation
+## Installation
```bash
+# using poetry
poetry add --group=dev flask-livetw
+
+# using pip
+pip install flask-livetw
```
-### Initialization
-Simply go to your project root folder, run the following command and follow along the steps.
+## Initialization
+
+To start using this package, simply go to your project root folder, run the following command and follow along the steps.
```bash
-poetry run flask-livetw
+# using poetry
+poetry shell
+flask-livetw init
+
+# using pip
+"activate your virtual environment like you normally do"
+flask-livetw init
```
-> **Note 1:** If want to skip the questions, you can use the `-Y` or `--yes` flag.
+> **Note 1:** To skip requirements check use the `-Y` or `--yes` flag.
>
-> **Note 2:** If you want to use default values for the setup, you can use the `-D` or `--default` flag.
->
-> **Note 3:** You can use the `-h` or `--help` flag to see the available options.
-
-## Integrate with pip
+> **Note 2:** To use default values for the initialization use the `-D` or `--default` flag.
-### Installation
+### Default values
-```bash
-pip install flask-livetw
-```
+```py
+FLASK_ROOT = "src"
-### Initialization
+STATIC_FOLDER = "src/static"
-Simply go to your project root folder, run the following command and follow along the steps.
+TEMPLATE_FOLDER = "src/templates"
+TEMPLATE_GLOB = "src/templates/**/*.html"
+ROOT_LAYOUT_FILE = "src/templates/layout.html"
-```bash
-python -m flask_livetw
-```
+LIVE_RELOAD_FILE = "src/static/.dev/live_reload.js"
-After the initialization, you need to install the dependencies manually.
+GLOBALCSS_FILE = "src/static/.dev/global.css"
+TAILWIND_FILE = "src/static/.dev/tailwind.css"
+MINIFIED_TAILWIND_FILE = "src/static/tailwind_min.css"
-```bash
-pip install pytailwindcss python-dotenv websockets
+UPDATE_GITIGNORE = False
```
+Example as file system tree:
+```txt
+project_root
+├── src
+│ ├── static
+│ │ ├── .dev
+│ │ │ ├── global.css
+│ │ │ ├── live_reload.js
+│ │ │ └── tailwind.css
+│ │ ├── tailwind_min.css
+│ │ ...
+│ └── templates
+│ ├── layout.html
+│ ...
+├── .gitignore
+├── pyproject.toml
+...
+```
-## Usage
-### Development
+## Commands
-When developing your app, you can use the following command to start the dev server.
+In order to use the commands, you need to activate your virtual environment first.
```bash
-./dev.py dev
-```
+# using poetry
+poetry shell
-> **Note:** You can use the `-h` or `--help` flag to see the available options.
+# using pip
+"activate your virtual environment like you normally do"
+```
-### Building
+Each command has its own help page, you can use the `-h` or `--help` flag to see the available options.
-When you are done developing, you can use the following command to build your app.
+### dev
```bash
-./dev.py build
+flask-livetw dev
```
-> **Note:** You can use the `-h` or `--help` flag to see the available options.
-
+By default the command starts:
-## Default values
+- a flask server in debug mode
+- a live reload websocket server
+- a tailwindcss in watch mode
-### Package cli
+### build
-```py
-DEFAULT_FLASK_ROOT = "src"
+Builds the tailwindcss of the templates as a single css file.
-DEFAULT_STATIC_FOLDER = "src/static"
+```bash
+flask-livetw build
+```
-DEFAULT_TEMPLATE_FOLDER = "src/templates"
-DEFAULT_TEMPLATE_GLOB = "src/templates/**/*.html"
+By default the builded tailwindcss file will be minified.
-DEFAULT_ROOT_LAYOUT_FILE = "src/templates/layout.html"
-DEFAULT_LIVE_RELOAD_FILE = "src/static/.dev/live_reload.js"
-DEFAULT_GLOBALCSS_FILE = ".dev/global.css"
-DEFAULT_TWCSS_FILE = "src/static/.dev/tailwindcss.css"
-DEFAULT_MINIFIED_TWCSS_FILE = "src/static/tailwindcss_min.css"
+### local-install
-DEFAULT_UPDATE_GITIGNORE = False
+```bash
+flask-livetw local-install
```
-Example as file system tree:
-
-```txt
-project_root
-├── src
-│ ├── static
-│ │ ├── .dev
-│ │ │ ├── global.css
-│ │ │ ├── live_reload.js
-│ │ │ └── tailwindcss.css
-│ │ ├── tailwindcss_min.css
-│ │ ...
-│ └── templates
-│ ├── layout.html
-│ ...
-├── .gitignore
-├── dev.py
-├── pyproject.toml
-...
-```
+This command creates a local script that mimics the `flask-livetw` command and installs the necessary dependencies.
-### Dev server
+After the installation, you can use the `dev` and `build` commands as follows:
-```py
-LRWS_HOST = "127.0.0.1"
-LRWS_PORT = 5678
-TW_INPUT_PATH = "package_test/static/.dev/global.css"
-TW_OUTPUT_PATH = "src/static/.dev/tailwindcss.css"
-TW_OUTPUT_PATH_BUILD = "src/static/tailwindcss_min.css"
+```bash
+./dev.py dev
+./dev.py build
```
+
## Contributing
Contributions are welcome, feel free to submit a pull request or an issue.
-## Credits
+
+## Packages used
- [pytailwindcss](https://github.com/timonweb/pytailwindcss)
- [python-dotenv](https://github.com/theskumar/python-dotenv)
+- [tomli](https://github.com/hukkin/tomli)
- [websockets](https://github.com/python-websockets/websockets)
+
## License
[MIT](./LICENSE)
diff --git a/flask_livetw/__main__.py b/flask_livetw/__main__.py
index 2253097..c82292c 100644
--- a/flask_livetw/__main__.py
+++ b/flask_livetw/__main__.py
@@ -1,5 +1,4 @@
from flask_livetw.main import main
-
if __name__ == "__main__":
raise SystemExit(main())
diff --git a/flask_livetw/build_app.py b/flask_livetw/build_app.py
new file mode 100644
index 0000000..341594e
--- /dev/null
+++ b/flask_livetw/build_app.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import shlex
+import subprocess
+from typing import Sequence
+
+from flask_livetw.config import Config
+from flask_livetw.util import Term, pkgprint
+
+MINIFY_ON_BUILD = True
+
+
+@dataclasses.dataclass
+class BuildConfig:
+ input: str
+ output: str
+ minify: bool
+
+
+def minify_tailwind(config: BuildConfig) -> int:
+ input_arg = f"-i {config.input}"
+
+ output_arg = f"-o {config.output}"
+
+ minify_arg = "--minify" if config.minify else ""
+
+ command = f"tailwindcss {input_arg} {output_arg} {minify_arg}"
+
+ pkgprint("Minifying tailwindcss for production...")
+
+ build_result = subprocess.run(shlex.split(command))
+
+ if build_result.returncode != 0:
+ pkgprint(f"Tailwind build for production {Term.R}fail{Term.END}")
+ return build_result.returncode
+
+ pkgprint(f"Tailwind build for production {Term.G}ready{Term.END}")
+ return build_result.returncode
+
+
+def build(cli_args: argparse.Namespace) -> int:
+ config = Config.from_pyproject_toml()
+
+ build_config = BuildConfig(
+ input=cli_args.input or config.full_globalcss_file,
+ output=cli_args.output or config.full_tailwind_minified_file,
+ minify=cli_args.minify,
+ )
+
+ return minify_tailwind(build_config)
+
+
+def add_command_args(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "-i",
+ "--input",
+ dest="input",
+ type=str,
+ help="Input path, accepts glob patterns.",
+ )
+ parser.add_argument(
+ "-o", "--output", dest="output", type=str, help="Output path."
+ )
+ build_minify_group = parser.add_mutually_exclusive_group()
+ build_minify_group.add_argument(
+ "--minify", dest="minify", action="store_true", help="Minify output."
+ )
+ build_minify_group.add_argument(
+ "--no-minify",
+ dest="minify",
+ action="store_false",
+ help="Do not minify output.",
+ )
+ parser.set_defaults(minify=MINIFY_ON_BUILD)
+
+
+def add_command(
+ subparser: argparse._SubParsersAction[argparse.ArgumentParser],
+) -> None:
+ parser = subparser.add_parser(
+ name="build",
+ description="""
+ Build the tailwindcss of the project as a single minified css file.
+ """,
+ help="Build tailwindcss for production.",
+ allow_abbrev=True,
+ formatter_class=argparse.MetavarTypeHelpFormatter,
+ )
+
+ add_command_args(parser)
+
+
+def main(args: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="""
+ Build the tailwindcss of the project as a single css file.
+ """,
+ allow_abbrev=True,
+ formatter_class=argparse.MetavarTypeHelpFormatter,
+ )
+
+ add_command_args(parser)
+
+ parsed_args = parser.parse_args(args)
+
+ return build(parsed_args)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/flask_livetw/cli.py b/flask_livetw/cli.py
deleted file mode 100644
index 57e0092..0000000
--- a/flask_livetw/cli.py
+++ /dev/null
@@ -1,251 +0,0 @@
-import argparse
-import dataclasses
-import os
-import shlex
-import subprocess
-from typing import Union
-
-from flask_livetw.util import Term
-
-DEFAULT_FLASK_ROOT = "src"
-
-DEFAULT_STATIC_FOLDER = "static"
-
-DEFAULT_TEMPLATE_FOLDER = "templates"
-DEFAULT_TEMPLATE_GLOB = "**/*.html"
-
-DEFAULT_ROOT_LAYOUT_FILE = "layout.html"
-DEFAULT_LIVE_RELOAD_FILE = ".dev/live_reload.js"
-DEFAULT_GLOBALCSS_FILE = ".dev/global.css"
-DEFAULT_TWCSS_FILE = ".dev/tailwindcss.css"
-DEFAULT_MINIFIED_TWCSS_FILE = "tailwindcss_min.css"
-
-DEFAULT_UPDATE_GITIGNORE = False
-
-
-PKG_PREFIX = f"{Term.M}[livetw]{Term.END}"
-
-
-@dataclasses.dataclass
-class Config:
- gitignore: bool
-
- flask_root: Union[str, None]
-
- static_folder: str
- full_static_folder: str
-
- templates_folder: str
- full_templates_folder: str
- templates_glob: str
- full_templates_glob: str
-
- root_layout_file: str
- full_root_layout_file: str
- live_reload_file: str
- full_live_reload_file: str
- globalcss_file: str
- full_globalcss_file: str
- twcss_file: str
- full_twcss_file: str
- minified_twcss_file: str
- full_minified_twcss_file: str
-
-
-def create_cli() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(
- description="Mods a Flask app to use TailwindCSS in a dev server like manner.",
- allow_abbrev=True,
- formatter_class=argparse.MetavarTypeHelpFormatter,
- )
-
- parser.add_argument(
- "-Y",
- "--yes",
- dest="all_yes",
- action="store_true",
- default=False,
- help="answer yes to all requirements checks",
- )
-
- parser.add_argument(
- "-D",
- "--default",
- dest="default",
- action="store_true",
- default=False,
- help="use default values for all options",
- )
-
- parser.add_argument(
- "--gi",
- "--gitignore",
- dest="gitignore",
- action="store_true",
- default=DEFAULT_UPDATE_GITIGNORE,
- help=f"update .gitignore to exclude dev related files (default: {DEFAULT_UPDATE_GITIGNORE})",
- )
-
- parser.add_argument(
- "--fr",
- "--flask-root",
- dest="flask_root",
- type=str,
- help="flask app root path (relative to cwd)",
- )
-
- return parser
-
-
-def pkgprint(*values: object, end: str = "\n", sep: str = " ") -> None:
- print(f"{PKG_PREFIX}", *values, end=end, sep=sep)
-
-
-def check_requirements() -> int:
- pkgprint("Checking requirements...")
- cwd = os.getcwd()
- pkgprint(f"Current working directory: {Term.C}{cwd}{Term.END}")
- continue_install = Term.confirm(f"{PKG_PREFIX} Is this your project root?")
-
- if not continue_install:
- pkgprint("Change cwd and start again. Modding canceled")
- return 1
-
- python_cmd = shlex.split("python --version")
- python_cmd_result = subprocess.run(
- python_cmd, shell=True, capture_output=True
- )
-
- if python_cmd_result.returncode != 0:
- Term.error("python --version failed, is python installed?")
- return python_cmd_result.returncode
-
- version = python_cmd_result.stdout.decode("utf-8").strip()
- if version != "Python 3.8.10":
- pkgprint(f"python --version: {Term.C}{version}{Term.END}")
-
- continue_install = Term.confirm(
- f"{PKG_PREFIX} Continue with this version?"
- )
- if not continue_install:
- pkgprint("Change python version and start again. Modding canceled")
- return 1
-
- return 0
-
-
-def get_config(args: argparse.Namespace) -> Config:
- if args.default:
- return Config(
- gitignore=DEFAULT_UPDATE_GITIGNORE,
- flask_root=DEFAULT_FLASK_ROOT,
- static_folder=DEFAULT_STATIC_FOLDER,
- full_static_folder=f"{DEFAULT_FLASK_ROOT}/{DEFAULT_STATIC_FOLDER}",
- templates_folder=DEFAULT_TEMPLATE_FOLDER,
- full_templates_folder=f"{DEFAULT_FLASK_ROOT}/{DEFAULT_TEMPLATE_FOLDER}",
- templates_glob=DEFAULT_TEMPLATE_GLOB,
- full_templates_glob=f"{DEFAULT_FLASK_ROOT}/{DEFAULT_TEMPLATE_FOLDER}/{DEFAULT_TEMPLATE_GLOB}",
- root_layout_file=DEFAULT_ROOT_LAYOUT_FILE,
- full_root_layout_file=f"{DEFAULT_FLASK_ROOT}/{DEFAULT_TEMPLATE_FOLDER}/{DEFAULT_ROOT_LAYOUT_FILE}",
- live_reload_file=DEFAULT_LIVE_RELOAD_FILE,
- full_live_reload_file=f"{DEFAULT_FLASK_ROOT}/{DEFAULT_STATIC_FOLDER}/{DEFAULT_LIVE_RELOAD_FILE}",
- globalcss_file=DEFAULT_GLOBALCSS_FILE,
- full_globalcss_file=f"{DEFAULT_FLASK_ROOT}/{DEFAULT_STATIC_FOLDER}/{DEFAULT_GLOBALCSS_FILE}",
- twcss_file=DEFAULT_TWCSS_FILE,
- full_twcss_file=f"{DEFAULT_FLASK_ROOT}/{DEFAULT_STATIC_FOLDER}/{DEFAULT_TWCSS_FILE}",
- minified_twcss_file=DEFAULT_MINIFIED_TWCSS_FILE,
- full_minified_twcss_file=f"{DEFAULT_FLASK_ROOT}/{DEFAULT_STATIC_FOLDER}/{DEFAULT_MINIFIED_TWCSS_FILE}",
- )
-
- gitignore = True if args.gitignore else False
-
- Term.blank()
-
- flask_root = args.flask_root
- if flask_root is None:
- flask_root = Term.ask_dir(
- f"{PKG_PREFIX} Flask app root (relative to {Term.C}cwd/{Term.END}) [{DEFAULT_FLASK_ROOT}] ",
- default=DEFAULT_FLASK_ROOT,
- )
-
- static_folder = Term.ask_dir(
- f"{PKG_PREFIX} Static folder (relative to {Term.C}cwd/{flask_root}/{Term.END}) [{DEFAULT_STATIC_FOLDER}] ",
- flask_root,
- DEFAULT_STATIC_FOLDER,
- )
-
- templates_folder = Term.ask_dir(
- f"{PKG_PREFIX} Templates folder (relative to {Term.C}cwd/{flask_root}/{Term.END}) [{DEFAULT_TEMPLATE_FOLDER}] ",
- flask_root,
- DEFAULT_TEMPLATE_FOLDER,
- )
-
- templates_glob = (
- Term.ask(
- f"{PKG_PREFIX} Templates glob (relative to {Term.C}cwd/{flask_root}/{templates_folder}/{Term.END}) [{DEFAULT_TEMPLATE_GLOB}] ",
- )
- or DEFAULT_TEMPLATE_GLOB
- )
-
- root_layout_file = (
- Term.ask(
- f"{PKG_PREFIX} Root layout file (relative to {Term.C}cwd/{flask_root}/{templates_folder}/{Term.END}) [{DEFAULT_ROOT_LAYOUT_FILE}] ",
- )
- or DEFAULT_ROOT_LAYOUT_FILE
- )
-
- live_reload_file = (
- Term.ask(
- f"{PKG_PREFIX} Live reload file (relative to {Term.C}cwd/{flask_root}/{static_folder}/{Term.END}) [{DEFAULT_LIVE_RELOAD_FILE}] ",
- )
- or DEFAULT_LIVE_RELOAD_FILE
- )
-
- globalcss_file = (
- Term.ask(
- f"{PKG_PREFIX} Global css file (relative to {Term.C}cwd/{flask_root}/{static_folder}/{Term.END}) [{DEFAULT_GLOBALCSS_FILE}] ",
- )
- or DEFAULT_GLOBALCSS_FILE
- )
-
- twcss_file = (
- Term.ask(
- f"{PKG_PREFIX} TailwindCSS file (relative to {Term.C}cwd/{flask_root}/{static_folder}/{Term.END}) [{DEFAULT_TWCSS_FILE}] ",
- )
- or DEFAULT_TWCSS_FILE
- )
-
- minified_twcss_file = (
- Term.ask(
- f"{PKG_PREFIX} Minified TailwindCSS file (relative to {Term.C}cwd/{flask_root}/{static_folder}/{Term.END}) [{DEFAULT_MINIFIED_TWCSS_FILE}] ",
- )
- or DEFAULT_MINIFIED_TWCSS_FILE
- )
-
- if not flask_root or flask_root == ".":
- full_static_folder = static_folder
- full_templates_folder = templates_folder
- else:
- full_static_folder = f"{flask_root}/{static_folder}"
- full_templates_folder = f"{flask_root}/{templates_folder}"
-
- return Config(
- gitignore=gitignore,
- flask_root=flask_root,
- static_folder=static_folder,
- full_static_folder=full_static_folder,
- templates_folder=templates_folder,
- full_templates_folder=full_templates_folder,
- templates_glob=templates_glob,
- full_templates_glob=f"{full_templates_folder}/{templates_glob}",
- root_layout_file=root_layout_file,
- full_root_layout_file=f"{full_templates_folder}/{root_layout_file}",
- live_reload_file=live_reload_file,
- full_live_reload_file=f"{full_static_folder}/{live_reload_file}",
- globalcss_file=globalcss_file,
- full_globalcss_file=f"{full_static_folder}/{globalcss_file}",
- twcss_file=twcss_file,
- full_twcss_file=f"{full_static_folder}/{twcss_file}",
- minified_twcss_file=minified_twcss_file,
- full_minified_twcss_file=f"{full_static_folder}/{minified_twcss_file}",
- )
diff --git a/flask_livetw/config.py b/flask_livetw/config.py
new file mode 100644
index 0000000..9e99058
--- /dev/null
+++ b/flask_livetw/config.py
@@ -0,0 +1,431 @@
+from __future__ import annotations
+
+import dataclasses
+import re
+from typing import Any
+
+import tomli
+
+from flask_livetw.util import PKG_PP, Term
+
+DEFAULT_FLASK_ROOT = "src"
+
+DEFAULT_FOLDER_STATIC = "static"
+
+DEFAULT_FOLDER_TEMPLATE = "templates"
+DEFAULT_TEMPLATES_GLOB = "**/*.html"
+
+DEFAULT_FILE_ROOT_LAYOUT = "layout.html"
+DEFAULT_FILE_LIVE_RELOAD = ".dev/live_reload.js"
+DEFAULT_FILE_GLOBALCSS = ".dev/global.css"
+DEFAULT_FILE_TAILWIND = ".dev/tailwind.css"
+DEFAULT_FILE_MINIFIED_TAILWIND = "tailwind_min.css"
+
+DEFAULT_LIVERELOAD_HOST = "127.0.0.1"
+DEFAULT_LIVERELOAD_PORT = 5678
+
+DEFAULT_FLASK_HOST = None
+DEFAULT_FLASK_PORT = None
+DEFAULT_FLASK_EXCLUDE_PATTERNS = []
+
+
+def get_pyproject_toml(base_dir: str | None = None) -> dict[str, Any] | None:
+ path = (
+ f"{base_dir.strip()}/pyproject.toml"
+ if base_dir and base_dir.strip()
+ else "pyproject.toml"
+ )
+ try:
+ with open(path, "rb") as f:
+ return tomli.load(f)
+ except FileNotFoundError:
+ Term.info(f"Could not find pyproject.toml at '{path}'")
+ return None
+ except tomli.TOMLDecodeError as e:
+ Term.warn(f"Malformed pyproject.toml: {e}")
+ return None
+
+
+@dataclasses.dataclass
+class Config:
+ flask_root: str
+
+ static_folder: str
+ full_static_folder: str
+
+ templates_folder: str
+ full_templates_folder: str
+
+ templates_glob: str
+ full_templates_glob: str
+ root_layout_file: str
+ full_root_layout_file: str
+
+ live_reload_file: str
+ full_live_reload_file: str
+
+ globalcss_file: str
+ full_globalcss_file: str
+ tailwind_file: str
+ full_tailwind_file: str
+ tailwind_minified_file: str
+ full_tailwind_minified_file: str
+
+ live_reload_host: str
+ live_reload_port: int
+
+ flask_host: str | None
+ flask_port: int | None
+ flask_exclude_patterns: list[str] | None
+
+ @staticmethod
+ def from_dict_with_defaults(
+ src_dict: dict[str, Any], base_dir: str | None = None
+ ) -> Config:
+ flask_root = src_dict.get("flask_root", DEFAULT_FLASK_ROOT)
+ static_folder = src_dict.get("static_folder", DEFAULT_FOLDER_STATIC)
+ templates_folder = src_dict.get(
+ "templates_folder", DEFAULT_FOLDER_TEMPLATE
+ )
+
+ if isinstance(base_dir, str) and base_dir != "":
+ base_dir = base_dir.rstrip("/")
+ full_static_folder = f"{base_dir}/{flask_root}/{static_folder}"
+ full_templates_folder = (
+ f"{base_dir}/{flask_root}/{templates_folder}"
+ )
+ else:
+ full_static_folder = f"{flask_root}/{static_folder}"
+ full_templates_folder = f"{flask_root}/{templates_folder}"
+
+ templates_glob = src_dict.get("templates_glob", DEFAULT_TEMPLATES_GLOB)
+ root_layout_file = src_dict.get(
+ "root_layout_file", DEFAULT_FILE_ROOT_LAYOUT
+ )
+ live_reload_file = src_dict.get(
+ "live_reload_file", DEFAULT_FILE_LIVE_RELOAD
+ )
+ globalcss_file = src_dict.get("globalcss_file", DEFAULT_FILE_GLOBALCSS)
+ tailwind_file = src_dict.get("tailwind_file", DEFAULT_FILE_TAILWIND)
+ tailwind_minified_file = src_dict.get(
+ "tailwind_minified_file", DEFAULT_FILE_MINIFIED_TAILWIND
+ )
+ live_reload_host = src_dict.get(
+ "live_reload_host", DEFAULT_LIVERELOAD_HOST
+ )
+ live_reload_port = src_dict.get(
+ "live_reload_port", DEFAULT_LIVERELOAD_PORT
+ )
+ flask_host = src_dict.get("flask_host", DEFAULT_FLASK_HOST)
+ flask_port = src_dict.get("flask_port", DEFAULT_FLASK_PORT)
+ flask_exclude_patterns = src_dict.get(
+ "flask_exclude_patterns", DEFAULT_FLASK_EXCLUDE_PATTERNS
+ )
+
+ config_dict: dict[str, Any] = {
+ "flask_root": flask_root,
+ "static_folder": static_folder,
+ "full_static_folder": full_static_folder,
+ "templates_folder": templates_folder,
+ "full_templates_folder": full_templates_folder,
+ "templates_glob": templates_glob,
+ "full_templates_glob": f"{full_templates_folder}/{templates_glob}",
+ "root_layout_file": root_layout_file,
+ "full_root_layout_file": f"{full_templates_folder}/{root_layout_file}",
+ "live_reload_file": live_reload_file,
+ "full_live_reload_file": f"{full_static_folder}/{live_reload_file}",
+ "globalcss_file": globalcss_file,
+ "full_globalcss_file": f"{full_static_folder}/{globalcss_file}",
+ "tailwind_file": tailwind_file,
+ "full_tailwind_file": f"{full_static_folder}/{tailwind_file}",
+ "tailwind_minified_file": tailwind_minified_file,
+ "full_tailwind_minified_file": f"{full_static_folder}/{tailwind_minified_file}",
+ "live_reload_host": live_reload_host,
+ "live_reload_port": live_reload_port,
+ "flask_host": flask_host,
+ "flask_port": flask_port,
+ "flask_exclude_patterns": flask_exclude_patterns,
+ }
+
+ return Config(**config_dict)
+
+ @staticmethod
+ def default(base_dir: str | None = None) -> Config:
+ return Config.from_dict_with_defaults({}, base_dir)
+
+ @staticmethod
+ def try_from_pyproject_toml(base_dir: str | None = None) -> Config | None:
+ if base_dir is None or base_dir == "":
+ base_dir = "."
+ else:
+ base_dir = base_dir.rstrip("/")
+
+ pyproject = get_pyproject_toml(base_dir)
+ if pyproject is None:
+ return None
+
+ tool_config = pyproject.get("tool")
+ if tool_config is None:
+ return None
+
+ flask_livetw_config = tool_config.get("flask-livetw")
+ if flask_livetw_config is None:
+ return None
+
+ return Config.from_dict_with_defaults(flask_livetw_config, base_dir)
+
+ @staticmethod
+ def from_pyproject_toml(base_dir: str | None = None) -> Config:
+ """Errors are ignored and default values are used instead"""
+ if base_dir is None or base_dir == "":
+ base_dir = "."
+
+ pyproject = get_pyproject_toml(base_dir)
+ if pyproject is None:
+ pyproject = {}
+
+ config = pyproject.get("tool", {})
+ if not isinstance(
+ config, dict
+ ): # pyright: ignore[reportUnnecessaryIsInstance]
+ config: dict[str, Any] = {}
+
+ config = config.get("flask-livetw", {})
+ if not isinstance(
+ config, dict
+ ): # pyright: ignore[reportUnnecessaryIsInstance]
+ config = {}
+
+ return Config.from_dict_with_defaults(config, base_dir)
+
+
+def add_field(
+ pyproject: dict[str, Any],
+ key: str,
+ new_config: Config,
+ default: Any,
+ acc: str,
+) -> str:
+ value = pyproject.get(key, getattr(new_config, key))
+ if value == default:
+ return acc
+
+ value_type = type(value)
+ if value_type == str:
+ return f'{acc}\n{key} = "{value}"'
+
+ return f"{acc}\n{key} = {value}"
+
+
+def update_pyproject_toml(config: Config) -> int:
+ try:
+ with open("pyproject.toml", "rb") as f:
+ pyproject = tomli.load(f)
+ except FileNotFoundError:
+ Term.info("Could not find pyproject.toml")
+ pyproject = {}
+ except tomli.TOMLDecodeError as e:
+ Term.info(f"Malformed pyproject.toml: {e}")
+ Term.info("Verify that the file is valid TOML")
+ return 1
+
+ ppconfig = pyproject.get("tool", {}).get("flask-livetw", {})
+
+ new_config = """\n[tool.flask-livetw]"""
+ new_config = add_field(
+ ppconfig, "flask_root", config, DEFAULT_FLASK_ROOT, new_config
+ )
+ new_config = add_field(
+ ppconfig, "static_folder", config, DEFAULT_FOLDER_STATIC, new_config
+ )
+ new_config = add_field(
+ ppconfig,
+ "templates_folder",
+ config,
+ DEFAULT_FOLDER_TEMPLATE,
+ new_config,
+ )
+ new_config = add_field(
+ ppconfig, "templates_glob", config, DEFAULT_TEMPLATES_GLOB, new_config
+ )
+ new_config = add_field(
+ ppconfig,
+ "root_layout_file",
+ config,
+ DEFAULT_FILE_ROOT_LAYOUT,
+ new_config,
+ )
+ new_config = add_field(
+ ppconfig,
+ "live_reload_file",
+ config,
+ DEFAULT_FILE_LIVE_RELOAD,
+ new_config,
+ )
+ new_config = add_field(
+ ppconfig, "globalcss_file", config, DEFAULT_FILE_GLOBALCSS, new_config
+ )
+ new_config = add_field(
+ ppconfig, "tailwind_file", config, DEFAULT_FILE_TAILWIND, new_config
+ )
+ new_config = add_field(
+ ppconfig,
+ "tailwind_minified_file",
+ config,
+ DEFAULT_FILE_MINIFIED_TAILWIND,
+ new_config,
+ )
+ new_config = add_field(
+ ppconfig,
+ "live_reload_host",
+ config,
+ DEFAULT_LIVERELOAD_HOST,
+ new_config,
+ )
+ new_config = add_field(
+ ppconfig,
+ "live_reload_port",
+ config,
+ DEFAULT_LIVERELOAD_PORT,
+ new_config,
+ )
+ new_config = add_field(
+ ppconfig, "flask_host", config, DEFAULT_FLASK_HOST, new_config
+ )
+ new_config = add_field(
+ ppconfig, "flask_port", config, DEFAULT_FLASK_PORT, new_config
+ )
+ new_config = add_field(
+ ppconfig,
+ "flask_exclude_patterns",
+ config,
+ DEFAULT_FLASK_EXCLUDE_PATTERNS,
+ new_config,
+ )
+ new_config += "\n"
+
+ try:
+ with open("pyproject.toml", "r") as f:
+ pyproject_toml = f.read()
+ if "[tool.flask-livetw]" in pyproject_toml:
+ pyproject_toml = re.sub(
+ r"\[tool\.flask-livetw\][^[]*", new_config, pyproject_toml
+ )
+ else:
+ pyproject_toml += new_config
+
+ with open("pyproject.toml", "w") as f:
+ f.write(pyproject_toml)
+ except FileNotFoundError:
+ Term.info("Could not find pyproject.toml")
+ Term.info("Creating pyproject.toml...")
+ with open("pyproject.toml", "w") as f:
+ f.write(new_config)
+
+ return 0
+
+
+def ask_project_layout(app_source: str | None = None) -> Config:
+ flask_root = app_source
+ if flask_root is None or flask_root == "":
+ flask_root = Term.ask_dir(
+ f"{PKG_PP} Flask app root (relative to {Term.C}cwd/{Term.END}) [{DEFAULT_FLASK_ROOT}] ",
+ default=DEFAULT_FLASK_ROOT,
+ )
+
+ static_folder = Term.ask_dir(
+ f"{PKG_PP} Static folder (relative to {Term.C}cwd/{flask_root}/{Term.END}) [{DEFAULT_FOLDER_STATIC}] ",
+ flask_root,
+ DEFAULT_FOLDER_STATIC,
+ )
+
+ templates_folder = Term.ask_dir(
+ f"{PKG_PP} Templates folder (relative to {Term.C}cwd/{flask_root}/{Term.END}) [{DEFAULT_FOLDER_TEMPLATE}] ",
+ flask_root,
+ DEFAULT_FOLDER_TEMPLATE,
+ )
+
+ templates_glob = (
+ Term.ask(
+ f"{PKG_PP} Templates glob (relative to {Term.C}cwd/{flask_root}/{templates_folder}/{Term.END}) [{DEFAULT_TEMPLATES_GLOB}] ",
+ )
+ or DEFAULT_TEMPLATES_GLOB
+ )
+
+ root_layout_file = (
+ Term.ask(
+ f"{PKG_PP} Root layout file (relative to {Term.C}cwd/{flask_root}/{templates_folder}/{Term.END}) [{DEFAULT_FILE_ROOT_LAYOUT}] ",
+ )
+ or DEFAULT_FILE_ROOT_LAYOUT
+ )
+
+ live_reload_file = (
+ Term.ask(
+ f"{PKG_PP} Live reload file (relative to {Term.C}cwd/{flask_root}/{static_folder}/{Term.END}) [{DEFAULT_FILE_LIVE_RELOAD}] ",
+ )
+ or DEFAULT_FILE_LIVE_RELOAD
+ )
+
+ globalcss_file = (
+ Term.ask(
+ f"{PKG_PP} Global css file (relative to {Term.C}cwd/{flask_root}/{static_folder}/{Term.END}) [{DEFAULT_FILE_GLOBALCSS}] ",
+ )
+ or DEFAULT_FILE_GLOBALCSS
+ )
+
+ tailwind_file = (
+ Term.ask(
+ f"{PKG_PP} TailwindCSS file (relative to {Term.C}cwd/{flask_root}/{static_folder}/{Term.END}) [{DEFAULT_FILE_TAILWIND}] ",
+ )
+ or DEFAULT_FILE_TAILWIND
+ )
+
+ tailwind_minified_file = (
+ Term.ask(
+ f"{PKG_PP} Minified TailwindCSS file (relative to {Term.C}cwd/{flask_root}/{static_folder}/{Term.END}) [{DEFAULT_FILE_MINIFIED_TAILWIND}] ",
+ )
+ or DEFAULT_FILE_MINIFIED_TAILWIND
+ )
+
+ if not flask_root or flask_root == ".":
+ full_static_folder = static_folder
+ full_templates_folder = templates_folder
+ else:
+ full_static_folder = f"{flask_root}/{static_folder}"
+ full_templates_folder = f"{flask_root}/{templates_folder}"
+
+ return Config(
+ flask_root=flask_root,
+ static_folder=static_folder,
+ full_static_folder=full_static_folder,
+ templates_folder=templates_folder,
+ full_templates_folder=full_templates_folder,
+ templates_glob=templates_glob,
+ full_templates_glob=f"{full_templates_folder}/{templates_glob}",
+ root_layout_file=root_layout_file,
+ full_root_layout_file=f"{full_templates_folder}/{root_layout_file}",
+ live_reload_file=live_reload_file,
+ full_live_reload_file=f"{full_static_folder}/{live_reload_file}",
+ globalcss_file=globalcss_file,
+ full_globalcss_file=f"{full_static_folder}/{globalcss_file}",
+ tailwind_file=tailwind_file,
+ full_tailwind_file=f"{full_static_folder}/{tailwind_file}",
+ tailwind_minified_file=tailwind_minified_file,
+ full_tailwind_minified_file=f"{full_static_folder}/{tailwind_minified_file}",
+ live_reload_host=DEFAULT_LIVERELOAD_HOST,
+ live_reload_port=DEFAULT_LIVERELOAD_PORT,
+ flask_host=DEFAULT_FLASK_HOST,
+ flask_port=DEFAULT_FLASK_PORT,
+ flask_exclude_patterns=DEFAULT_FLASK_EXCLUDE_PATTERNS,
+ )
+
+
+def main() -> int:
+ config = Config.from_pyproject_toml()
+ print(config)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/flask_livetw/dev_server.py b/flask_livetw/dev_server.py
new file mode 100644
index 0000000..a5959e6
--- /dev/null
+++ b/flask_livetw/dev_server.py
@@ -0,0 +1,363 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import concurrent.futures
+import dataclasses
+import datetime
+import json
+import shlex
+import subprocess
+from typing import Sequence, Set
+
+import websockets.legacy.protocol as ws_protocol
+import websockets.server as ws_server
+
+from flask_livetw.config import Config
+from flask_livetw.util import Term, pkgprint
+
+FLASK_BASE_EXCLUDE_PATTERNS = ("*/**/dev.py",)
+
+LR_CONNECTIONS: Set[ws_server.WebSocketServerProtocol] = set()
+
+
+async def handle_connection(websocket: ws_server.WebSocketServerProtocol):
+ LR_CONNECTIONS.add(websocket)
+ try:
+ await websocket.wait_closed()
+ finally:
+ LR_CONNECTIONS.remove(websocket)
+
+
+async def live_reload_server(host: str, port: int):
+ async with ws_server.serve(handle_connection, host, port) as server:
+ pkgprint(
+ f"Live reload {Term.G}ready{Term.END} on {Term.C}ws://{host}:{Term.BOLD}{port}{Term.END}"
+ )
+
+ await server.wait_closed()
+
+ pkgprint(f"Live reload {Term.G}closed{Term.END}")
+
+
+def handle_tailwind_output(process: subprocess.Popen[bytes]):
+ if process.stdout is None:
+ return
+
+ for line in iter(process.stdout.readline, b""):
+ if process.poll() is not None:
+ break
+
+ if line.startswith(b"Done"):
+ ws_protocol.broadcast(
+ LR_CONNECTIONS,
+ json.dumps(
+ {
+ "type": "TRIGGER_FULL_RELOAD",
+ "data": datetime.datetime.now().isoformat(),
+ }
+ ),
+ )
+
+ print(f'{Term.C}[twcss]{Term.END} {line.decode("utf-8")}', end="")
+
+
+def handle_flask_output(process: subprocess.Popen[bytes]):
+ if process.stdout is None:
+ return
+
+ for line in iter(process.stdout.readline, b""):
+ if process.poll() is not None:
+ break
+
+ print(f'{Term.G}[flask]{Term.END} {line.decode("utf-8")}', end="")
+
+
+@dataclasses.dataclass
+class DevConfig:
+ no_live_reload: bool
+ live_reload_host: str
+ live_reload_port: int
+
+ no_flask: bool
+ flask_host: str | None
+ flask_port: int | None
+ flask_mode: str
+ flask_exclude_patterns: Sequence[str] | None
+
+ no_tailwind: bool
+ tailwind_input: str | None
+ tailwind_output: str
+ tailwind_minify: bool
+
+
+async def dev_server(config: DevConfig):
+ def live_reload_coroutine():
+ if config.no_live_reload or config.no_tailwind:
+ return None
+
+ host = config.live_reload_host
+ port = config.live_reload_port
+
+ return live_reload_server(host, port)
+
+ def tailwind_cli_executor(
+ loop: asyncio.AbstractEventLoop,
+ pool: concurrent.futures.ThreadPoolExecutor,
+ ):
+ if config.no_tailwind:
+ return None
+
+ input_arg = ""
+ if config.tailwind_input is not None:
+ input_arg = f"-i {config.tailwind_input}"
+
+ output_arg = f"-o {config.tailwind_output}"
+
+ minify_arg = "--minify" if config.tailwind_minify else ""
+
+ cmd = f"tailwindcss --watch {input_arg} {output_arg} {minify_arg}"
+
+ process = subprocess.Popen(
+ shlex.split(cmd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT
+ )
+
+ return loop.run_in_executor(pool, handle_tailwind_output, process)
+
+ def flask_server_executor(
+ loop: asyncio.AbstractEventLoop,
+ pool: concurrent.futures.ThreadPoolExecutor,
+ ):
+ if config.no_flask:
+ return None
+
+ host_arg = ""
+ if config.flask_host is not None:
+ host_arg = f"--host {config.flask_host}"
+
+ port_arg = ""
+ if config.flask_port is not None:
+ port_arg = f"--port {config.flask_port}"
+
+ debug_arg = "--debug" if config.flask_mode == "debug" else ""
+
+ exclude_patterns: list[str] = list(FLASK_BASE_EXCLUDE_PATTERNS)
+ if config.flask_exclude_patterns is not None:
+ exclude_patterns.extend(config.flask_exclude_patterns)
+
+ exclude_patterns_arg = (
+ f"--exclude-patterns {';'.join(exclude_patterns)}"
+ )
+
+ cmd = f"\
+ flask run {host_arg} {port_arg} {debug_arg} {exclude_patterns_arg}"
+
+ process = subprocess.Popen(
+ shlex.split(cmd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT
+ )
+
+ return loop.run_in_executor(pool, handle_flask_output, process)
+
+ loop = asyncio.get_running_loop()
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
+ maybe_future_like = (
+ live_reload_coroutine(),
+ tailwind_cli_executor(loop, pool),
+ flask_server_executor(loop, pool),
+ )
+
+ futures = (
+ future for future in maybe_future_like if future is not None
+ )
+
+ pkgprint("Starting dev server...")
+
+ _ = await asyncio.gather(*futures, return_exceptions=True)
+
+
+def dev(cli_args: argparse.Namespace) -> int:
+ project_config = Config.try_from_pyproject_toml()
+ if project_config is None:
+ pkgprint(
+ "Project config not found. Dev server not started.",
+ )
+ pkgprint(
+ "Try checking your current working directory or running 'flask-livetw init' to configure the project."
+ )
+ return 1
+
+ no_live_reload = cli_args.no_live_reload
+ live_reload_host = (
+ cli_args.live_reload_host or project_config.live_reload_host
+ )
+ live_reload_port = (
+ cli_args.live_reload_port or project_config.live_reload_port
+ )
+
+ no_flask = cli_args.no_flask
+ flask_host = cli_args.flask_host or project_config.flask_host
+ flask_port = cli_args.flask_port or project_config.flask_port
+ flask_mode = cli_args.flask_mode
+ flask_exclude_patterns = (
+ cli_args.flask_exclude_patterns
+ or project_config.flask_exclude_patterns
+ )
+
+ no_tailwind = cli_args.no_tailwind
+ tailwind_input = (
+ cli_args.tailwind_input or project_config.full_globalcss_file
+ )
+ tailwind_output = (
+ cli_args.tailwind_output or project_config.full_tailwind_file
+ )
+ tailwind_minify = cli_args.tailwind_minify
+
+ dev_config = DevConfig(
+ no_live_reload=no_live_reload,
+ live_reload_host=live_reload_host,
+ live_reload_port=live_reload_port,
+ no_flask=no_flask,
+ flask_host=flask_host,
+ flask_port=flask_port,
+ flask_mode=flask_mode,
+ flask_exclude_patterns=flask_exclude_patterns,
+ no_tailwind=no_tailwind,
+ tailwind_input=tailwind_input,
+ tailwind_output=tailwind_output,
+ tailwind_minify=tailwind_minify,
+ )
+
+ asyncio.run(dev_server(dev_config))
+ return 0
+
+
+def add_command_args(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "--no-live-reload",
+ dest="no_live_reload",
+ action="store_true",
+ default=False,
+ help="Disable live reload server.",
+ )
+ parser.add_argument(
+ "-lrh",
+ "--live-reload-host",
+ dest="live_reload_host",
+ type=str,
+ help="Hostname for live reload server.",
+ )
+ parser.add_argument(
+ "-lrp",
+ "--live-reload-port",
+ dest="live_reload_port",
+ type=int,
+ help="Port for live reload server.",
+ )
+
+ parser.add_argument(
+ "--no-flask",
+ dest="no_flask",
+ action="store_true",
+ default=False,
+ help="Disable flask server.",
+ )
+ parser.add_argument(
+ "-fh",
+ "--flask-host",
+ dest="flask_host",
+ type=str,
+ help="Hostname for flask server.",
+ )
+ parser.add_argument(
+ "-fp",
+ "--flask-port",
+ dest="flask_port",
+ type=int,
+ help="Port for flask server.",
+ )
+ parser.add_argument(
+ "-fm",
+ "--flask-mode",
+ dest="flask_mode",
+ choices=("debug", "no-debug"),
+ default="debug",
+ help="If debug mode is enabled, the flask server will be started with --debug flag. Default: debug.",
+ )
+ parser.add_argument(
+ "--flask-exclude-patterns",
+ dest="flask_exclude_patterns",
+ type=str,
+ nargs="+",
+ help="File exclude patterns for flask server. Base: */**/dev.py",
+ )
+
+ parser.add_argument(
+ "--no-tailwind",
+ dest="no_tailwind",
+ action="store_true",
+ default=False,
+ help="Disable tailwindcss generation. If tailwindcss is disabled the live reload server will not be started.",
+ )
+ parser.add_argument(
+ "-ti",
+ "--tailwind-input",
+ dest="tailwind_input",
+ type=str,
+ help="Input path for global css file. Includes glob patterns.",
+ )
+ parser.add_argument(
+ "-to",
+ "--tailwind-output",
+ dest="tailwind_output",
+ type=str,
+ help="Output path for the generated css file.",
+ )
+ parser.add_argument(
+ "-tm",
+ "--tailwind-minify",
+ dest="tailwind_minify",
+ action="store_true",
+ default=False,
+ help="Enables minification of the generated css file.",
+ )
+
+
+def add_command(
+ subparser: argparse._SubParsersAction[argparse.ArgumentParser],
+) -> None:
+ parser = subparser.add_parser(
+ name="dev",
+ description="""
+ Extended dev mode for flask apps.
+ By default runs the flask app in debug mode,
+ tailwindcss in watch mode and live reload server.
+ """,
+ help="Run a development server.",
+ allow_abbrev=True,
+ formatter_class=argparse.MetavarTypeHelpFormatter,
+ )
+
+ add_command_args(parser)
+
+
+def main(args: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="""
+ Extended dev mode for flask apps.
+ By default runs the flask app in debug mode,
+ tailwindcss in watch mode and live reload server.
+ """,
+ allow_abbrev=True,
+ formatter_class=argparse.MetavarTypeHelpFormatter,
+ )
+
+ add_command_args(parser)
+
+ parsed_args = parser.parse_args(args)
+
+ return dev(parsed_args)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/flask_livetw/initialize.py b/flask_livetw/initialize.py
new file mode 100644
index 0000000..fd6a27d
--- /dev/null
+++ b/flask_livetw/initialize.py
@@ -0,0 +1,318 @@
+from __future__ import annotations
+
+import argparse
+import os
+import re
+from typing import Sequence
+
+from flask_livetw.config import (
+ Config,
+ ask_project_layout,
+ update_pyproject_toml,
+)
+from flask_livetw.util import Term, load_resource, pkgprint
+
+LIVE_RELOAD_SCRIPT = load_resource("live_reload.js")
+TAILWIND_CONFIG = load_resource("tailwind.config.js")
+GLOBAL_CSS = load_resource("global.css")
+LAYOUT_TEMPLATE = load_resource("layout.html")
+
+
+def generate_tailwind_config(content_glob: str) -> str:
+ return TAILWIND_CONFIG.content.replace(
+ "{content_glob_placeholder}", content_glob
+ )
+
+
+def add_content_glob(config: str, content_glob: str) -> str | None:
+ content_glob_re = re.compile(r"content:\s*\[([^\]]*)\]")
+ re_match = content_glob_re.search(config)
+ if re_match is None:
+ return None
+
+ existing_globs = re_match.group(1)
+ content_start, content_end = re_match.span(1)
+ prev = config[:content_start]
+ next = config[content_end:]
+ if existing_globs.strip() == "":
+ new_globs = f"'{content_glob}',"
+ else:
+ no_new_line = existing_globs.lstrip("\n")
+ space = " " * (len(no_new_line) - len(no_new_line.lstrip()))
+ existing_globs = no_new_line.rstrip(", \t\n")
+ if space == "":
+ new_globs = f"\n {existing_globs},\n '{content_glob}',\n "
+ else:
+ new_globs = f"\n{existing_globs},\n{space}'{content_glob}',\n "
+
+ config = f"{prev}{new_globs}{next}"
+ config = config.rstrip() + "\n"
+ return config
+
+
+def generate_live_reload_template(
+ live_reload_file: str, tailwind_file: str, tailwind_min_file: str
+) -> str:
+ return (
+ """
+ {% if config.DEBUG %}
+
+
+ {% else %}
+
+ {% endif %}
+"""
+ ).strip("\n")
+
+
+def generate_layout_template(
+ live_reload_file: str, tailwind_file: str, tailwind_min_file: str
+) -> str:
+ return LAYOUT_TEMPLATE.content.replace(
+ "{live_reload_template_placeholder}",
+ generate_live_reload_template(
+ live_reload_file, tailwind_file, tailwind_min_file
+ ),
+ )
+
+
+def configure_tailwind(content_glob: str) -> int:
+ Term.blank()
+ pkgprint("Configuring tailwindcss...")
+
+ if os.path.exists(TAILWIND_CONFIG.name):
+ Term.info("Detected existing configuration file")
+ Term.info("Updating tailwindcss configuration file...")
+
+ with open(TAILWIND_CONFIG.name, "r") as f:
+ existing_config = f.read()
+
+ config = add_content_glob(existing_config, content_glob)
+ if config is None:
+ Term.info("No content config found in existing tailwind.config.js")
+ Term.info(f"Manually add '{content_glob}' to your content config.")
+ return -1
+
+ with open(TAILWIND_CONFIG.name, "w") as f:
+ f.write(config)
+
+ pkgprint("Tailwindcss configured")
+ return 0
+
+ config = generate_tailwind_config(content_glob)
+
+ with open(TAILWIND_CONFIG.name, "w") as f:
+ f.write(config)
+
+ pkgprint("Tailwindcss configured")
+ return 0
+
+
+def generate_files(
+ live_reload_file: str,
+ globalcss_file: str,
+) -> None:
+ Term.blank()
+ pkgprint("Generating files...")
+
+ try:
+ with open(live_reload_file, "w") as f:
+ f.write(LIVE_RELOAD_SCRIPT.content)
+ except FileNotFoundError:
+ os.makedirs(os.path.dirname(live_reload_file), exist_ok=True)
+ with open(live_reload_file, "w") as f:
+ f.write(LIVE_RELOAD_SCRIPT.content)
+
+ try:
+ with open(globalcss_file, "w") as f:
+ f.write(GLOBAL_CSS.content)
+ except FileNotFoundError:
+ os.makedirs(os.path.dirname(globalcss_file), exist_ok=True)
+ with open(globalcss_file, "w") as f:
+ f.write(GLOBAL_CSS.content)
+
+ pkgprint("Files generated")
+
+
+def update_layout(
+ root_layout_file: str,
+ live_reload_file: str,
+ tailwind_file: str,
+ tailwind_min_file: str,
+) -> int:
+ Term.blank()
+ pkgprint("Updating layout...")
+
+ try:
+ with open(root_layout_file, "+r") as f:
+ layout = f.read()
+ if "" not in layout:
+ Term.error(
+ "Root layour is malformed, the tag is missing. \
+ Please check your root layout file."
+ )
+ return 1
+
+ layout = layout.replace(
+ "",
+ generate_live_reload_template(
+ live_reload_file, tailwind_file, tailwind_min_file
+ )
+ + "\n",
+ )
+ f.seek(0)
+ f.write(layout)
+ f.truncate()
+
+ pkgprint("Root layout file updated")
+ return 0
+ except FileNotFoundError as e:
+ Term.warn(e)
+ os.makedirs(os.path.dirname(root_layout_file), exist_ok=True)
+ with open(root_layout_file, "w") as f:
+ f.write(
+ generate_layout_template(
+ live_reload_file, tailwind_file, tailwind_min_file
+ )
+ )
+
+ pkgprint("Root layout file created")
+ return 0
+
+
+def update_gitignore(static_folder: str, tailwind_file: str) -> None:
+ Term.blank()
+ pkgprint("Updating .gitignore...")
+
+ content = f"""
+# flask-livetw
+{static_folder}/{tailwind_file}
+"""
+ try:
+ with open(".gitignore", "a") as f:
+ f.write(content)
+ except FileNotFoundError:
+ Term.info("Missing .gitignore file, creating one...")
+ with open(".gitignore", "w") as f:
+ f.write(content)
+
+ pkgprint(".gitignore updated")
+
+
+def initialize(config: Config, gitignore: bool) -> int:
+ Term.blank()
+ pkgprint("Initializing flask-livetw 🚀...")
+
+ Term.blank()
+ pkgprint("Updating pyproject.toml...")
+ code = update_pyproject_toml(config)
+ if code != 0:
+ return code
+
+ tailwind_code = configure_tailwind(config.full_templates_glob)
+ if tailwind_code > 0:
+ return tailwind_code
+
+ generate_files(
+ config.full_live_reload_file,
+ config.full_globalcss_file,
+ )
+
+ code = update_layout(
+ config.full_root_layout_file,
+ config.live_reload_file,
+ config.tailwind_file,
+ config.tailwind_minified_file,
+ )
+ if code != 0:
+ return code
+
+ if gitignore:
+ update_gitignore(config.full_static_folder, config.tailwind_file)
+
+ Term.blank()
+
+ if tailwind_code == 0:
+ pkgprint("Initialization completed ✅")
+ return 0
+
+ pkgprint("Initialization almost completed")
+ pkgprint(
+ "Remember to add the content glob to your tailwind.config.js manually"
+ )
+ pkgprint(f"Glob: '{config.full_templates_glob}'")
+
+ return 0
+
+
+def init(cli: argparse.Namespace) -> int:
+ # project_config = Config.from_pyproject_toml()
+
+ if cli.default:
+ init_config = Config.default()
+ else:
+ pkgprint("Describe your project layout:")
+ init_config = ask_project_layout()
+
+ return initialize(init_config, gitignore=cli.gitignore)
+
+
+def add_command_args(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "-D",
+ "--default",
+ dest="default",
+ action="store_true",
+ default=False,
+ help="use default values for all options",
+ )
+
+ parser.add_argument(
+ "--gi",
+ "--gitignore",
+ dest="gitignore",
+ action="store_true",
+ default=False,
+ help="update .gitignore to exclude dev related files",
+ )
+
+
+def add_command(
+ subparser: argparse._SubParsersAction[argparse.ArgumentParser],
+) -> None:
+ parser = subparser.add_parser(
+ name="init",
+ description="""
+ Initialize flask-livetw for the project.
+ Adds the configuration to pyproject.toml and creates the necessary files.
+ """,
+ help="Initialize flask-livetw for the project.",
+ allow_abbrev=True,
+ formatter_class=argparse.MetavarTypeHelpFormatter,
+ )
+
+ add_command_args(parser)
+
+
+def main(args: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="Initialize flask-livetw in the current working directory.",
+ allow_abbrev=True,
+ formatter_class=argparse.MetavarTypeHelpFormatter,
+ )
+
+ add_command_args(parser)
+
+ parsed_args = parser.parse_args(args)
+
+ return init(parsed_args)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/flask_livetw/local_install.py b/flask_livetw/local_install.py
new file mode 100644
index 0000000..8e532b1
--- /dev/null
+++ b/flask_livetw/local_install.py
@@ -0,0 +1,387 @@
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import shlex
+import subprocess
+from typing import Sequence
+
+from flask_livetw.config import Config, ask_project_layout
+from flask_livetw.util import PKG_PP, Term, load_resource, pkgprint
+
+DEV_DEPENDENCIES = "pytailwindcss python-dotenv websockets"
+
+
+LIVE_RELOAD_SCRIPT = load_resource("live_reload.js")
+DEV_SCRIPT = load_resource("dev.py")
+TAILWIND_CONFIG = load_resource("tailwind.config.js")
+GLOBAL_CSS = load_resource("global.css")
+LAYOUT_TEMPLATE = load_resource("layout.html")
+
+
+def generate_tw_config(content_glob: str) -> str:
+ return TAILWIND_CONFIG.content.replace(
+ "{content_glob_placeholder}", content_glob
+ )
+
+
+def generate_dev_script(
+ globalcss_file: str, twcss_file: str, minified_twcss_file: str
+) -> str:
+ return (
+ DEV_SCRIPT.content.replace(
+ "{tailwind_input_placeholder}", globalcss_file
+ )
+ .replace("{tailwind_output_placeholder}", twcss_file)
+ .replace("{minified_tailwind_output_placeholder}", minified_twcss_file)
+ )
+
+
+def generate_live_reload_template(
+ live_reload_file: str, twcss_file: str, minified_twcss_file: str
+) -> str:
+ return (
+ """
+ {% if config.DEBUG %}
+
+
+ {% else %}
+
+ {% endif %}
+"""
+ ).strip("\n")
+
+
+def generate_layout_template(
+ live_reload_file: str, twcss_file: str, minified_twcss_file: str
+) -> str:
+ return LAYOUT_TEMPLATE.content.replace(
+ "{live_reload_template_placeholder}",
+ generate_live_reload_template(
+ live_reload_file, twcss_file, minified_twcss_file
+ ),
+ )
+
+
+def install_dev_dependencies() -> int:
+ Term.blank()
+ pkgprint("Installing required dev dependencies...")
+
+ poetry_cmd = shlex.split(f"poetry add --group=dev {DEV_DEPENDENCIES}")
+
+ try:
+ _ = subprocess.run(poetry_cmd, shell=True, check=True)
+ except subprocess.CalledProcessError as e:
+ Term.error(e)
+ Term.info(
+ "Dev dependencies installation failed, please install them manually" # noqa: E501
+ )
+ return -1
+
+ pkgprint("Dev dependencies installed")
+ return 0
+
+
+def configure_tailwind(content_glob: str) -> int:
+ Term.blank()
+ pkgprint("Configuring tailwindcss...")
+
+ if os.path.exists(TAILWIND_CONFIG.name):
+ Term.info("Detected existing configuration file")
+ Term.info("Updating tailwindcss configuration file...")
+
+ with open(TAILWIND_CONFIG.name, "r+t") as f:
+ config = f.read()
+ content_glob_re = re.compile(r"content:\s*\[([^\]]*)\]")
+ re_match = content_glob_re.search(config)
+ if re_match is None:
+ Term.info(
+ "No content config found in existing tailwind.config.js"
+ )
+ Term.info("Add the following glob to the content config:")
+ Term.info(f"'{content_glob}',")
+ return -1
+
+ existing_globs = re_match.group(1)
+ content_start, content_end = re_match.span(1)
+ prev = config[:content_start]
+ next = config[content_end:]
+ if existing_globs.strip() == "":
+ new_globs = f"'{content_glob}',"
+ else:
+ no_new_line = existing_globs.lstrip("\n")
+ space = " " * (len(no_new_line) - len(no_new_line.lstrip()))
+ existing_globs = no_new_line.rstrip(", \t\n")
+ if space == "":
+ new_globs = (
+ f"\n {existing_globs},\n '{content_glob}',\n "
+ )
+ else:
+ new_globs = (
+ f"\n{existing_globs},\n{space}'{content_glob}',\n "
+ )
+
+ config = f"{prev}{new_globs}{next}"
+ config = config.rstrip() + "\n"
+ f.seek(0)
+ f.write(config)
+ f.truncate()
+
+ Term.info("Tailwindcss configuration file updated")
+ pkgprint("Tailwindcss configured")
+ return 0
+
+ with open(TAILWIND_CONFIG.name, "w") as f:
+ f.write(generate_tw_config(content_glob))
+
+ Term.info("Tailwindcss configuration file created")
+ pkgprint("Tailwindcss configuration complete")
+ return 0
+
+
+def generate_files(
+ live_reload_file: str,
+ globalcss_file: str,
+ twcss_file: str,
+ minified_twcss_file: str,
+) -> None:
+ with open(DEV_SCRIPT.name, "w") as f:
+ f.write(
+ generate_dev_script(
+ globalcss_file, twcss_file, minified_twcss_file
+ )
+ )
+
+ try:
+ with open(live_reload_file, "w") as f:
+ f.write(LIVE_RELOAD_SCRIPT.content)
+ except FileNotFoundError:
+ os.makedirs(os.path.dirname(live_reload_file), exist_ok=True)
+ with open(live_reload_file, "w") as f:
+ f.write(LIVE_RELOAD_SCRIPT.content)
+
+ with open(globalcss_file, "w") as f:
+ f.write(GLOBAL_CSS.content)
+
+
+def update_layout(
+ root_layout_file: str,
+ live_reload_file: str,
+ twcss_file: str,
+ minified_twcss_file: str,
+) -> int:
+ try:
+ with open(root_layout_file, "+r") as f:
+ layout = f.read()
+ if "" not in layout:
+ Term.error(
+ "Root layour is malformed, the tag is missing. \
+ Please check your root layout file."
+ )
+ return 1
+
+ layout = layout.replace(
+ "",
+ generate_live_reload_template(
+ live_reload_file, twcss_file, minified_twcss_file
+ )
+ + "\n",
+ )
+ f.seek(0)
+ f.write(layout)
+ f.truncate()
+ return 0
+ except FileNotFoundError as e:
+ Term.warn(e)
+ os.makedirs(os.path.dirname(root_layout_file), exist_ok=True)
+ with open(root_layout_file, "w") as f:
+ f.write(
+ generate_layout_template(
+ live_reload_file, twcss_file, minified_twcss_file
+ )
+ )
+
+ return 0
+
+
+def update_gitignore(static_folder: str, twcss_file: str) -> None:
+ content = f"""
+# flask-livetw
+{static_folder}/{twcss_file}
+"""
+ try:
+ with open(".gitignore", "a") as f:
+ f.write(content)
+ except FileNotFoundError:
+ Term.info("Missing .gitignore file, creating one...")
+ with open(".gitignore", "w") as f:
+ f.write(content)
+
+
+def check_requirements() -> int:
+ pkgprint("Checking requirements...")
+ cwd = os.getcwd()
+ pkgprint(f"Current working directory: {Term.C}{cwd}{Term.END}")
+ continue_install = Term.confirm(f"{PKG_PP} Is this your project root?")
+
+ if not continue_install:
+ pkgprint("Change cwd and start again. Modding canceled")
+ return 1
+
+ python_cmd = shlex.split("python --version")
+ python_cmd_result = subprocess.run(
+ python_cmd, shell=True, capture_output=True
+ )
+
+ if python_cmd_result.returncode != 0:
+ Term.error("python --version failed, is python installed?")
+ return python_cmd_result.returncode
+
+ version = python_cmd_result.stdout.decode("utf-8").strip()
+ if version != "Python 3.8.10":
+ pkgprint(f"python --version: {Term.C}{version}{Term.END}")
+
+ continue_install = Term.confirm(
+ f"{PKG_PP} Continue with this version?"
+ )
+ if not continue_install:
+ pkgprint("Change python version and start again. Modding canceled")
+ return 1
+
+ return 0
+
+
+def local_install(args: argparse.Namespace) -> int:
+ if not args.all_yes:
+ code = check_requirements()
+ if code != 0:
+ return code
+
+ config = Config.try_from_pyproject_toml()
+ if config is None:
+ config = ask_project_layout()
+
+ Term.blank()
+ pkgprint("Installing flask-livetw as local script 🚀...")
+
+ dependancies_code = install_dev_dependencies()
+ if dependancies_code > 0:
+ return dependancies_code
+
+ tailwind_code = configure_tailwind(config.full_templates_glob)
+ if tailwind_code > 0:
+ return tailwind_code
+
+ generate_files(
+ config.full_live_reload_file,
+ config.full_globalcss_file,
+ config.full_tailwind_file,
+ config.full_tailwind_minified_file,
+ )
+
+ code = update_layout(
+ config.full_root_layout_file,
+ config.live_reload_file,
+ config.full_tailwind_file,
+ config.full_tailwind_minified_file,
+ )
+ if code != 0:
+ return code
+
+ if args.gitignore:
+ update_gitignore(config.full_static_folder, config.tailwind_file)
+
+ Term.blank()
+
+ if dependancies_code == 0 and tailwind_code == 0:
+ pkgprint("Local install completed 🎉")
+ return 0
+
+ pkgprint("Local install almost completed")
+
+ if dependancies_code != 0:
+ pkgprint("Remember to install the missing dev dependencies manually")
+ pkgprint(f"Dependancies: {DEV_DEPENDENCIES}")
+
+ if tailwind_code != 0:
+ pkgprint(
+ "Remember to add the content glob to your tailwind.config.js manually"
+ )
+ pkgprint(f"Glob: '{config.full_templates_glob}'")
+
+ return 0
+
+
+def add_command_args(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "-Y",
+ "--yes",
+ dest="all_yes",
+ action="store_true",
+ default=False,
+ help="answer yes to all requirements checks",
+ )
+
+ parser.add_argument(
+ "-D",
+ "--default",
+ dest="default",
+ action="store_true",
+ default=False,
+ help="use default values for all options",
+ )
+
+ parser.add_argument(
+ "--gi",
+ "--gitignore",
+ dest="gitignore",
+ action="store_true",
+ default=False,
+ help="update .gitignore to exclude dev related files",
+ )
+
+
+def add_command(
+ subparser: argparse._SubParsersAction[argparse.ArgumentParser],
+) -> None:
+ parser = subparser.add_parser(
+ name="local-install",
+ description="""
+ Install flask-livetw as a local script
+ (adds dev dependencies, configures tailwindcss,
+ adds dev script and updates root layout file).
+ """,
+ help="Install flask-livetw as a local script.",
+ allow_abbrev=True,
+ formatter_class=argparse.MetavarTypeHelpFormatter,
+ )
+
+ add_command_args(parser)
+
+
+def main(args: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="""
+ Install flask-livetw as a local script
+ (adds dev dependencies, configures tailwindcss,
+ adds dev script and updates root layout file).
+ """,
+ allow_abbrev=True,
+ formatter_class=argparse.MetavarTypeHelpFormatter,
+ )
+ add_command_args(parser)
+
+ parsed_args = parser.parse_args(args)
+
+ return local_install(parsed_args)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/flask_livetw/main.py b/flask_livetw/main.py
index dae9ce4..b6c1066 100644
--- a/flask_livetw/main.py
+++ b/flask_livetw/main.py
@@ -1,289 +1,48 @@
-#!/usr/bin/env python
+from __future__ import annotations
-import os
-import re
-import shlex
-import subprocess
+import argparse
+from typing import Sequence
-from flask_livetw.cli import (
- check_requirements,
- create_cli,
- get_config,
- pkgprint,
-)
-from flask_livetw.util import Term, load_resource
+from flask_livetw import build_app, dev_server, initialize, local_install
-DEV_DEPENDENCIES = "pytailwindcss python-dotenv websockets"
-
-LIVE_RELOAD_SCRIPT = load_resource("live_reload.js")
-DEV_SCRIPT = load_resource("dev.py")
-TAILWIND_CONFIG = load_resource("tailwind.config.js")
-GLOBAL_CSS = load_resource("global.css")
-LAYOUT_TEMPLATE = load_resource("layout.html")
-
-
-def generate_tw_config(content_glob: str) -> str:
- return TAILWIND_CONFIG.content.replace(
- "{content_glob_placeholder}", content_glob
+def create_cli() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="flask-livetw",
+ description="CLI for flask-livetw commands.",
+ allow_abbrev=True,
)
-
-
-def generate_dev_script(
- globalcss_file: str, twcss_file: str, minified_twcss_file: str
-) -> str:
- return (
- DEV_SCRIPT.content.replace(
- "{tailwind_input_placeholder}", globalcss_file
- )
- .replace("{tailwind_output_placeholder}", twcss_file)
- .replace("{minified_tailwind_output_placeholder}", minified_twcss_file)
+ subparsers = parser.add_subparsers(
+ title="commands",
+ dest="command",
+ required=True,
)
+ dev_server.add_command(subparsers)
-def generate_live_reload_template(
- live_reload_file: str, twcss_file: str, minified_twcss_file: str
-) -> str:
- return (
- """
- {% if config.DEBUG %}
-
-
- {% else %}
-
- {% endif %}
-"""
- ).strip("\n")
-
-
-def generate_layout_template(
- live_reload_file: str, twcss_file: str, minified_twcss_file: str
-) -> str:
- return LAYOUT_TEMPLATE.content.replace(
- "{live_reload_template_placeholder}",
- generate_live_reload_template(
- live_reload_file, twcss_file, minified_twcss_file
- ),
- )
-
-
-def install_dev_dependencies() -> int:
- Term.blank()
- pkgprint("Installing required dev dependencies...")
-
- poetry_cmd = shlex.split(f"poetry add --group=dev {DEV_DEPENDENCIES}")
+ build_app.add_command(subparsers)
- try:
- _ = subprocess.run(poetry_cmd, shell=True, check=True)
- except subprocess.CalledProcessError as e:
- Term.error(e)
- Term.info(
- "Dev dependencies installation failed, please install them manually" # noqa: E501
- )
- return -1
+ initialize.add_command(subparsers)
- pkgprint("Dev dependencies installed")
- return 0
-
-
-def configure_tailwind(content_glob: str) -> int:
- Term.blank()
- pkgprint("Configuring tailwindcss...")
-
- if os.path.exists(TAILWIND_CONFIG.name):
- Term.info("Detected existing configuration file")
- Term.info("Updating tailwindcss configuration file...")
-
- with open(TAILWIND_CONFIG.name, "r+t") as f:
- config = f.read()
- content_glob_re = re.compile(r"content:\s*\[([^\]]*)\]")
- re_match = content_glob_re.search(config)
- if re_match is None:
- Term.info(
- "No content config found in existing tailwind.config.js"
- )
- Term.info("Add the following glob to the content config:")
- Term.info(f"'{content_glob}',")
- return -1
-
- existing_globs = re_match.group(1)
- content_start, content_end = re_match.span(1)
- prev = config[:content_start]
- next = config[content_end:]
- if existing_globs.strip() == "":
- new_globs = f"'{content_glob}',"
- else:
- no_new_line = existing_globs.lstrip("\n")
- space = " " * (len(no_new_line) - len(no_new_line.lstrip()))
- existing_globs = no_new_line.rstrip(", \t\n")
- if space == "":
- new_globs = (
- f"\n {existing_globs},\n '{content_glob}',\n "
- )
- else:
- new_globs = (
- f"\n{existing_globs},\n{space}'{content_glob}',\n "
- )
-
- config = f"{prev}{new_globs}{next}"
- config = config.rstrip() + "\n"
- f.seek(0)
- f.write(config)
- f.truncate()
-
- Term.info("Tailwindcss configuration file updated")
- pkgprint("Tailwindcss configured")
- return 0
-
- with open(TAILWIND_CONFIG.name, "w") as f:
- f.write(generate_tw_config(content_glob))
-
- Term.info("Tailwindcss configuration file created")
- pkgprint("Tailwindcss configuration complete")
- return 0
+ local_install.add_command(subparsers)
+ return parser
-def generate_files(
- live_reload_file: str,
- globalcss_file: str,
- twcss_file: str,
- minified_twcss_file: str,
-) -> None:
- with open(DEV_SCRIPT.name, "w") as f:
- f.write(
- generate_dev_script(
- globalcss_file, twcss_file, minified_twcss_file
- )
- )
-
- try:
- with open(live_reload_file, "w") as f:
- f.write(LIVE_RELOAD_SCRIPT.content)
- except FileNotFoundError:
- os.makedirs(os.path.dirname(live_reload_file), exist_ok=True)
- with open(live_reload_file, "w") as f:
- f.write(LIVE_RELOAD_SCRIPT.content)
-
- with open(globalcss_file, "w") as f:
- f.write(GLOBAL_CSS.content)
-
-
-def update_layout(
- root_layout_file: str,
- live_reload_file: str,
- twcss_file: str,
- minified_twcss_file: str,
-) -> int:
- try:
- with open(root_layout_file, "+r") as f:
- layout = f.read()
- if "" not in layout:
- Term.error(
- "Root layour is malformed, the tag is missing. \
- Please check your root layout file."
- )
- return 1
-
- layout = layout.replace(
- "",
- generate_live_reload_template(
- live_reload_file, twcss_file, minified_twcss_file
- )
- + "\n",
- )
- f.seek(0)
- f.write(layout)
- f.truncate()
- return 0
- except FileNotFoundError as e:
- Term.warn(e)
- os.makedirs(os.path.dirname(root_layout_file), exist_ok=True)
- with open(root_layout_file, "w") as f:
- f.write(
- generate_layout_template(
- live_reload_file, twcss_file, minified_twcss_file
- )
- )
-
- return 0
-
-
-def update_gitignore(static_folder: str, twcss_file: str) -> None:
- content = f"""
-# flask-livetw
-{static_folder}/{twcss_file}
-"""
- try:
- with open(".gitignore", "a") as f:
- f.write(content)
- except FileNotFoundError:
- Term.info("Missing .gitignore file, creating one...")
- with open(".gitignore", "w") as f:
- f.write(content)
-
-
-def main() -> int:
- cli_args = create_cli().parse_args()
-
- if not cli_args.all_yes:
- code = check_requirements()
- if code != 0:
- return code
-
- config = get_config(cli_args)
-
- pkgprint("Modding your project... 🚀")
-
- dependancies_code = install_dev_dependencies()
- if dependancies_code > 0:
- return dependancies_code
-
- tailwind_code = configure_tailwind(config.full_templates_glob)
- if tailwind_code > 0:
- return tailwind_code
-
- generate_files(
- config.full_live_reload_file,
- config.full_globalcss_file,
- config.full_twcss_file,
- config.full_minified_twcss_file,
- )
-
- code = update_layout(
- config.full_root_layout_file,
- config.live_reload_file,
- config.twcss_file,
- config.minified_twcss_file,
- )
- if code != 0:
- return code
-
- if config.gitignore:
- update_gitignore(config.full_static_folder, config.twcss_file)
- Term.blank()
+def main(args: Sequence[str] | None = None) -> int:
+ parsed_args = create_cli().parse_args(args)
- if dependancies_code == 0 and tailwind_code == 0:
- pkgprint("Modding complete ✅")
- return 0
+ if parsed_args.command == "dev":
+ return dev_server.dev(parsed_args)
- pkgprint("Modding almost completed")
+ if parsed_args.command == "build":
+ return build_app.build(parsed_args)
- if dependancies_code != 0:
- pkgprint("Remember to install the missing dev dependencies manually")
- pkgprint(f"Dependancies: {DEV_DEPENDENCIES}")
+ if parsed_args.command == "init":
+ return initialize.init(parsed_args)
- if tailwind_code != 0:
- pkgprint(
- "Remember to add the content glob to your tailwind.config.js manually"
- )
- pkgprint(f"Glob: '{config.full_templates_glob}'")
+ if parsed_args.command == "local-install":
+ return local_install.local_install(parsed_args)
return 0
diff --git a/flask_livetw/static/dev.py b/flask_livetw/resources/dev.py
similarity index 99%
rename from flask_livetw/static/dev.py
rename to flask_livetw/resources/dev.py
index 6e8b8fa..e2d6fe2 100644
--- a/flask_livetw/static/dev.py
+++ b/flask_livetw/resources/dev.py
@@ -1,4 +1,5 @@
#!/usr/bin/env python
+from __future__ import annotations
import argparse
import asyncio
diff --git a/flask_livetw/static/global.css b/flask_livetw/resources/global.css
similarity index 100%
rename from flask_livetw/static/global.css
rename to flask_livetw/resources/global.css
diff --git a/flask_livetw/static/layout.html b/flask_livetw/resources/layout.html
similarity index 100%
rename from flask_livetw/static/layout.html
rename to flask_livetw/resources/layout.html
diff --git a/flask_livetw/static/live_reload.js b/flask_livetw/resources/live_reload.js
similarity index 100%
rename from flask_livetw/static/live_reload.js
rename to flask_livetw/resources/live_reload.js
diff --git a/flask_livetw/static/tailwind.config.js b/flask_livetw/resources/tailwind.config.js
similarity index 100%
rename from flask_livetw/static/tailwind.config.js
rename to flask_livetw/resources/tailwind.config.js
diff --git a/flask_livetw/util.py b/flask_livetw/util.py
index 5a7b8e1..a30d7bf 100644
--- a/flask_livetw/util.py
+++ b/flask_livetw/util.py
@@ -1,10 +1,14 @@
+from __future__ import annotations
+
import dataclasses
import os
import platform
from typing import Callable, Union
+PKG_PPN = "livetw"
+
STATIC_PATH = os.path.join(
- os.path.dirname(os.path.realpath(__file__)), "static"
+ os.path.dirname(os.path.realpath(__file__)), "resources"
)
@@ -122,3 +126,10 @@ def ask_dir(
Term.error(f"'{full_path}' is not a dir")
dir = input(message).strip()
+
+
+PKG_PP = f"{Term.M}[{PKG_PPN}]{Term.END}"
+
+
+def pkgprint(*values: object, end: str = "\n", sep: str = " ") -> None:
+ print(f"{PKG_PP}", *values, end=end, sep=sep)
diff --git a/poetry.lock b/poetry.lock
deleted file mode 100644
index 74f4afe..0000000
--- a/poetry.lock
+++ /dev/null
@@ -1,7 +0,0 @@
-# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
-package = []
-
-[metadata]
-lock-version = "2.0"
-python-versions = "^3.8.10"
-content-hash = "14b1c8005ea3066a881421bbb7ded56abea61e98ad9228d3340cd85898765255"
diff --git a/pyproject.toml b/pyproject.toml
index e03b9e2..8d293f2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[tool.poetry]
name = "flask-livetw"
-version = "0.2.0"
-description = "A simple package that enables live tailwindcss reloading for a template based app in flask"
+version = "0.3.0"
+description = "A simple package that enables livereload and tailwindcss for flask templates."
license = "MIT"
authors = ["J-Josu "]
readme = "README.md"
@@ -25,8 +25,26 @@ flask-livetw = "flask_livetw.main:main"
[tool.poetry.dependencies]
python = "^3.8.10"
+pytailwindcss = "^0.2.0"
+websockets = "^11.0.3"
+tomli = "^2.0.1"
+
+
+[tool.poetry.group.dev.dependencies]
+pre-commit = "^3.4.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
+
+
+[tool.black]
+line-length = 79
+
+[tool.isort]
+profile = "black"
+line_length = 79
+
+[tool.pyright]
+reportPrivateUsage = false