From d4d25c17d8d8dfefb51c30b9528cb126fca069b4 Mon Sep 17 00:00:00 2001 From: Elijah Ahianyo Date: Thu, 1 Jun 2023 00:26:03 +0000 Subject: [PATCH] add support for .env configuration (#1104) --- poetry.lock | 19 ++++++++++++++-- pynecone/app.py | 9 +++----- pynecone/config.py | 50 ++++++++++++++++++++++++++++++++++++----- pynecone/constants.py | 47 +++++++++++++++++++++++++++++++------- pynecone/pc.py | 13 +++++++++-- pynecone/utils/build.py | 12 ++++++++++ pynecone/utils/exec.py | 4 ++-- pyproject.toml | 1 + tests/test_config.py | 11 +++++---- 9 files changed, 137 insertions(+), 29 deletions(-) diff --git a/poetry.lock b/poetry.lock index aaf1e6dc8..115b31e25 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "anyio" @@ -1136,6 +1136,21 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-dotenv" +version = "0.13.0" +description = "Add .env support to your django/flask apps in development and deployments" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "python-dotenv-0.13.0.tar.gz", hash = "sha256:3b9909bc96b0edc6b01586e1eed05e71174ef4e04c71da5786370cebea53ad74"}, + {file = "python_dotenv-0.13.0-py2.py3-none-any.whl", hash = "sha256:25c0ff1a3e12f4bde8d592cc254ab075cfe734fc5dd989036716fd17ee7e5ec7"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "python-engineio" version = "4.4.1" @@ -1787,4 +1802,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "693f9d308dcdc9788aba818f480e0cadecf9d5f97e43cc66d1a69da7aa06204f" +content-hash = "ad76c12812d3aedc9a36727b7e69e9b064077e365ebacc4be904d09824ddac4b" diff --git a/pynecone/app.py b/pynecone/app.py index 32389612c..5ad080b92 100644 --- a/pynecone/app.py +++ b/pynecone/app.py @@ -88,15 +88,12 @@ class App(Base): self.add_cors() self.add_default_endpoints() - # Set up CORS options. - cors_allowed_origins = config.cors_allowed_origins - if config.cors_allowed_origins == [constants.CORS_ALLOWED_ORIGINS]: - cors_allowed_origins = "*" - # Set up the Socket.IO AsyncServer. self.sio = AsyncServer( async_mode="asgi", - cors_allowed_origins=cors_allowed_origins, + cors_allowed_origins="*" + if config.cors_allowed_origins == constants.CORS_ALLOWED_ORIGINS + else config.cors_allowed_origins, cors_credentials=config.cors_credentials, max_http_buffer_size=config.polling_max_http_buffer_size, ping_interval=constants.PING_INTERVAL, diff --git a/pynecone/config.py b/pynecone/config.py index dd362b97d..5b4572315 100644 --- a/pynecone/config.py +++ b/pynecone/config.py @@ -2,11 +2,14 @@ from __future__ import annotations +import importlib import os import sys import urllib.parse from typing import List, Optional +from dotenv import load_dotenv + from pynecone import constants from pynecone.base import Base @@ -129,9 +132,9 @@ class Config(Base): username: Optional[str] = None # The frontend port. - port: str = constants.FRONTEND_PORT + frontend_port: str = constants.FRONTEND_PORT - # The frontend port. + # The backend port. backend_port: str = constants.BACKEND_PORT # The backend host. @@ -141,7 +144,7 @@ class Config(Base): api_url: str = constants.API_URL # The deploy url. - deploy_url: Optional[str] = None + deploy_url: Optional[str] = constants.DEPLOY_URL # The database url. db_url: Optional[str] = constants.DB_URL @@ -150,7 +153,7 @@ class Config(Base): db_config: Optional[DBConfig] = None # The redis url. - redis_url: Optional[str] = None + redis_url: Optional[str] = constants.REDIS_URL # Telemetry opt-in. telemetry_enabled: bool = True @@ -176,7 +179,7 @@ class Config(Base): ] = constants.Transports.WEBSOCKET_POLLING # List of origins that are allowed to connect to the backend API. - cors_allowed_origins: Optional[list] = [constants.CORS_ALLOWED_ORIGINS] + cors_allowed_origins: Optional[list] = constants.CORS_ALLOWED_ORIGINS # Whether credentials (cookies, authentication) are allowed in requests to the backend API. cors_credentials: Optional[bool] = True @@ -184,6 +187,12 @@ class Config(Base): # The maximum size of a message when using the polling backend transport. polling_max_http_buffer_size: Optional[int] = constants.POLLING_MAX_HTTP_BUFFER_SIZE + # Dotenv file path + env_path: Optional[str] = constants.DOT_ENV_FILE + + # Whether to override OS environment variables + override_os_envs: Optional[bool] = True + def __init__(self, *args, **kwargs): """Initialize the config values. @@ -198,6 +207,36 @@ class Config(Base): super().__init__(*args, **kwargs) + # set overriden class attribute values as os env variables to avoid losing them + for key, value in dict(self).items(): + key = key.upper() + if ( + key.startswith("_") + or key in os.environ + or (value is None and key != "DB_URL") + ): + continue + os.environ[key] = str(value) + + # Load env variables from env file + load_dotenv(self.env_path, override=self.override_os_envs) # type: ignore + # Recompute constants after loading env variables + importlib.reload(constants) + # Recompute instance attributes + self.recompute_field_values() + + def recompute_field_values(self): + """Recompute instance field values to reflect new values after reloading + constant values. + """ + for field in self.get_fields(): + try: + if field.startswith("_"): + continue + setattr(self, field, getattr(constants, f"{field.upper()}")) + except AttributeError: + pass + def get_config() -> Config: """Get the app config. @@ -210,5 +249,6 @@ def get_config() -> Config: sys.path.append(os.getcwd()) try: return __import__(constants.CONFIG_MODULE).config + except ImportError: return Config(app_name="") # type: ignore diff --git a/pynecone/constants.py b/pynecone/constants.py index 06690012b..968d2ce73 100644 --- a/pynecone/constants.py +++ b/pynecone/constants.py @@ -4,9 +4,36 @@ import os import re from enum import Enum from types import SimpleNamespace +from typing import Any, Type import pkg_resources + +def get_value(key: str, default: Any = None, type_: Type = str) -> Type: + """Get the value for the constant. + Obtain os env value and cast non-string types into + their original types. + + Args: + key: constant name. + default: default value if key doesn't exist. + type_: the type of the constant. + + Returns: + the value of the constant in its designated type + """ + value = os.getenv(key, default) + try: + if value and type_ != str: + value = eval(value) + except Exception: + pass + finally: + # Special case for db_url expects None to be a valid input when + # user explicitly overrides db_url as None + return value if value != "None" else None # noqa B012 + + # App names and versions. # The name of the Pynecone package. MODULE_NAME = "pynecone" @@ -61,12 +88,15 @@ PCVERSION_APP_FILE = os.path.join(WEB_DIR, "pynecone.json") ENV_JSON = os.path.join(WEB_DIR, "env.json") # Commands to run the app. +DOT_ENV_FILE = ".env" # The frontend default port. -FRONTEND_PORT = "3000" +FRONTEND_PORT = get_value("FRONTEND_PORT", "3000") # The backend default port. -BACKEND_PORT = "8000" +BACKEND_PORT = get_value("BACKEND_PORT", "8000") # The backend api url. -API_URL = "http://localhost:8000" +API_URL = get_value("API_URL", "http://localhost:8000") +# The deploy url +DEPLOY_URL = get_value("DEPLOY_URL") # bun root location BUN_ROOT_PATH = "$HOME/.bun" # The default path where bun is installed. @@ -74,7 +104,7 @@ BUN_PATH = f"{BUN_ROOT_PATH}/bin/bun" # Command to install bun. INSTALL_BUN = f"curl -fsSL https://bun.sh/install | bash -s -- bun-v{MAX_BUN_VERSION}" # Default host in dev mode. -BACKEND_HOST = "0.0.0.0" +BACKEND_HOST = get_value("BACKEND_HOST", "0.0.0.0") # The default timeout when launching the gunicorn server. TIMEOUT = 120 # The command to run the backend in production mode. @@ -122,9 +152,11 @@ FRONTEND_ZIP = "frontend.zip" # The name of the backend zip during deployment. BACKEND_ZIP = "backend.zip" # The name of the sqlite database. -DB_NAME = "pynecone.db" +DB_NAME = os.getenv("DB_NAME", "pynecone.db") # The sqlite url. -DB_URL = f"sqlite:///{DB_NAME}" +DB_URL = get_value("DB_URL", f"sqlite:///{DB_NAME}") +# The redis url +REDIS_URL = get_value("REDIS_URL") # The default title to show for Pynecone apps. DEFAULT_TITLE = "Pynecone App" # The default description to show for Pynecone apps. @@ -134,7 +166,6 @@ DEFAULT_IMAGE = "favicon.ico" # The default meta list to show for Pynecone apps. DEFAULT_META_LIST = [] - # The gitignore file. GITIGNORE_FILE = ".gitignore" # Files to gitignore. @@ -306,5 +337,5 @@ COLOR_MODE = "colorMode" TOGGLE_COLOR_MODE = "toggleColorMode" # Server socket configuration variables -CORS_ALLOWED_ORIGINS = "*" +CORS_ALLOWED_ORIGINS = get_value("CORS_ALLOWED_ORIGINS", ["*"], list) POLLING_MAX_HTTP_BUFFER_SIZE = 1000 * 1000 diff --git a/pynecone/pc.py b/pynecone/pc.py index 82c66db57..e045d8360 100644 --- a/pynecone/pc.py +++ b/pynecone/pc.py @@ -72,7 +72,7 @@ def run( loglevel: constants.LogLevel = typer.Option( constants.LogLevel.ERROR, help="The log level to use." ), - port: str = typer.Option(None, help="Specify a different frontend port."), + frontend_port: str = typer.Option(None, help="Specify a different frontend port."), backend_port: str = typer.Option(None, help="Specify a different backend port."), backend_host: str = typer.Option(None, help="Specify the backend host."), ): @@ -81,8 +81,17 @@ def run( console.print( "[yellow][WARNING] We strongly advise you to use Windows Subsystem for Linux (WSL) for optimal performance when using Pynecone. Due to compatibility issues with one of our dependencies, Bun, you may experience slower performance on Windows. By using WSL, you can expect to see a significant speed increase." ) + # Set ports as os env variables to take precedence over config and + # .env variables(if override_os_envs flag in config is set to False). + build.set_os_env( + frontend_port=frontend_port, + backend_port=backend_port, + backend_host=backend_host, + ) - frontend_port = get_config().port if port is None else port + frontend_port = ( + get_config().frontend_port if frontend_port is None else frontend_port + ) backend_port = get_config().backend_port if backend_port is None else backend_port backend_host = get_config().backend_host if backend_host is None else backend_host diff --git a/pynecone/utils/build.py b/pynecone/utils/build.py index 81b79ee23..a4bad4c3b 100644 --- a/pynecone/utils/build.py +++ b/pynecone/utils/build.py @@ -58,6 +58,18 @@ def set_environment_variables(): ) +def set_os_env(**kwargs): + """Set os environment variables. + + Args: + kwargs: env key word args. + """ + for key, value in kwargs.items(): + if not value: + continue + os.environ[key.upper()] = value + + def generate_sitemap(deploy_url: str): """Generate the sitemap config file. diff --git a/pynecone/utils/exec.py b/pynecone/utils/exec.py index 49c4a9dd5..6d0a62b37 100644 --- a/pynecone/utils/exec.py +++ b/pynecone/utils/exec.py @@ -96,7 +96,7 @@ def run_frontend( # Run the frontend in development mode. console.rule("[bold green]App Running") - os.environ["PORT"] = get_config().port if port is None else port + os.environ["PORT"] = get_config().frontend_port if port is None else port run_process_and_launch_url( [prerequisites.get_package_manager(), "run", "dev"], root, loglevel ) @@ -123,7 +123,7 @@ def run_frontend_prod( export_app(app, loglevel=loglevel) # Set the port. - os.environ["PORT"] = get_config().port if port is None else port + os.environ["PORT"] = get_config().frontend_port if port is None else port # Run the frontend in production mode. console.rule("[bold green]App Running") diff --git a/pyproject.toml b/pyproject.toml index f0b93fdf4..75b4a35ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ typer = "0.4.2" uvicorn = "^0.20.0" watchdog = "^2.3.1" websockets = "^10.4" +python-dotenv = "0.13.0" [tool.poetry.group.dev.dependencies] pytest = "^7.1.2" diff --git a/tests/test_config.py b/tests/test_config.py index 5e4814f99..2323eb601 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,4 @@ +import os from typing import Dict import pytest @@ -21,18 +22,19 @@ def config_no_db_url_values(base_config_values) -> Dict: return base_config_values -@pytest.fixture -def config_empty_db_url_values(base_config_values) -> Dict: +@pytest.fixture(autouse=True) +def config_empty_db_url_values(base_config_values): """Create config values with empty db_url. Args: base_config_values: Base config values fixture. - Returns: + Yields: Config values """ base_config_values["db_url"] = None - return base_config_values + yield base_config_values + os.environ.pop("DB_URL") def test_config_db_url(base_config_values): @@ -41,6 +43,7 @@ def test_config_db_url(base_config_values): Args: base_config_values: base_config_values fixture. """ + os.environ.pop("DB_URL") config = pc.Config(**base_config_values) assert config.db_url == base_config_values["db_url"]