Skip to content

Commit

Permalink
Add settings module (#1044)
Browse files Browse the repository at this point in the history
Co-authored-by: Peyton Murray <peynmurray@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Jan 22, 2025
1 parent c8f8a81 commit a7a6390
Show file tree
Hide file tree
Showing 17 changed files with 618 additions and 174 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@

class CondaLock(lock.LockPlugin):
def _conda_command(self, conda_store) -> str:
with conda_store.session_factory() as db:
settings = conda_store.get_settings(db=db)
settings = conda_store.get_settings()
return settings.conda_command

def _conda_flags(self, conda_store) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,6 @@ def start(self):
dbutil.upgrade(self.conda_store.config.database_url)

with self.conda_store.session_factory() as db:
self.conda_store.ensure_settings(db)
self.conda_store.ensure_namespace(db)
self.conda_store.ensure_conda_channels(db)

Expand Down
84 changes: 40 additions & 44 deletions conda-store-server/conda_store_server/_internal/server/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1429,28 +1429,25 @@ async def api_get_settings(
namespace: str = None,
environment_name: str = None,
):
with conda_store.get_db() as db:
if namespace is None:
arn = ""
elif environment_name is None:
arn = namespace
else:
arn = f"{namespace}/{environment_name}"

auth.authorize_request(
request,
arn,
{Permissions.SETTING_READ},
require=True,
)
if namespace is None:
arn = ""
elif environment_name is None:
arn = namespace
else:
arn = f"{namespace}/{environment_name}"

auth.authorize_request(
request,
arn,
{Permissions.SETTING_READ},
require=True,
)

return {
"status": "ok",
"data": conda_store.get_settings(
db, namespace, environment_name
).model_dump(),
"message": None,
}
return {
"status": "ok",
"data": conda_store.get_settings(namespace, environment_name).model_dump(),
"message": None,
}


@router_api.put(
Expand All @@ -1473,28 +1470,27 @@ async def api_put_settings(
namespace: str = None,
environment_name: str = None,
):
with conda_store.get_db() as db:
if namespace is None:
arn = ""
elif environment_name is None:
arn = namespace
else:
arn = f"{namespace}/{environment_name}"
if namespace is None:
arn = ""
elif environment_name is None:
arn = namespace
else:
arn = f"{namespace}/{environment_name}"

auth.authorize_request(
request,
arn,
{Permissions.SETTING_UPDATE},
require=True,
)

auth.authorize_request(
request,
arn,
{Permissions.SETTING_UPDATE},
require=True,
)
try:
conda_store.set_settings(namespace, environment_name, data)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

try:
conda_store.set_settings(db, namespace, environment_name, data)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e.args[0]))

return {
"status": "ok",
"data": None,
"message": f"global setting keys {list(data.keys())} updated",
}
return {
"status": "ok",
"data": None,
"message": f"global setting keys {list(data.keys())} updated",
}
59 changes: 29 additions & 30 deletions conda-store-server/conda_store_server/_internal/server/views/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,34 +293,33 @@ async def ui_get_setting(
namespace: str = None,
environment_name: str = None,
):
with conda_store.get_db() as db:
if namespace is None:
arn = ""
elif environment_name is None:
arn = namespace
else:
arn = f"{namespace}/{environment_name}"

auth.authorize_request(
request,
arn,
{Permissions.SETTING_READ},
require=True,
)

api_setting_url = str(request.url_for("api_put_settings"))
if namespace is not None:
api_setting_url += f"{namespace}/"
if environment_name is not None:
api_setting_url += f"{environment_name}/"

context = {
"request": request,
"namespace": namespace,
"environment_name": environment_name,
"api_settings_url": api_setting_url,
"settings": conda_store.get_settings(
db, namespace=namespace, environment_name=environment_name
),
}
if namespace is None:
arn = ""
elif environment_name is None:
arn = namespace
else:
arn = f"{namespace}/{environment_name}"

auth.authorize_request(
request,
arn,
{Permissions.SETTING_READ},
require=True,
)

api_setting_url = str(request.url_for("api_put_settings"))
if namespace is not None:
api_setting_url += f"{namespace}/"
if environment_name is not None:
api_setting_url += f"{environment_name}/"

context = {
"request": request,
"namespace": namespace,
"environment_name": environment_name,
"api_settings_url": api_setting_url,
"settings": conda_store.get_settings(
namespace=namespace, environment_name=environment_name
),
}
return templates.TemplateResponse(request, "setting.html", context)
178 changes: 178 additions & 0 deletions conda-store-server/conda_store_server/_internal/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Copyright (c) conda-store development team. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.

import functools
from typing import Any, Callable, Dict

import pydantic
from sqlalchemy.orm import Session

from conda_store_server import api
from conda_store_server._internal import schema


class Settings:
def __init__(self, db: Session, deployment_default: schema.Settings):
self.db = db
self.deployment_default = deployment_default.model_dump()

def _ensure_closed_session(func: Callable):
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
result = func(self, *args, **kwargs)
self.db.close()
return result

return wrapper

@_ensure_closed_session
def set_settings(
self,
namespace: str | None = None,
environment_name: str | None = None,
data: Dict[str, Any] | None = None,
):
"""Persist settings to the database
Parameters
----------
namespace : str, optional
namespace to use to retrieve settings
environment_name : str, optional
environment to use to retrieve settings
data : dict
settings to upsert
"""
if data is None:
return

# if an environment is specified without a namespace, it is not possible to
# determine which environment the setting should be applied to. Raise an error.
if namespace is None and environment_name is not None:
raise ValueError(
f"Environment {environment_name} is set without a namespace. Please specify a namespace."
)

setting_keys = schema.Settings.model_fields.keys()
if not data.keys() <= setting_keys:
invalid_keys = data.keys() - setting_keys
raise ValueError(f"Invalid setting keys {invalid_keys}")

for key, value in data.items():
field = schema.Settings.model_fields[key]
global_setting = field.json_schema_extra["metadata"]["global"]
if global_setting and (
namespace is not None or environment_name is not None
):
raise ValueError(
f"Setting {key} is a global setting and cannot be set within a namespace or environment"
)

validator = pydantic.TypeAdapter(field.annotation)
validator.validate_python(value)

if namespace is not None and environment_name is not None:
prefix = f"setting/{namespace}/{environment_name}"
elif namespace is not None:
prefix = f"setting/{namespace}"
else:
prefix = "setting"

api.set_kvstore_key_values(self.db, prefix, data)

@_ensure_closed_session
def get_settings(
self, namespace: str | None = None, environment_name: str | None = None
) -> schema.Settings:
"""Get full schema.settings object for a given level of specificity.
If no namespace or environment is given, then the default settings
are returned. Settings merged follow the merge rules for updating
dict's. So, lists and dict fields are overwritten opposed to merged.
Parameters
----------
namespace : str, optional
namespace to use to retrieve settings
environment_name : str, optional
environment to use to retrieve settings
Returns
-------
schema.Settings
merged settings object
"""
# build default/global settings object
settings = self.deployment_default
settings.update(api.get_kvstore_key_values(self.db, "setting"))

# bulid list of prefixes to check from least precidence to highest precedence
prefixes = []
if namespace is not None:
prefixes.append(f"setting/{namespace}")
if namespace is not None and environment_name is not None:
prefixes.append(f"setting/{namespace}/{environment_name}")

if len(prefixes) > 0:
# get the fields that scoped globally. These are the keys that will NOT be
# merged on for namespace and environment prefixes.
global_fields = [
k
for k, v in schema.Settings.model_fields.items()
if v.json_schema_extra["metadata"]["global"]
]
# start building settings with the least specific defaults
for prefix in prefixes:
new_settings = api.get_kvstore_key_values(self.db, prefix)
# remove any global fields
new_settings = {
k: v for k, v in new_settings.items() if k not in global_fields
}
settings.update(new_settings)

return schema.Settings(**settings)

@_ensure_closed_session
def get_setting(
self,
key: str,
namespace: str | None = None,
environment_name: str | None = None,
) -> Any: # noqa: ANN401
"""Get a given setting at the given level of specificity. Will short
cut and look up global setting directly even if a namespace/environment
is specified
Parameters
----------
key : str
name of the setting to return
namespace : str, optional
namespace to use to retrieve settings
environment_name : str, optional
environment to use to retrieve settings
Returns
-------
Any
setting value, merged for the given level of specificity
"""
field = schema.Settings.model_fields.get(key)
if field is None:
return None

prefixes = ["setting"]
if field.json_schema_extra["metadata"]["global"] is False:
if namespace is not None:
prefixes.append(f"setting/{namespace}")
if environment_name is not None:
prefixes.append(f"setting/{namespace}/{environment_name}")

# start building settings with the least specific defaults
result = self.deployment_default.get(key)
for prefix in prefixes:
value = api.get_kvstore_key(self.db, prefix, key)
if value is not None:
result = value

return result
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,6 @@ def build_conda_environment(db: Session, conda_store, build):
)

settings = conda_store.get_settings(
db=db,
namespace=build.environment.namespace.name,
environment_name=build.environment.name,
)
Expand Down Expand Up @@ -361,7 +360,6 @@ def solve_conda_environment(db: Session, conda_store, solve: orm.Solve):
def build_conda_env_export(db: Session, conda_store, build: orm.Build):
conda_prefix = build.build_path(conda_store)
settings = conda_store.get_settings(
db=db,
namespace=build.environment.namespace.name,
environment_name=build.environment.name,
)
Expand Down Expand Up @@ -431,7 +429,6 @@ def build_constructor_installer(db: Session, conda_store, build: orm.Build):
conda_prefix = build.build_path(conda_store)

settings = conda_store.get_settings(
db=db,
namespace=build.environment.namespace.name,
environment_name=build.environment.name,
)
Expand Down
Loading

0 comments on commit a7a6390

Please sign in to comment.