Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/environments #29

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cally.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"**/.mypy_cache": true,
"**/.ruff_cache": true,
"**/*egg*": true,
"**/venv": true
},
"files.watcherExclude": {
"**/.pytest_cache": true,
Expand Down
2 changes: 1 addition & 1 deletion src/cally/cdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from constructs import Construct

if TYPE_CHECKING:
from cally.cli.config.types import CallyStackService
from cally.cli.config.config_types import CallyStackService


@dataclass
Expand Down
11 changes: 1 addition & 10 deletions src/cally/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,11 @@ def get_commands(package_name: str) -> List:


@click.group()
@click.option(
'--config',
type=click.Path(path_type=Path),
default=Path(Path.cwd(), 'cally.yaml'),
envvar='CALLY_CONFIG',
help='Path to the project config file',
)
@click.version_option(__version__)
@click.pass_context
def cally(ctx: click.Context, config: Path) -> None:
def cally() -> None:
"""
Top level click command group for Cally
"""
ctx.obj = CallyConfig(config_file=config)


commands = get_commands('cally.cli.commands')
Expand Down
16 changes: 12 additions & 4 deletions src/cally/cli/commands/config.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import click
import yaml

from ..config import CallyConfig, service_options
from ..config.environment import CallyEnvironmentCommand, CallyEnvironmentConfig
from ..config.service import CallyServiceCommand, CallyServiceConfig


@click.group()
def config() -> None:
pass


@click.command()
@service_options
@click.command(cls=CallyServiceCommand())
@click.pass_obj
def print_service(config: CallyConfig):
def print_service(config: CallyServiceConfig):
"""Prints the service config as YAML"""
click.secho(yaml.dump(config.settings.to_dict()))


@click.command(cls=CallyEnvironmentCommand())
@click.pass_obj
def print_services(config: CallyEnvironmentConfig):
"""Prints all the services in an environment config as YAML"""
click.secho(yaml.dump(config.settings.to_dict()))


config.add_command(print_service)
config.add_command(print_services)
20 changes: 9 additions & 11 deletions src/cally/cli/commands/tf.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from pathlib import Path


import click

from ..config import CallyConfig, service_options
from ..config.config_types import CallyStackService
from ..config.terraform_service import CallyStackServiceCommand, pass_stack_obj
from ..tools import terraform


Expand All @@ -12,11 +12,10 @@ def tf() -> None:
pass


@click.command(name='print')
@service_options
@click.pass_obj
def print_template(config: CallyConfig):
with terraform.Action(service=config.as_dataclass('CallyStackService')) as action:
@click.command(name='print', cls=CallyStackServiceCommand())
@pass_stack_obj
def print_template(config: CallyStackService):
with terraform.Action(service=config) as action:
click.secho(action.print())


Expand All @@ -27,10 +26,9 @@ def print_template(config: CallyConfig):
default=Path(Path.cwd(), 'cdk.tf.json'),
help='Output path for the terraform json',
)
@service_options
@click.pass_obj
def write_template(config: CallyConfig, output: Path):
with terraform.Action(service=config.as_dataclass('CallyStackService')) as action:
@pass_stack_obj
def write_template(config: CallyStackService, output: Path):
with terraform.Action(service=config) as action:
output.write_text(action.print())
click.secho(f'Template written to {output}')

Expand Down
120 changes: 52 additions & 68 deletions src/cally/cli/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,39 @@
from dataclasses import fields
from pathlib import Path
from typing import Optional, Union
from typing import Callable, ClassVar, Generic, List, Optional, TypeVar

import click
from dynaconf import Dynaconf

from . import types as cally_types
from . import config_types
from .options import CALLY_CONFIG_OPTIONS
from .validators import BASE_CALLY_CONFIG

T = TypeVar(
"T",
config_types.CallyEnvironment,
config_types.CallyProject,
config_types.CallyService,
config_types.CallyStackService,
)

class CallyConfig:
config_file: Path
_environment: Optional[str] = None
_service: Optional[str] = None
_settings: Dynaconf

def __init__(self, config_file: Path) -> None:
self.config_file = config_file
class CallyConfig(Generic[T]):
CALLY_TYPE: ClassVar[Callable]
_config_file: Path
_config: T
_settings: Dynaconf

@property
def environment(self) -> Optional[str]:
return self._environment
def config_file(self) -> Optional[Path]:
return self._config_file

@environment.setter
def environment(self, value: str):
self._environment = value
@config_file.setter
def config_file(self, value: Path):
self._config_file = value

@property
def service(self) -> Optional[str]:
return self._service

@service.setter
def service(self, value: str):
self._service = value
def _settings_kwargs(self) -> dict:
return {'loaders': []}

@property
def settings(self):
Expand All @@ -44,54 +45,37 @@ def settings(self):
settings_file=self.config_file,
merge_enabled=True,
core_loaders=[],
loaders=[
'cally.cli.config.loader',
],
validators=BASE_CALLY_CONFIG,
cally_env=self.environment,
cally_service=self.service,
**self._settings_kwargs,
)
return self._settings

def as_dataclass(self, cally_type='CallyService') -> cally_types.CallyService:
cls = getattr(cally_types, cally_type)
items = {
x.name: getattr(self.settings, x.name)
for x in fields(cally_types.CallyStackService)
if x.name in self.settings
}
return cls(**items)


def ctx_callback(
ctx: click.Context, param: click.Parameter, value: Union[str, int]
) -> Union[str, int]:
setattr(ctx.obj, str(param.name), value)
return value


def service_options(func):
"""This decorator, can be used on any custom commands where you expect
a service and environment to be set.
"""
options = [
click.option(
'--environment',
envvar='CALLY_ENVIRONMENT',
expose_value=False,
required=True,
help='Environment to operate within',
callback=ctx_callback,
),
click.option(
'--service',
envvar='CALLY_SERVICE',
expose_value=False,
required=True,
help='Service name to retrieve config details',
callback=ctx_callback,
),
]
for option in reversed(options):
func = option(func)
return func
@property
def config(
self,
) -> T:
if getattr(self, '_config', None) is None:
self._config = self.CALLY_TYPE(
**config_types.filter_dataclass_props(self.settings, self.CALLY_TYPE)
)
return self._config


class CallyConfigContext(click.Context):
def __init__(self, *args, **kwargs) -> None:
if kwargs.get('obj', None) is None:
kwargs.update(obj=CallyConfig())
super().__init__(*args, **kwargs)


class CallyCommandClass(click.Command):
context_class = CallyConfigContext
default_cally_options: List[click.Option] = CALLY_CONFIG_OPTIONS

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.params.extend(self.default_cally_options)


def CallyCommand(): # noqa: N802
return CallyCommandClass
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
from dataclasses import asdict, dataclass, field
from dataclasses import asdict, dataclass, field, fields, is_dataclass
from typing import Any, Optional

from ..exceptions import ObjectNotDataclassError


def filter_dataclass_props(data: dict, cls: Any) -> dict:
if not is_dataclass(cls):
raise ObjectNotDataclassError(f'{cls} is not of type dataclass')
return {x.name: data.get(x.name) for x in fields(cls) if x.name in data}


@dataclass
class CallyProject:
pass


@dataclass
class CallyService:
class CallyService(CallyProject):
name: str
environment: str

Expand Down Expand Up @@ -56,3 +69,8 @@ def get_stack_var(self, var: str, default: Optional[Any] = None) -> Any:

def to_dict(self) -> dict:
return asdict(self)


@dataclass
class CallyEnvironment(CallyProject):
environment: str
32 changes: 32 additions & 0 deletions src/cally/cli/config/environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import click

from . import CallyCommandClass, CallyConfig, config_types, mixins
from .options import CALLY_ENVIRONMENT_OPTIONS


class CallyEnvironmentConfig(
CallyConfig[config_types.CallyEnvironment], mixins.CallyEnvironment
):
CALLY_TYPE = config_types.CallyEnvironment

@property
def _settings_kwargs(self) -> dict:
return {
'loaders': ['cally.cli.config.loaders.environment'],
'cally_env': self.environment,
}


class CallyEnvironmentConfigContext(click.Context):
def __init__(self, *args, **kwargs) -> None:
if kwargs.get('obj', None) is None:
kwargs.update(obj=CallyEnvironmentConfig())
super().__init__(*args, **kwargs)


def CallyEnvironmentCommand(): # noqa: N802
class CallyEnvironmentCommandClass(CallyCommandClass):
context_class = CallyEnvironmentConfigContext
default_cally_options = CALLY_ENVIRONMENT_OPTIONS

return CallyEnvironmentCommandClass
27 changes: 27 additions & 0 deletions src/cally/cli/config/loaders/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from dynaconf import LazySettings


def mixin_helper(obj: LazySettings, loaded: dict, service: dict) -> None:
mixins = service.pop('mixins', [])
# Resolution Order: Global Mixins, Env Mixins, Service
# The last to be loaded wins.
for mixin in [x.strip() for x in mixins if len(x.strip()) > 0]:
obj.update(loaded.get('mixins', {}).get(mixin, {}))
obj.update(loaded.get(obj.cally_env, {}).get('mixins', {}).get(mixin, {}))
obj.update(service)


def envvar_helper(obj: LazySettings) -> None:
# Environment Variables
prefix: str = obj.envvar_prefix_for_dynaconf
remove = len(prefix) + 1 # PREFIX_ lenth
for key, val in obj.environ.items():
if not key.startswith(prefix):
continue
if key in {f'{prefix}_SERVICE', f'{prefix}_ENVIRONMENT'}:
continue
obj.update({key[remove:].lower(): val})

# Clear out Service/Environment
obj.unset(f'{prefix}_ENV')
obj.unset(f'{prefix}_SERVICE')
Loading
Loading