diff --git a/pynecone/__init__.py b/pynecone/__init__.py index 2a09f52f9..747323d0b 100644 --- a/pynecone/__init__.py +++ b/pynecone/__init__.py @@ -9,7 +9,7 @@ from .base import Base from .components import * from .components.component import custom_component as memo from .components.graphing.victory import data -from .config import Config +from .config import Config, DBConfig from .constants import Env, Transports from .event import ( EVENT_ARG, diff --git a/pynecone/config.py b/pynecone/config.py index d39e7848c..07c6616e9 100644 --- a/pynecone/config.py +++ b/pynecone/config.py @@ -1,13 +1,124 @@ """The Pynecone config.""" +from __future__ import annotations + import os import sys +import urllib.parse from typing import List, Optional from pynecone import constants from pynecone.base import Base +class DBConfig(Base): + """Database config.""" + + engine: str + username: Optional[str] = "" + password: Optional[str] = "" + host: Optional[str] = "" + port: Optional[int] = None + database: str + + @classmethod + def postgresql( + cls, + database: str, + username: str, + password: Optional[str] = None, + host: Optional[str] = None, + port: Optional[int] = 5432, + ) -> DBConfig: + """Create an instance with postgresql engine. + + Args: + database: Database name. + username: Database username. + password: Database password. + host: Database host. + port: Database port. + + Returns: + DBConfig instance. + """ + return cls( + engine="postgresql", + username=username, + password=password, + host=host, + port=port, + database=database, + ) + + @classmethod + def postgresql_psycopg2( + cls, + database: str, + username: str, + password: Optional[str] = None, + host: Optional[str] = None, + port: Optional[int] = 5432, + ) -> DBConfig: + """Create an instance with postgresql+psycopg2 engine. + + Args: + database: Database name. + username: Database username. + password: Database password. + host: Database host. + port: Database port. + + Returns: + DBConfig instance. + """ + return cls( + engine="postgresql+psycopg2", + username=username, + password=password, + host=host, + port=port, + database=database, + ) + + @classmethod + def sqlite( + cls, + database: str, + ) -> DBConfig: + """Create an instance with sqlite engine. + + Args: + database: Database name. + + Returns: + DBConfig instance. + """ + return cls( + engine="sqlite", + database=database, + ) + + def get_url(self) -> str: + """Get database URL. + + Returns: + The database URL. + """ + host = ( + f"{self.host}:{self.port}" if self.host and self.port else self.host or "" + ) + username = urllib.parse.quote_plus(self.username) if self.username else "" + password = urllib.parse.quote_plus(self.password) if self.password else "" + + if username: + path = f"{username}:{password}@{host}" if password else f"{username}@{host}" + else: + path = f"{host}" + + return f"{self.engine}://{path}/{self.database}" + + class Config(Base): """A Pynecone config.""" @@ -32,6 +143,9 @@ class Config(Base): # The database url. db_url: Optional[str] = constants.DB_URL + # The database config. + db_config: Optional[DBConfig] = None + # The redis url. redis_url: Optional[str] = None @@ -67,6 +181,20 @@ 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 + def __init__(self, *args, **kwargs): + """Initialize the config values. + + If db_url is not provided gets it from db_config. + + Args: + *args: The args to pass to the Pydantic init method. + **kwargs: The kwargs to pass to the Pydantic init method. + """ + if "db_url" not in kwargs and "db_config" in kwargs: + kwargs["db_url"] = kwargs["db_config"].get_url() + + super().__init__(*args, **kwargs) + def get_config() -> Config: """Get the app config. diff --git a/tests/conftest.py b/tests/conftest.py index fef1876e0..732178af5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,11 @@ """Test fixtures.""" import platform -from typing import Generator, List +from typing import Dict, Generator, List import pytest import pynecone as pc +from pynecone import constants from pynecone.event import EventSpec @@ -262,3 +263,37 @@ def upload_state(tmp_path): self.img_list.append(file.filename) return FileUploadState + + +@pytest.fixture +def base_config_values() -> Dict: + """Get base config values. + + Returns: + Dictionary of base config values + """ + return {"app_name": "app", "db_url": constants.DB_URL, "env": pc.Env.DEV} + + +@pytest.fixture +def base_db_config_values() -> Dict: + """Get base DBConfig values. + + Returns: + Dictionary of base db config values + """ + return {"database": "db"} + + +@pytest.fixture +def sqlite_db_config_values(base_db_config_values) -> Dict: + """Get sqlite DBConfig values. + + Args: + base_db_config_values: Base DBConfig fixture. + + Returns: + Dictionary of sqlite DBConfig values + """ + base_db_config_values["engine"] = "sqlite" + return base_db_config_values diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..5e4814f99 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,91 @@ +from typing import Dict + +import pytest + +import pynecone as pc +from pynecone import constants +from pynecone.config import DBConfig + + +@pytest.fixture +def config_no_db_url_values(base_config_values) -> Dict: + """Create config values with no db_url. + + Args: + base_config_values: Base config fixture. + + Returns: + Config values. + """ + base_config_values.pop("db_url") + return base_config_values + + +@pytest.fixture +def config_empty_db_url_values(base_config_values) -> Dict: + """Create config values with empty db_url. + + Args: + base_config_values: Base config values fixture. + + Returns: + Config values + """ + base_config_values["db_url"] = None + return base_config_values + + +def test_config_db_url(base_config_values): + """Test defined db_url is not changed. + + Args: + base_config_values: base_config_values fixture. + """ + config = pc.Config(**base_config_values) + assert config.db_url == base_config_values["db_url"] + + +def test_default_db_url(config_no_db_url_values): + """Test that db_url is assigned the default value if not passed. + + Args: + config_no_db_url_values: Config values with no db_url defined. + """ + config = pc.Config(**config_no_db_url_values) + assert config.db_url == constants.DB_URL + + +def test_empty_db_url(config_empty_db_url_values): + """Test that db_url is not automatically assigned if an empty value is defined. + + Args: + config_empty_db_url_values: Config values with empty db_url. + """ + config = pc.Config(**config_empty_db_url_values) + assert config.db_url is None + + +def test_db_url_precedence(base_config_values, sqlite_db_config_values): + """Test that db_url is not overwritten when db_url is defined. + + Args: + base_config_values: config values that include db_ur. + sqlite_db_config_values: DB config values. + """ + db_config = DBConfig(**sqlite_db_config_values) + base_config_values["db_config"] = db_config + config = pc.Config(**base_config_values) + assert config.db_url == base_config_values["db_url"] + + +def test_db_url_from_db_config(config_no_db_url_values, sqlite_db_config_values): + """Test db_url generation from db_config. + + Args: + config_no_db_url_values: Config values with no db_url. + sqlite_db_config_values: DB config values. + """ + db_config = DBConfig(**sqlite_db_config_values) + config_no_db_url_values["db_config"] = db_config + config = pc.Config(**config_no_db_url_values) + assert config.db_url == db_config.get_url() diff --git a/tests/test_db_config.py b/tests/test_db_config.py new file mode 100644 index 000000000..b558b598b --- /dev/null +++ b/tests/test_db_config.py @@ -0,0 +1,204 @@ +import urllib.parse + +import pytest + +from pynecone.config import DBConfig + + +@pytest.mark.parametrize( + "engine,username,password,host,port,database,expected_url", + [ + ( + "postgresql", + "user", + "pass", + "localhost", + 5432, + "db", + "postgresql://user:pass@localhost:5432/db", + ), + ( + "postgresql", + "user", + "pass", + "localhost", + None, + "db", + "postgresql://user:pass@localhost/db", + ), + ( + "postgresql", + "user", + None, + "localhost", + None, + "db", + "postgresql://user@localhost/db", + ), + ("postgresql", "user", None, None, None, "db", "postgresql://user@/db"), + ("postgresql", "user", None, None, 5432, "db", "postgresql://user@/db"), + ( + "postgresql", + None, + None, + "localhost", + 5432, + "db", + "postgresql://localhost:5432/db", + ), + ("sqlite", None, None, None, None, "db.sqlite", "sqlite:///db.sqlite"), + ], +) +def test_get_url(engine, username, password, host, port, database, expected_url): + """Test generation of URL. + + Args: + engine: Database engine. + username: Database username. + password: Database password. + host: Database host. + port: Database port. + database: Database name. + expected_url: Expected database URL generated. + """ + db_config = DBConfig( + engine=engine, + username=username, + password=password, + host=host, + port=port, + database=database, + ) + assert db_config.get_url() == expected_url + + +def test_url_encode(): + """Test username and password are urlencoded when database URL is generated.""" + username = "user@user" + password = "pass@pass" + database = "db" + username_encoded = urllib.parse.quote_plus(username) + password_encoded = urllib.parse.quote_plus(password) + engine = "postgresql" + + db_config = DBConfig( + engine=engine, username=username, password=password, database=database + ) + assert ( + db_config.get_url() + == f"{engine}://{username_encoded}:{password_encoded}@/{database}" + ) + + +def test_url_encode_database_name(): + """Test database name is not URL encoded.""" + username = "user" + password = "pass" + database = "db@prod" + engine = "postgresql" + + db_config = DBConfig( + engine=engine, username=username, password=password, database=database + ) + assert db_config.get_url() == f"{engine}://{username}:{password}@/{database}" + + +def test_constructor_sqlite(): + """Test DBConfig.sqlite constructor create the instance correctly.""" + db_config = DBConfig.sqlite(database="app.db") + assert db_config.engine == "sqlite" + assert db_config.username == "" + assert db_config.password == "" + assert db_config.host == "" + assert db_config.port is None + assert db_config.database == "app.db" + assert db_config.get_url() == "sqlite:///app.db" + + +@pytest.mark.parametrize( + "username,password,host,port,database,expected_url", + [ + ( + "user", + "pass", + "localhost", + 5432, + "db", + "postgresql://user:pass@localhost:5432/db", + ), + ("user", "", "localhost", None, "db", "postgresql://user@localhost/db"), + ("user", "", "", None, "db", "postgresql://user@/db"), + ("", "", "localhost", 5432, "db", "postgresql://localhost:5432/db"), + ("", "", "", None, "db", "postgresql:///db"), + ], +) +def test_constructor_postgresql(username, password, host, port, database, expected_url): + """Test DBConfig.postgresql constructor creates the instance correctly. + + Args: + username: Database username. + password: Database password. + host: Database host. + port: Database port. + database: Database name. + expected_url: Expected database URL generated. + """ + db_config = DBConfig.postgresql( + username=username, password=password, host=host, port=port, database=database + ) + assert db_config.engine == "postgresql" + assert db_config.username == username + assert db_config.password == password + assert db_config.host == host + assert db_config.port == port + assert db_config.database == database + assert db_config.get_url() == expected_url + + +@pytest.mark.parametrize( + "username,password,host,port,database,expected_url", + [ + ( + "user", + "pass", + "localhost", + 5432, + "db", + "postgresql+psycopg2://user:pass@localhost:5432/db", + ), + ( + "user", + "", + "localhost", + None, + "db", + "postgresql+psycopg2://user@localhost/db", + ), + ("user", "", "", None, "db", "postgresql+psycopg2://user@/db"), + ("", "", "localhost", 5432, "db", "postgresql+psycopg2://localhost:5432/db"), + ("", "", "", None, "db", "postgresql+psycopg2:///db"), + ], +) +def test_constructor_postgresql_psycopg2( + username, password, host, port, database, expected_url +): + """Test DBConfig.postgresql_psycopg2 constructor creates the instance correctly. + + Args: + username: Database username. + password: Database password. + host: Database host. + port: Database port. + database: Database name. + expected_url: Expected database URL generated. + """ + db_config = DBConfig.postgresql_psycopg2( + username=username, password=password, host=host, port=port, database=database + ) + assert db_config.engine == "postgresql+psycopg2" + assert db_config.username == username + assert db_config.password == password + assert db_config.host == host + assert db_config.port == port + assert db_config.database == database + assert db_config.get_url() == expected_url