Module: project_settings

Expand source code
# Copyright (C) 2023-present The Project Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar
from typing import List
from typing import Literal
from typing import cast
from typing_extensions import Self
from cl.runtime.records.dataclasses_extensions import missing

SETTINGS_FILES_ENVVAR = "CL_SETTINGS_FILES"
"""The name of environment variable used to override the settings file(s) names or locations."""


@dataclass(slots=True, kw_only=True)
class ProjectSettings:
    """
    Information about the project location and layout used to search for settings and packages.
    This class finds the location of .env or settings.yaml and detects one of two supported layouts:

    One-level (suitable only for monorepo git layout):
        - project and packages root (one level layout)
            -- project files
            -- package files (files from all packages are interleaved under a common root)

    Two-level (suitable for monorepo, submodules or subtree git layout):
        - project root (first level of two-level layout)
            -- project files
            -- package root (second level of two-level layout)
                --- package files (files from each package are under a separate package root)
    """

    project_root: str = missing()
    """Project root directory is the location of .env or settings.yaml file."""

    project_levels: int = missing()
    """Number of levels in project layout (one or two)."""

    __instance: ClassVar[ProjectSettings] = None
    """Singleton instance."""

    def init(self) -> None:
        """Same as __init__ but can be used when field values are set both during and after construction."""
        if self.project_levels != 1 and self.project_levels != 2:
            raise RuntimeError(f"Field 'ProjectSettings.project_levels' must be 1 or 2.")

    @classmethod
    def get_project_root(cls) -> str:
        """Project root directory is the location of .env or settings.yaml file."""
        return cls.instance().project_root

    @classmethod
    def get_package_root(cls, package: str) -> str:
        """
        Package root directory for the specified package, same as project root in one-level layout
        and project_root/package_name for two-level layout. Not the same as get_source_root.

        Args:
            package: Dot-delimited package root, e.g. 'cl.runtime'
        """
        package_tokens = package.split(".")
        package_tokens_len = len(package_tokens)
        source_root = cls.get_source_root(package)
        result = os.path.normpath(str(Path(source_root).parents[package_tokens_len - 1]))
        return result

    @classmethod
    def get_source_root(cls, package: str) -> str:
        """
        Source code root directory (the entry in PYTHONPATH) for the specified package.

        Notes:
            Error if the directory does not contains __init__.py

        Args:
            package: Dot-delimited package root, e.g. 'cl.runtime'
        """
        project_root = cls.instance().project_root
        project_levels = cls.instance().project_levels
        relative_path = package.replace(".", os.sep)
        if project_levels == 1:
            # One-level project, search directly under project root
            search_paths = [os.path.normpath(os.path.join(project_root, relative_path, "__init__.py"))]
        elif project_levels == 2:
            # Two-level project, check each dot-delimited package token in reverse order as potential package root
            package_tokens = package.split(".")
            package_tokens.reverse()
            search_paths = [
                os.path.normpath(os.path.join(project_root, x, relative_path, "__init__.py")) for x in package_tokens
            ]
        else:
            raise RuntimeError(f"Field 'ProjectSettings.project_levels' must be 1 or 2.")

        # Find the first directory with __init__.py
        init_path = next((x for x in search_paths if os.path.exists(x)), None)
        if init_path is not None:
            result = os.path.normpath(os.path.dirname(init_path))
            return result
        else:
            search_paths_str = "n".join(search_paths)
            raise RuntimeError(
                f"Did not find  __init__.py for package '{package}'. Location searched:n" f"{search_paths_str}n"
            )

    @classmethod
    def get_stubs_root(cls, package: str) -> str | None:
        """
        Stubs root directory for the specified package.

        Notes:
            Error if the directory does not contains __init__.py

        Args:
            package: Dot-delimited package root, e.g. 'cl.runtime'
        """
        package_tokens = package.split(".")
        package_tokens_len = len(package_tokens)
        source_root = cls.get_source_root(package)
        common_root = str(Path(source_root).parents[package_tokens_len - 1])
        if package_tokens[0] == "tests":
            # Do not look for stubs relative to tests
            return None
        elif package_tokens[0] == "stubs":
            # Stubs package is specified directly
            return source_root
        else:
            # Look for stubs package relative to source, return if exists
            stubs_root = os.path.normpath(os.path.join(common_root, "stubs", *package_tokens))
            if os.path.exists(stubs_root):
                return stubs_root
            else:
                return None

    @classmethod
    def get_tests_root(cls, package: str) -> str | None:
        """
        Tests root directory for the specified package.

        Notes:
            The presence of __init__.py is not required for tests

        Args:
            package: Dot-delimited package root, e.g. 'cl.runtime'
        """
        package_tokens = package.split(".")
        package_tokens_len = len(package_tokens)
        source_root = cls.get_source_root(package)
        common_root = str(Path(source_root).parents[package_tokens_len - 1])
        if package_tokens[0] == "tests":
            # Tests package is specified directly
            return source_root
        elif package_tokens[0] == "stubs":
            # Do not look for tests package relative to stubs
            return None
        else:
            # Look for tests package relative to source, return if exists
            tests_root = os.path.normpath(os.path.join(common_root, "tests", *package_tokens))
            if os.path.exists(tests_root):
                return tests_root
            else:
                return None

    @classmethod
    def get_wwwroot(cls) -> str:
        """Class method returning path to wwwroot directory under project root directory."""
        project_root = cls.get_project_root()
        return os.path.normpath(os.path.join(project_root, "wwwroot"))

    @classmethod
    def get_databases_dir(cls) -> str:
        """Class method returning path to databases directory under project root directory."""
        project_root = cls.get_project_root()
        db_dir = os.path.join(project_root, "databases")
        if not os.path.exists(db_dir):
            # Create the directory if does not exist
            os.makedirs(db_dir)
        return db_dir

    @classmethod
    def instance(cls) -> Self:
        """Return singleton instance."""
        # Check if cached value exists, load if not found
        if cls.__instance is None:
            env_settings_files = os.getenv(SETTINGS_FILES_ENVVAR)
            if env_settings_files:
                # TODO: Handle by replacing settings.yaml in search by the specified list
                raise RuntimeError(
                    f"Override of the Dynaconf settings file(s) names or locations using envvar "
                    f"'{SETTINGS_FILES_ENVVAR}' is not supported in this version."
                )

            # Possible project root locations for each layout relative to this module
            superproject_root_dir = os.path.normpath(Path(__file__).parents[4])
            monorepo_root_dir = os.path.normpath(Path(__file__).parents[3])

            # Settings filenames to search
            settings_filenames = [".env", "settings.yaml"]

            project_root = None
            project_levels = None
            try:
                if os.path.exists(superproject_root_dir):
                    # Supermodule directory takes priority but only if it contains one of the settings files
                    if any(os.path.exists(os.path.join(superproject_root_dir, x)) for x in settings_filenames):
                        project_root = superproject_root_dir
                        project_levels = 2
            # Handle the possibility that directory access is prohibited
            except FileNotFoundError:
                pass
            except PermissionError:
                pass

            if project_root is None:
                try:
                    if os.path.exists(monorepo_root_dir):
                        # Monorepo directory is searched next
                        if any(os.path.exists(os.path.join(monorepo_root_dir, x)) for x in settings_filenames):
                            project_root = monorepo_root_dir
                            project_levels = 1
                # Handle the possibility that directory access is prohibited
                except FileNotFoundError:
                    pass
                except PermissionError:
                    pass

            # Error if still not found
            if project_root is None:
                raise RuntimeError(
                    f"""Project settings ('.env' or 'settings.yaml' files) could not be found. Locations searched:
1. {superproject_root_dir}
2. {monorepo_root_dir}
"""
                )
            obj = ProjectSettings(project_root=project_root, project_levels=project_levels)
            cls.__instance = obj
        return cls.__instance

Global variables

var SETTINGS_FILES_ENVVAR

The name of environment variable used to override the settings file(s) names or locations.

Classes

class ProjectSettings (*, project_root: str = None, project_levels: int = None)

Information about the project location and layout used to search for settings and packages. This class finds the location of .env or settings.yaml and detects one of two supported layouts:

One-level (suitable only for monorepo git layout): – project and packages root (one level layout) – project files – package files (files from all packages are interleaved under a common root)

Two-level (suitable for monorepo, submodules or subtree git layout): – project root (first level of two-level layout) – project files – package root (second level of two-level layout) — package files (files from each package are under a separate package root)

Expand source code
@dataclass(slots=True, kw_only=True)
class ProjectSettings:
    """
    Information about the project location and layout used to search for settings and packages.
    This class finds the location of .env or settings.yaml and detects one of two supported layouts:

    One-level (suitable only for monorepo git layout):
        - project and packages root (one level layout)
            -- project files
            -- package files (files from all packages are interleaved under a common root)

    Two-level (suitable for monorepo, submodules or subtree git layout):
        - project root (first level of two-level layout)
            -- project files
            -- package root (second level of two-level layout)
                --- package files (files from each package are under a separate package root)
    """

    project_root: str = missing()
    """Project root directory is the location of .env or settings.yaml file."""

    project_levels: int = missing()
    """Number of levels in project layout (one or two)."""

    __instance: ClassVar[ProjectSettings] = None
    """Singleton instance."""

    def init(self) -> None:
        """Same as __init__ but can be used when field values are set both during and after construction."""
        if self.project_levels != 1 and self.project_levels != 2:
            raise RuntimeError(f"Field 'ProjectSettings.project_levels' must be 1 or 2.")

    @classmethod
    def get_project_root(cls) -> str:
        """Project root directory is the location of .env or settings.yaml file."""
        return cls.instance().project_root

    @classmethod
    def get_package_root(cls, package: str) -> str:
        """
        Package root directory for the specified package, same as project root in one-level layout
        and project_root/package_name for two-level layout. Not the same as get_source_root.

        Args:
            package: Dot-delimited package root, e.g. 'cl.runtime'
        """
        package_tokens = package.split(".")
        package_tokens_len = len(package_tokens)
        source_root = cls.get_source_root(package)
        result = os.path.normpath(str(Path(source_root).parents[package_tokens_len - 1]))
        return result

    @classmethod
    def get_source_root(cls, package: str) -> str:
        """
        Source code root directory (the entry in PYTHONPATH) for the specified package.

        Notes:
            Error if the directory does not contains __init__.py

        Args:
            package: Dot-delimited package root, e.g. 'cl.runtime'
        """
        project_root = cls.instance().project_root
        project_levels = cls.instance().project_levels
        relative_path = package.replace(".", os.sep)
        if project_levels == 1:
            # One-level project, search directly under project root
            search_paths = [os.path.normpath(os.path.join(project_root, relative_path, "__init__.py"))]
        elif project_levels == 2:
            # Two-level project, check each dot-delimited package token in reverse order as potential package root
            package_tokens = package.split(".")
            package_tokens.reverse()
            search_paths = [
                os.path.normpath(os.path.join(project_root, x, relative_path, "__init__.py")) for x in package_tokens
            ]
        else:
            raise RuntimeError(f"Field 'ProjectSettings.project_levels' must be 1 or 2.")

        # Find the first directory with __init__.py
        init_path = next((x for x in search_paths if os.path.exists(x)), None)
        if init_path is not None:
            result = os.path.normpath(os.path.dirname(init_path))
            return result
        else:
            search_paths_str = "n".join(search_paths)
            raise RuntimeError(
                f"Did not find  __init__.py for package '{package}'. Location searched:n" f"{search_paths_str}n"
            )

    @classmethod
    def get_stubs_root(cls, package: str) -> str | None:
        """
        Stubs root directory for the specified package.

        Notes:
            Error if the directory does not contains __init__.py

        Args:
            package: Dot-delimited package root, e.g. 'cl.runtime'
        """
        package_tokens = package.split(".")
        package_tokens_len = len(package_tokens)
        source_root = cls.get_source_root(package)
        common_root = str(Path(source_root).parents[package_tokens_len - 1])
        if package_tokens[0] == "tests":
            # Do not look for stubs relative to tests
            return None
        elif package_tokens[0] == "stubs":
            # Stubs package is specified directly
            return source_root
        else:
            # Look for stubs package relative to source, return if exists
            stubs_root = os.path.normpath(os.path.join(common_root, "stubs", *package_tokens))
            if os.path.exists(stubs_root):
                return stubs_root
            else:
                return None

    @classmethod
    def get_tests_root(cls, package: str) -> str | None:
        """
        Tests root directory for the specified package.

        Notes:
            The presence of __init__.py is not required for tests

        Args:
            package: Dot-delimited package root, e.g. 'cl.runtime'
        """
        package_tokens = package.split(".")
        package_tokens_len = len(package_tokens)
        source_root = cls.get_source_root(package)
        common_root = str(Path(source_root).parents[package_tokens_len - 1])
        if package_tokens[0] == "tests":
            # Tests package is specified directly
            return source_root
        elif package_tokens[0] == "stubs":
            # Do not look for tests package relative to stubs
            return None
        else:
            # Look for tests package relative to source, return if exists
            tests_root = os.path.normpath(os.path.join(common_root, "tests", *package_tokens))
            if os.path.exists(tests_root):
                return tests_root
            else:
                return None

    @classmethod
    def get_wwwroot(cls) -> str:
        """Class method returning path to wwwroot directory under project root directory."""
        project_root = cls.get_project_root()
        return os.path.normpath(os.path.join(project_root, "wwwroot"))

    @classmethod
    def get_databases_dir(cls) -> str:
        """Class method returning path to databases directory under project root directory."""
        project_root = cls.get_project_root()
        db_dir = os.path.join(project_root, "databases")
        if not os.path.exists(db_dir):
            # Create the directory if does not exist
            os.makedirs(db_dir)
        return db_dir

    @classmethod
    def instance(cls) -> Self:
        """Return singleton instance."""
        # Check if cached value exists, load if not found
        if cls.__instance is None:
            env_settings_files = os.getenv(SETTINGS_FILES_ENVVAR)
            if env_settings_files:
                # TODO: Handle by replacing settings.yaml in search by the specified list
                raise RuntimeError(
                    f"Override of the Dynaconf settings file(s) names or locations using envvar "
                    f"'{SETTINGS_FILES_ENVVAR}' is not supported in this version."
                )

            # Possible project root locations for each layout relative to this module
            superproject_root_dir = os.path.normpath(Path(__file__).parents[4])
            monorepo_root_dir = os.path.normpath(Path(__file__).parents[3])

            # Settings filenames to search
            settings_filenames = [".env", "settings.yaml"]

            project_root = None
            project_levels = None
            try:
                if os.path.exists(superproject_root_dir):
                    # Supermodule directory takes priority but only if it contains one of the settings files
                    if any(os.path.exists(os.path.join(superproject_root_dir, x)) for x in settings_filenames):
                        project_root = superproject_root_dir
                        project_levels = 2
            # Handle the possibility that directory access is prohibited
            except FileNotFoundError:
                pass
            except PermissionError:
                pass

            if project_root is None:
                try:
                    if os.path.exists(monorepo_root_dir):
                        # Monorepo directory is searched next
                        if any(os.path.exists(os.path.join(monorepo_root_dir, x)) for x in settings_filenames):
                            project_root = monorepo_root_dir
                            project_levels = 1
                # Handle the possibility that directory access is prohibited
                except FileNotFoundError:
                    pass
                except PermissionError:
                    pass

            # Error if still not found
            if project_root is None:
                raise RuntimeError(
                    f"""Project settings ('.env' or 'settings.yaml' files) could not be found. Locations searched:
1. {superproject_root_dir}
2. {monorepo_root_dir}
"""
                )
            obj = ProjectSettings(project_root=project_root, project_levels=project_levels)
            cls.__instance = obj
        return cls.__instance

Static methods

def get_databases_dir() -> str

Class method returning path to databases directory under project root directory.

def get_package_root(package: str) -> str

Package root directory for the specified package, same as project root in one-level layout and project_root/package_name for two-level layout. Not the same as get_source_root.

Args

package
Dot-delimited package root, e.g. ‘cl.runtime’
def get_project_root() -> str

Project root directory is the location of .env or settings.yaml file.

def get_source_root(package: str) -> str

Source code root directory (the entry in PYTHONPATH) for the specified package.

Notes

Error if the directory does not contains init.py

Args

package
Dot-delimited package root, e.g. ‘cl.runtime’
def get_stubs_root(package: str) -> str | None

Stubs root directory for the specified package.

Notes

Error if the directory does not contains init.py

Args

package
Dot-delimited package root, e.g. ‘cl.runtime’
def get_tests_root(package: str) -> str | None

Tests root directory for the specified package.

Notes

The presence of init.py is not required for tests

Args

package
Dot-delimited package root, e.g. ‘cl.runtime’
def get_wwwroot() -> str

Class method returning path to wwwroot directory under project root directory.

def instance() -> Self

Return singleton instance.

Fields

var project_levels -> int

Number of levels in project layout (one or two).

var project_root -> str

Project root directory is the location of .env or settings.yaml file.

Methods

def init(self) -> None

Same as init but can be used when field values are set both during and after construction.