diff --git a/.flake8 b/.flake8 index 46d4260..ee5a677 100644 --- a/.flake8 +++ b/.flake8 @@ -1,7 +1,9 @@ [flake8] select = ANN,B,B9,BLK,C,D,DAR,E,F,I,S,W ignore = E203,W503,ANN101,ANN102,S322 -per-file-ignores = src/replit/__init__.py:F401 +per-file-ignores = + src/replit/__init__.py:F401 + src/replit/maqpi/__init__.py:F401 max-line-length = 88 application-import-names = vidgen,tests import-order-style = google diff --git a/examples/maqpi/manual_auth.py b/examples/maqpi/manual_auth.py new file mode 100644 index 0000000..2cdaecf --- /dev/null +++ b/examples/maqpi/manual_auth.py @@ -0,0 +1,16 @@ +"""A basic example of repl auth.""" +import simple + +app = simple.App("app") + + +@app.route("/") +def index(): + if simple.signed_in: + return "Hello " + simple.auth.name + else: + return simple.signin() # optionally: simple.sigin(title="My title") + + +if __name__ == "__main__": + app.run() diff --git a/poetry.lock b/poetry.lock index 87282bc..81ad6ea 100644 --- a/poetry.lock +++ b/poetry.lock @@ -91,7 +91,7 @@ python-versions = "*" version = "3.0.4" [[package]] -category = "dev" +category = "main" description = "Composable command line interface toolkit" name = "click" optional = false @@ -218,6 +218,25 @@ version = "1.0.2" [package.dependencies] flake8 = "*" +[[package]] +category = "main" +description = "A simple framework for building complex web applications." +name = "flask" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "1.1.2" + +[package.dependencies] +Jinja2 = ">=2.10.1" +Werkzeug = ">=0.15" +click = ">=5.1" +itsdangerous = ">=0.24" + +[package.extras] +dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] +docs = ["sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] +dotenv = ["python-dotenv"] + [[package]] category = "dev" description = "Git Object Database" @@ -257,7 +276,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "1.2.0" [[package]] -category = "dev" +category = "main" +description = "Various helpers to pass data to untrusted environments and back." +name = "itsdangerous" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.1.0" + +[[package]] +category = "main" description = "A very fast and expressive template engine." name = "jinja2" optional = false @@ -271,7 +298,7 @@ MarkupSafe = ">=0.23" i18n = ["Babel (>=0.8)"] [[package]] -category = "dev" +category = "main" description = "Safely add untrusted strings to HTML/XML markup." name = "markupsafe" optional = false @@ -602,6 +629,18 @@ brotli = ["brotlipy (>=0.6.0)"] secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +[[package]] +category = "main" +description = "The comprehensive WSGI web application library." +name = "werkzeug" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "1.0.1" + +[package.extras] +dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] +watchdog = ["watchdog"] + [metadata] content-hash = "7388d8240a2817695e502fb845922da8df699d77d143693ffe3285eca1df3495" python-versions = "^3.8" @@ -685,6 +724,10 @@ flake8-polyfill = [ {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, ] +flask = [ + {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, + {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, +] gitdb = [ {file = "gitdb-4.0.5-py3-none-any.whl", hash = "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac"}, {file = "gitdb-4.0.5.tar.gz", hash = "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"}, @@ -701,6 +744,10 @@ imagesize = [ {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] +itsdangerous = [ + {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"}, + {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"}, +] jinja2 = [ {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, @@ -908,3 +955,7 @@ urllib3 = [ {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, ] +werkzeug = [ + {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"}, + {file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"}, +] diff --git a/pyproject.toml b/pyproject.toml index bf6bc58..939f1f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ license = "MIT" python = "^3.8" requests = "^2.24.0" typing_extensions = "^3.7.4" +flask = "^1.1.2" [tool.poetry.dev-dependencies] flake8 = "^3.8.3" diff --git a/src/replit/__init__.py b/src/replit/__init__.py index d0a010a..f2652cf 100644 --- a/src/replit/__init__.py +++ b/src/replit/__init__.py @@ -1,4 +1,5 @@ """The replit python module.""" +from . import maqpi from .audio import Audio from .database import db diff --git a/src/replit/maqpi/__init__.py b/src/replit/maqpi/__init__.py new file mode 100644 index 0000000..20589f5 --- /dev/null +++ b/src/replit/maqpi/__init__.py @@ -0,0 +1,18 @@ +"""Make apps quickly in python.""" +import os + +import flask +from werkzeug.local import LocalProxy + +from . import html +from .app import App +from .html import Page, Paragraph +from .utils import sign_in_snippet, signin +from ..database import db + +auth = LocalProxy(lambda: flask.request.auth) +signed_in = LocalProxy(lambda: flask.request.signed_in) + +# TODO: signinwall(exclude=['/a', '/b']) +# TODO: @need_signin +# TODO: Param checking with @needs_params diff --git a/src/replit/maqpi/app.py b/src/replit/maqpi/app.py new file mode 100644 index 0000000..f74aca0 --- /dev/null +++ b/src/replit/maqpi/app.py @@ -0,0 +1,77 @@ +"""Core of maqpi.""" +from dataclasses import dataclass +from typing import Any + +import flask + + +@dataclass +class ReplitAuthContext: + """A dataclass defining a Repl Auth state.""" + + user_id: int + name: str + roles: str + + @classmethod + def from_headers(cls, headers: dict): + """Initialize an instance using the Replit magic headers. + + Args: + headers (dict): A dictionary of headers received + + Returns: + [type]: An initialized class instance + """ + return cls( + user_id=headers.get("X-Replit-User-Id"), + name=headers.get("X-Replit-User-Name"), + roles=headers.get("X-Replit-User-Roles"), + ) + + @property + def signed_in(self) -> bool: + """Return whether or not the authentication is activated.""" + return self.name != "" + + +class Request(flask.Request): + """Represents a client request.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize request and run update_auth.""" + super().__init__(*args, **kwargs) + self.update_auth() + + def update_auth(self) -> None: + """Update the auth property to be a ReplitAuthContext.""" + self.auth = ReplitAuthContext.from_headers(self.headers) + + @property + def signed_in(self) -> bool: + """Return whether or not the authentication is activated.""" + return self.auth.signed_in + + +class App(flask.Flask): + """Represents a web application.""" + + request_class = Request + + def all_pages_sign_in(self) -> None: + """Require sign-in on all pages.""" + raise NotImplementedError() + + def _run(self, *args: Any, **kwargs: Any) -> Any: + """Interface with the underlying flask instance's run function.""" + return super().run(*args, **kwargs) + + def run(self, port: int = 8080, localhost: bool = False) -> None: + """Run the app. + + Args: + port (int): The port to run the app on. Defaults to 8080. + localhost (bool): Whether to run the app without exposing it on all + interfaces. Defaults to False. + """ + super().run(host="localhost" if localhost else "0.0.0.0", port=port) diff --git a/src/replit/maqpi/files.py b/src/replit/maqpi/files.py new file mode 100644 index 0000000..b4a55ea --- /dev/null +++ b/src/replit/maqpi/files.py @@ -0,0 +1,65 @@ +"""Utitilities for interacting with static files.""" +import flask + + +class FileCache: + """A simple cache for files.""" + + def __init__(self) -> None: + self.data = {} + + def add_to_cache(self, filename: str, content: str) -> None: + """Add a filename to the cache. + + Args: + filename (str): The filename to add. + content (str): The content to add to the cache. + """ + self.data[filename] = content + + def has(self, filename: str) -> bool: + """Whether the cache has a certain filename. + + Args: + filename (str): The filename to check for. + + Returns: + bool: Whether the cache has filename. + """ + return filename in self.data + + def invalidate(self, filename: str) -> None: + """Remove a filename from the cache. + + Args: + filename (str): The filename to remove. + """ + try: + self.data.pop(filename) + except KeyError: + pass + + def flush(self) -> None: + """Remove all values from the cache.""" + self.data = {} + + +cache = FileCache() + + +class File(flask.Response): + """Represents a static file.""" + + def __init__(self, filename: str, no_cache: bool = False) -> None: + self.filename = str(filename) + self.no_cache = no_cache + + # load file + if filename not in cache: + with open(filename, "r") as f: + self.content = f.read() + + if not no_cache: + cache.add_to_cache(filename, self.content) + + super().__init__(self.content) diff --git a/src/replit/maqpi/html.py b/src/replit/maqpi/html.py new file mode 100644 index 0000000..f2ea5b6 --- /dev/null +++ b/src/replit/maqpi/html.py @@ -0,0 +1,43 @@ +"""Class-representations for HTML elements.""" +from abc import ABC +from dataclasses import dataclass + +import flask + + +class HTMLElement(ABC): + """Base class for an HTML element.""" + + pass + + +@dataclass +class Paragraph: + """Represents a Paragraph (p) tag.""" + + content: str + + def __str__(self) -> str: + return f"

{self.content}

" + + +class Page(flask.Response): + """Represents an HTML page.""" + + def __init__(self, title: str = None, head: str = "", body: str = "") -> None: + self.title = title + self.head = head + self.body = body + + title_html = f"{self.title}\n " if self.title else "" + super().__init__( + f""" + + + {title_html}{self.head} + + + {self.body} + +""" + ) diff --git a/src/replit/maqpi/utils.py b/src/replit/maqpi/utils.py new file mode 100644 index 0000000..7935a39 --- /dev/null +++ b/src/replit/maqpi/utils.py @@ -0,0 +1,20 @@ +"""Utitilities to make development easier.""" +from .html import Page + + +sign_in_snippet = ( + '' +) + + +def signin(title: str = "Please Sign In") -> Page: + """Return a sign-in page. + + Args: + title (str): The title of the sign in page. Defaults to "Please Sign In". + + Returns: + Page: The sign-in page. + """ + return Page(title=title, body=sign_in_snippet)