Skip to content

Commit

Permalink
Add recap add and recap remove CLI commands
Browse files Browse the repository at this point in the history
Users can now add and remove systems from `~/.recap/config` via the CLI:

```bash
$ recap add my_pg_instance postgresql://user:pass@host:1234/some_db
$ recap remove my_pg_instance
```

The `~/.recap/config` file is treated as a `.env` file. `.env` files continue to
work, and overwrite whatever is set in `~/.recap/config`.

Users may also override the location of `~/.recap/config` using the
`RECAP_CONFIG` environment variable.
  • Loading branch information
criccomini committed Aug 31, 2023
1 parent 5dc8bad commit 3dfa1a1
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 20 deletions.
2 changes: 1 addition & 1 deletion pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ app = [
"typer>=0.9.0",
"uvicorn>=0.23.2",
"rich>=13.5.2",
"python-dotenv>=1.0.0",
]

[build-system]
Expand Down
24 changes: 24 additions & 0 deletions recap/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rich import print_json

from recap import commands
from recap.settings import set_config, unset_config
from recap.types import to_dict

app = typer.Typer()
Expand All @@ -27,3 +28,26 @@ def schema(path: Annotated[str, typer.Argument(help="Path to get schema of.")]):

if recap_struct := commands.schema(path):
print_json(data=to_dict(recap_struct))


@app.command()
def add(
system: Annotated[str, typer.Argument(help="User-defined name of the system.")],
url: Annotated[str, typer.Argument(help="URL for the system.")],
):
"""
Add a system to the config.
"""

set_config(f"RECAP_SYSTEMS__{system}", url)


@app.command()
def remove(
system: Annotated[str, typer.Argument(help="User-defined name of the system.")]
):
"""
Remove a system from the config.
"""

unset_config(f"RECAP_SYSTEMS__{system}")
34 changes: 33 additions & 1 deletion recap/settings.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,43 @@
import os
from pathlib import Path

from dotenv import load_dotenv, set_key, unset_key
from pydantic import AnyUrl, Field
from pydantic_settings import BaseSettings, SettingsConfigDict

load_dotenv()

CONFIG_FILE = os.environ.get("RECAP_CONFIG") or os.path.expanduser("~/.recap/config")
SECRETS_DIR = os.environ.get("RECAP_SECRETS")


def touch_config():
config_path = Path(CONFIG_FILE)
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.touch(mode=0o600, exist_ok=True)
if SECRETS_DIR:
secrets_path = Path(SECRETS_DIR)
secrets_path.mkdir(mode=0o700, parents=True, exist_ok=True)


touch_config()


class RecapSettings(BaseSettings):
systems: dict[str, AnyUrl] = Field(default_factory=dict)
model_config = SettingsConfigDict(
env_file=".env",
# .env takes priority over CONFIG_FILE
env_file=[CONFIG_FILE, ".env"],
env_file_encoding="utf-8",
env_prefix="recap_",
env_nested_delimiter="__",
secrets_dir=SECRETS_DIR,
)


def set_config(key: str, val: str):
set_key(CONFIG_FILE, key.upper(), val)


def unset_config(key: str):
unset_key(CONFIG_FILE, key.upper())
70 changes: 52 additions & 18 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,67 @@
import os
import tempfile
from importlib import reload
from json import loads
from unittest.mock import patch

from pydantic import AnyUrl
from typer.testing import CliRunner

import recap.settings
from recap.cli import app
from recap.types import IntType, StructType

runner = CliRunner()


@patch("recap.commands.ls")
def test_ls_root(mock_ls):
mock_ls.return_value = ["foo", "bar"]
result = runner.invoke(app, ["ls"])
assert result.exit_code == 0
assert loads(result.stdout) == mock_ls.return_value
class TestCli:
@classmethod
def setup_class(cls):
# Create a temporary .env file for the test
cls.temp_file = tempfile.NamedTemporaryFile(delete=False)
cls.temp_file_name = cls.temp_file.name

@classmethod
def teardown_class(cls):
# Delete the temporary .env file
os.unlink(cls.temp_file_name)

@patch("recap.commands.ls")
def test_ls_subpath(mock_ls):
mock_ls.return_value = ["foo", "bar"]
result = runner.invoke(app, ["ls", "/foo/bar"])
assert result.exit_code == 0
assert loads(result.stdout) == mock_ls.return_value
@patch("recap.commands.ls")
def test_ls_root(self, mock_ls):
mock_ls.return_value = ["foo", "bar"]
result = runner.invoke(app, ["ls"])
assert result.exit_code == 0
assert loads(result.stdout) == mock_ls.return_value

@patch("recap.commands.ls")
def test_ls_subpath(self, mock_ls):
mock_ls.return_value = ["foo", "bar"]
result = runner.invoke(app, ["ls", "/foo/bar"])
assert result.exit_code == 0
assert loads(result.stdout) == mock_ls.return_value

@patch("recap.commands.schema")
def test_schema(mock_schema):
mock_schema.return_value = StructType([IntType(bits=32)])
result = runner.invoke(app, ["schema", "foo"])
assert result.exit_code == 0
assert loads(result.stdout) == {"type": "struct", "fields": ["int32"]}
@patch("recap.commands.schema")
def test_schema(self, mock_schema):
mock_schema.return_value = StructType([IntType(bits=32)])
result = runner.invoke(app, ["schema", "foo"])
assert result.exit_code == 0
assert loads(result.stdout) == {"type": "struct", "fields": ["int32"]}

def test_add_remove(self):
with patch.dict(os.environ, {"RECAP_CONFIG": self.temp_file_name}, clear=False):
# Reload after patching to reset CONFIG_FILE
reload(recap.settings)
from recap.settings import RecapSettings

# Test set_config
url = AnyUrl("scheme://user:pw@localhost:1234/db")
result = runner.invoke(app, ["add", "test_system", url.unicode_string()])
assert result.exit_code == 0
assert result.stdout == ""
assert RecapSettings().systems.get("test_system") == url

# Test unset_config
result = runner.invoke(app, ["remove", "test_system"])
assert result.exit_code == 0
assert result.stdout == ""
assert RecapSettings().systems.get("test_system") is None
35 changes: 35 additions & 0 deletions tests/unit/test_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import os
import tempfile
from importlib import reload
from unittest.mock import patch

from pydantic import AnyUrl

import recap.settings


class TestSettings:
@classmethod
def setup_class(cls):
# Create a temporary .env file for the test
cls.temp_file = tempfile.NamedTemporaryFile(delete=False)
cls.temp_file_name = cls.temp_file.name

@classmethod
def teardown_class(cls):
# Delete the temporary .env file
os.unlink(cls.temp_file_name)

def test_set_and_unset_config(self):
with patch.dict(os.environ, {"RECAP_CONFIG": self.temp_file_name}, clear=False):
# Reload after patching to reset CONFIG_FILE
reload(recap.settings)
from recap.settings import RecapSettings, set_config, unset_config

# Test set_config
set_config("recap_systems__test_key", "foo://test_value")
assert RecapSettings().systems.get("test_key") == AnyUrl("foo://test_value")

# Test unset_config
unset_config("recap_systems__test_key")
assert RecapSettings().systems.get("test_key") is None

0 comments on commit 3dfa1a1

Please sign in to comment.