-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add
recap add
and recap remove
CLI commands
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
1 parent
5dc8bad
commit 3dfa1a1
Showing
6 changed files
with
146 additions
and
20 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -60,6 +60,7 @@ app = [ | |
"typer>=0.9.0", | ||
"uvicorn>=0.23.2", | ||
"rich>=13.5.2", | ||
"python-dotenv>=1.0.0", | ||
] | ||
|
||
[build-system] | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |