Explorar o código

Add Database configuration object (#763)

* Add DB config object (#759)

* Add DBConfig to base (#759)
Kasun Herath %!s(int64=2) %!d(string=hai) anos
pai
achega
e9928d9838
Modificáronse 5 ficheiros con 460 adicións e 2 borrados
  1. 1 1
      pynecone/__init__.py
  2. 128 0
      pynecone/config.py
  3. 36 1
      tests/conftest.py
  4. 91 0
      tests/test_config.py
  5. 204 0
      tests/test_db_config.py

+ 1 - 1
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,

+ 128 - 0
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.

+ 36 - 1
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

+ 91 - 0
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()

+ 204 - 0
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