Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
21e991b
Phase 1: Add core infrastructure for service layer
NiklasRosenstein Nov 29, 2025
2d41e67
Phase 2: Add domain models and error types
NiklasRosenstein Nov 29, 2025
6965a74
Phase 3: Extract ManifestLoader and NamespaceResolver services
NiklasRosenstein Nov 29, 2025
a12e5ba
Phase 4: Add advanced services (Templating, Profile, KubernetesApply)
NiklasRosenstein Nov 29, 2025
1195071
Integrate ManifestLoaderService into template and add commands
NiklasRosenstein Nov 29, 2025
adc3b19
Integrate NamespaceResolverService into template command
NiklasRosenstein Nov 29, 2025
f1aa7f7
Integrate KubernetesApplyService into template command
NiklasRosenstein Nov 29, 2025
03aea59
fmt lint
NiklasRosenstein Nov 29, 2025
794290d
Address GitHub Copilot code review feedback
NiklasRosenstein Nov 29, 2025
16b5816
Integrate TemplatingService into template command
NiklasRosenstein Nov 29, 2025
3844ddf
Integrate ProfileService into run command
NiklasRosenstein Nov 29, 2025
9c22659
Fix type annotations in NamespaceResolverService
NiklasRosenstein Nov 29, 2025
54eec37
Fix remaining type errors in production code
NiklasRosenstein Nov 29, 2025
e1c48c2
Fix all type annotations in test files
NiklasRosenstein Nov 30, 2025
5ff8346
update uv.lock
NiklasRosenstein Nov 30, 2025
3403987
Remove low-value tests
NiklasRosenstein Nov 30, 2025
67c6b87
fmt
NiklasRosenstein Nov 30, 2025
15e59bf
fix DI example doctest
NiklasRosenstein Nov 30, 2025
a7ea59e
Complete migration from PROVIDER to DIContainer
NiklasRosenstein Nov 30, 2025
f69c8d4
Integrate ExecutionContext and TemplateContext throughout all commands
NiklasRosenstein Nov 30, 2025
0d20d40
remove .claude and .tire
NiklasRosenstein Nov 30, 2025
6e8d96a
Remove old DI system and complete migration to single DIContainer
NiklasRosenstein Nov 30, 2025
24deb15
remove CLAUDE.md
NiklasRosenstein Nov 30, 2025
8394e51
Remove ExecutionContext from commands
NiklasRosenstein Nov 30, 2025
c4cbdaf
Replace TemplateContext boolean fields with Literal mode
NiklasRosenstein Nov 30, 2025
8fae40f
Delete ExecutionContext class
NiklasRosenstein Nov 30, 2025
52995c8
Code quality improvements for DI system
NiklasRosenstein Nov 30, 2025
74bc41b
Simplify dependency injection documentation
NiklasRosenstein Nov 30, 2025
dc59d02
fmt
NiklasRosenstein Nov 30, 2025
95767d3
Remove duplicate namespace-finding logic from KubernetesApplyService
NiklasRosenstein Nov 30, 2025
4b7c2c7
Integrate TemplatingService into template command
NiklasRosenstein Jan 14, 2026
1adb143
add CLAUDE.md
NiklasRosenstein Jan 14, 2026
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 CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Lint code with `tire lint`, format with `tire fmt (--fix)` and type-check with `tire check`
111 changes: 111 additions & 0 deletions docs/content/development/dependency-injection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Dependency Injection in Nyl

## Overview

Nyl uses a custom dependency injection (DI) system to manage dependencies across commands and services. Each command creates its own request-scoped container, ensuring clean separation of concerns and making testing easier.

## Core Concepts

### DIContainer

The `DIContainer` class (`src/nyl/core/di.py`) provides:

- **Factory registration**: Create instances on demand
- **Singleton registration**: Reuse pre-created instances
- **Type-safe resolution**: Resolve dependencies by type

```python
from nyl.core import DIContainer

container = DIContainer()

# Register a factory
container.register_factory(MyService, lambda: MyService())

# Register a singleton
container.register_singleton(Config, Config())

# Resolve dependencies
service = container.resolve(MyService)
```

### Container Setup Functions

**setup_base_container()** - Registers core dependencies:
- `ProfileManager`
- `ProjectConfig`
- `SecretsConfig`
- `ApiClient`

**setup_service_container()** - Registers service layer:
- `ManifestLoaderService`
- `NamespaceResolverService`
- `KubernetesApplyService`

## Adding New Commands

```python
from pathlib import Path
from nyl.core import DIContainer, setup_base_container
from nyl.project.config import ProjectConfig

@app.command()
def my_command() -> None:
# Create request-scoped container
container = DIContainer()
setup_base_container(container)

# Resolve dependencies
project = container.resolve(ProjectConfig)

# ... command logic ...
```

**Key principles:**
- Create new `DIContainer()` for each command invocation
- Call `setup_base_container()` for core dependencies
- Resolve services via `container.resolve()`

## Adding New Services

1. **Create service class** in `src/nyl/services/`:

```python
class MyNewService:
def do_something(self) -> None:
pass
```

2. **Register in container_setup.py**:

```python
def setup_service_container(container: DIContainer, **kwargs) -> None:
# Stateless service - use singleton
container.register_singleton(MyNewService, MyNewService())

# OR with dependencies:
def _create_service() -> MyNewService:
dep = container.resolve(Dependency)
return MyNewService(dep)

container.register_factory(MyNewService, _create_service)
```

3. **Use in commands**:

```python
service = container.resolve(MyNewService)
service.do_something()
```

## Best Practices

- **Request-scoped containers**: Always create new container per command
- **Factory vs Singleton**: Use factories for lazy init, singletons for stateless services
- **Type safety**: Always resolve by type, not string

## Further Reading

- `src/nyl/core/di.py` - DIContainer implementation
- `src/nyl/core/container_setup.py` - Setup functions
- `src/nyl/services/` - Service implementations
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"pyroscope-io>=0.8.11",
"pyyaml>=6.0.1",
"requests>=2.32.3",
"rich>=13.7.0",
"stablehash>=0.2.1,<0.3.0",
"structured-templates>=0.1.1",
"typer>=0.12.3",
Expand Down
38 changes: 4 additions & 34 deletions src/nyl/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,14 @@
import os
import shlex
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional

from kubernetes.client.api_client import ApiClient
from loguru import logger
from typer import Option, Typer

from nyl import __version__
from nyl.profiles import ProfileManager
from nyl.project.config import ProjectConfig
from nyl.secrets.config import SecretsConfig
from nyl.tools.di import DependenciesProvider
from nyl.tools.logging import lazy_str
from nyl.tools.pyroscope import init_pyroscope, tag_wrapper
from nyl.tools.shell import pretty_cmd
Expand All @@ -29,12 +23,9 @@

app: Typer = new_typer(help=__doc__)

# A global instance that we use for dependency injection.
PROVIDER = DependenciesProvider.default()

LOG_TIME_FORMAT = "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green>"
LOG_LEVEL_FORAMT = "<level>{level: <8}</level>"
LOG_DETAILS_FORMAT = "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan>"
LOG_LEVEL_FORMAT = "<level>{level: <8}</level>"
LOG_DETAILS_FORMAT = "<cyan>{name}</cyan>:<cyadn>{function}</cyan>:<cyan>{line}</cyan>"
LOG_MESSAGE_FORMAT = "<level>{message}</level>"


Expand All @@ -47,15 +38,6 @@ class LogLevel(str, Enum):
CRITICAL = "critical"


# Retrieving the Kubernetes API client depends on whether in-cluster configuration should be used or not.
@dataclass(kw_only=True)
class ApiClientConfig:
in_cluster: bool
" Load the in-cluster configuration if enabled; forego any Nyl profile configuration. "
profile: str | None
" If not loading the in-cluster configuration, use the given Nyl profile. Otherwise, use the default kubeconfig. "


@app.callback()
def _callback(
quiet: bool = Option(False, "--quiet", "-q", help="Shortcut for --log-level=error."),
Expand All @@ -70,9 +52,9 @@ def _callback(
log_file: Optional[Path] = Option(None, help="Additionally log to the given file."),
) -> None:
if log_details:
fmt = f"{LOG_TIME_FORMAT} | {LOG_LEVEL_FORAMT} | {LOG_DETAILS_FORMAT} | {LOG_MESSAGE_FORMAT}"
fmt = f"{LOG_TIME_FORMAT} | {LOG_LEVEL_FORMAT} | {LOG_DETAILS_FORMAT} | {LOG_MESSAGE_FORMAT}"
else:
fmt = f"{LOG_TIME_FORMAT} | {LOG_LEVEL_FORAMT} | {LOG_MESSAGE_FORMAT}"
fmt = f"{LOG_TIME_FORMAT} | {LOG_LEVEL_FORMAT} | {LOG_MESSAGE_FORMAT}"

logger.remove()
logger.level("METRIC", 40, "<green><bold>")
Expand Down Expand Up @@ -101,18 +83,6 @@ def _callback(
log_env["NYL_PYROSCOPE_URL"] = url_extract_basic_auth(log_env["NYL_PYROSCOPE_URL"], mask=True)[0]
logger.debug("Nyl-relevant environment variables: {}", lazy_str(json.dumps, log_env, indent=2))

PROVIDER.set_lazy(ProfileManager, lambda: ProfileManager.load(required=False))
PROVIDER.set_lazy(SecretsConfig, lambda: SecretsConfig.load(dependencies=PROVIDER))
PROVIDER.set_lazy(ProjectConfig, lambda: ProjectConfig.load(dependencies=PROVIDER))
PROVIDER.set_lazy(
ApiClient,
lambda: template.get_incluster_kubernetes_client()
if PROVIDER.get(ApiClientConfig).in_cluster
else template.get_profile_kubernetes_client(
PROVIDER.get(ProfileManager), PROVIDER.get(ApiClientConfig).profile
),
)


@app.command()
def version() -> None:
Expand Down
6 changes: 3 additions & 3 deletions src/nyl/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@

from nyl.commands.template import (
DEFAULT_NAMESPACE_ANNOTATION,
ManifestsWithSource,
is_namespace_resource,
load_manifests,
)
from nyl.resources import ObjectMetadata
from nyl.resources.helmchart import ChartRef, HelmChart, HelmChartSpec
from nyl.services.manifest import ManifestLoaderService, ManifestsWithSource
from nyl.tools.typer import new_typer
from nyl.tools.types import ResourceList

Expand All @@ -39,7 +38,8 @@ def namespace(

if manifest_file.exists():
content = manifest_file.read_text()
manifest = load_manifests([manifest_file])[0]
manifest_loader = ManifestLoaderService()
manifest = manifest_loader.load_manifests([manifest_file])[0]
else:
content = ""
manifest = ManifestsWithSource(ResourceList([]), manifest_file)
Expand Down
7 changes: 5 additions & 2 deletions src/nyl/commands/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from loguru import logger
from typer import Argument, Option, Typer

from nyl.commands import PROVIDER
from nyl.core import DIContainer, setup_base_container
from nyl.project.config import ProjectConfig
from nyl.tools.typer import new_typer

Expand Down Expand Up @@ -130,5 +130,8 @@ def component(
directory).
"""

components_path = PROVIDER.get(ProjectConfig).get_components_path()
container = DIContainer()
setup_base_container(container)

components_path = container.resolve(ProjectConfig).get_components_path()
chart(components_path / api_version / kind)
12 changes: 9 additions & 3 deletions src/nyl/commands/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from typer import Argument, Typer

from nyl.commands import PROVIDER
from nyl.core import DIContainer, setup_base_container
from nyl.profiles import ProfileManager
from nyl.tools.typer import new_typer

Expand All @@ -21,7 +21,10 @@ def activate(profile_name: str = Argument("default", envvar="NYL_PROFILE")) -> N
Evaluate the stdout of this command to export the KUBECONFIG into your environment.
"""

with PROVIDER.get(ProfileManager) as manager:
container = DIContainer()
setup_base_container(container)

with container.resolve(ProfileManager) as manager:
profile = manager.activate_profile(profile_name)

for key, value in profile.env.items():
Expand All @@ -34,7 +37,10 @@ def get_kubeconfig(profile_name: str = Argument("default", envvar="NYL_PROFILE")
Similar to `nyl profile activate`, but prints only the path to the `KUBECONFIG` file.
"""

with PROVIDER.get(ProfileManager) as manager:
container = DIContainer()
setup_base_container(container)

with container.resolve(ProfileManager) as manager:
profile = manager.activate_profile(profile_name)

print(profile.kubeconfig)
81 changes: 26 additions & 55 deletions src/nyl/commands/run.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import atexit
import os
import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory

from loguru import logger
from typer import Argument, Option

from nyl.commands import PROVIDER
from nyl.profiles import ActivatedProfile, ProfileManager
from nyl.profiles.kubeconfig import _trim_to_context
from nyl.tools import yaml
from nyl.core import DIContainer, setup_base_container
from nyl.profiles import ProfileManager
from nyl.services.profile import ProfileService
from nyl.tools.logging import lazy_str
from nyl.tools.shell import pretty_cmd

Expand Down Expand Up @@ -44,55 +40,30 @@ def run(
`nyl-profiles.yaml` configuration or from the same-named context in the global kubeconfig.
"""

manager = PROVIDER.get(ProfileManager)
if manager and profile_name in manager.config.profiles:
with manager:
profile = manager.activate_profile(profile_name)
kind = "profile"
kubeconfig = profile.kubeconfig
else:
# Check if the context exists in the kubeconfig.
kubeconfig = Path(os.environ.get("KUBECONFIG", "~/.kube/config")).expanduser()
if not kubeconfig.is_file():
logger.opt(colors=True).info("Profile <yellow>{}</> not found.", profile_name)
sys.exit(1)
# Create DI container for this command execution
container = DIContainer()
setup_base_container(container)

try:
kubeconfig_data = yaml.loads(kubeconfig.read_text())
kubeconfig_data = _trim_to_context(kubeconfig_data, profile_name)
except ValueError:
logger.debug("Failed to parse the kubeconfig file/find context '{}'.", profile_name)
logger.opt(colors=True).info("Profile <yellow>{}</> not found.", profile_name)
sys.exit(1)
else:
if not inherit_kubeconfig:
logger.opt(colors=True).error(
"Found context <yellow>{}</> in the kubeconfig ({}), but no Nyl profile with that name. "
"Consider using --inherit-kubeconfig,-I to run the command in that Kubernetes context.",
profile_name,
kubeconfig,
)
sys.exit(1)
manager = container.resolve(ProfileManager)
profile_service = ProfileService(manager)

logger.opt(colors=True).info(
"Falling back to context <yellow>{}</> from the kubeconfig ({}) due to --inherit-kubeconfig,-I option.",
profile_name,
kubeconfig,
)
kind = "context"

# Write the kubeconfig to a temporary file.
tmpdir = TemporaryDirectory()
atexit.register(tmpdir.cleanup)
kubeconfig = Path(tmpdir.name) / "kubeconfig"
kubeconfig.write_text(yaml.dumps(kubeconfig_data))
kubeconfig.chmod(0o600)

profile = ActivatedProfile(kubeconfig)
logger.opt(colors=True).info(
"Running command `<blue>{}</>` with {} <yellow>{}</>.",
lazy_str(pretty_cmd, command),
kind,
# Use ProfileService to resolve profile or kubeconfig context
profile = profile_service.resolve_profile(
profile_name,
inherit_kubeconfig=inherit_kubeconfig,
required=True,
)
sys.exit(subprocess.run(command, env={**os.environ, **profile.env}).returncode)
assert profile is not None # required=True ensures profile is returned

# Determine if we're using a Nyl profile or kubeconfig context
kind = "profile" if manager and profile_name in manager.config.profiles else "context"

# Use ActivatedProfile as context manager to ensure cleanup
with profile:
logger.opt(colors=True).info(
"Running command `<blue>{}</>` with {} <yellow>{}</>.",
lazy_str(pretty_cmd, command),
kind,
profile_name,
)
sys.exit(subprocess.run(command, env={**os.environ, **profile.env}).returncode)
Loading
Loading