757 lines
22 KiB
Python
757 lines
22 KiB
Python
# type: ignore
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
from functools import lru_cache
|
|
from typing import Any, Callable, Dict, Optional, Tuple, List, Literal
|
|
|
|
import requests
|
|
from authlib.integrations.base_client.errors import OAuthError
|
|
from authlib.integrations.requests_client import OAuth2Session
|
|
from requests.adapters import HTTPAdapter
|
|
from safety_schemas.models import STAGE_ID_MAPPING, Stage
|
|
from tenacity import (
|
|
before_sleep_log,
|
|
retry,
|
|
retry_if_exception_type,
|
|
stop_after_attempt,
|
|
wait_exponential_jitter,
|
|
)
|
|
|
|
from safety.auth.constants import (
|
|
AUTH_SERVER_URL,
|
|
OPENID_CONFIG_URL,
|
|
)
|
|
from safety.constants import (
|
|
PLATFORM_API_CHECK_UPDATES_ENDPOINT,
|
|
PLATFORM_API_INITIALIZE_ENDPOINT,
|
|
PLATFORM_API_POLICY_ENDPOINT,
|
|
PLATFORM_API_PROJECT_CHECK_ENDPOINT,
|
|
PLATFORM_API_PROJECT_ENDPOINT,
|
|
PLATFORM_API_PROJECT_SCAN_REQUEST_ENDPOINT,
|
|
PLATFORM_API_PROJECT_UPLOAD_SCAN_ENDPOINT,
|
|
PLATFORM_API_REQUIREMENTS_UPLOAD_SCAN_ENDPOINT,
|
|
REQUEST_TIMEOUT,
|
|
FeatureType,
|
|
get_config_setting,
|
|
FIREWALL_AUDIT_PYPI_PACKAGES_ENDPOINT,
|
|
FIREWALL_AUDIT_NPMJS_PACKAGES_ENDPOINT,
|
|
)
|
|
from safety.error_handlers import output_exception
|
|
from safety.errors import (
|
|
InvalidCredentialError,
|
|
NetworkConnectionError,
|
|
RequestTimeoutError,
|
|
SafetyError,
|
|
ServerError,
|
|
TooManyRequestsError,
|
|
)
|
|
from safety.meta import get_meta_http_headers
|
|
from safety.models import SafetyCLI
|
|
from safety.scan.util import AuthenticationType
|
|
from safety.util import SafetyContext
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
def get_keys(
|
|
client_session: OAuth2Session, openid_config: Dict[str, Any]
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Retrieve the keys from the OpenID configuration.
|
|
|
|
Args:
|
|
client_session (OAuth2Session): The OAuth2 session.
|
|
openid_config (Dict[str, Any]): The OpenID configuration.
|
|
|
|
Returns:
|
|
Optional[Dict[str, Any]]: The keys, if available.
|
|
"""
|
|
if "jwks_uri" in openid_config:
|
|
return client_session.get(url=openid_config["jwks_uri"], bearer=False).json() # type: ignore
|
|
return None
|
|
|
|
|
|
def is_email_verified(info: Dict[str, Any]) -> Optional[bool]:
|
|
"""
|
|
Check if the email is verified.
|
|
|
|
Args:
|
|
info (Dict[str, Any]): The user information.
|
|
|
|
Returns:
|
|
bool: True
|
|
"""
|
|
# return info.get(CLAIM_EMAIL_VERIFIED_API) or info.get(
|
|
# CLAIM_EMAIL_VERIFIED_AUTH_SERVER
|
|
# )
|
|
|
|
# Always return True to avoid email verification
|
|
return True
|
|
|
|
|
|
def extract_detail(response: requests.Response) -> Optional[str]:
|
|
"""
|
|
Extract the reason from an HTTP response.
|
|
|
|
Args:
|
|
response (requests.Response): The response.
|
|
|
|
Returns:
|
|
Optional[str]: The reason.
|
|
"""
|
|
detail = None
|
|
|
|
try:
|
|
detail = response.json().get("detail")
|
|
except Exception:
|
|
LOG.debug("Failed to extract detail from response: %s", response.status_code)
|
|
|
|
return detail
|
|
|
|
|
|
def parse_response(func: Callable) -> Callable:
|
|
"""
|
|
Decorator to parse the response from an HTTP request.
|
|
|
|
Args:
|
|
func (Callable): The function to wrap.
|
|
|
|
Returns:
|
|
Callable: The wrapped function.
|
|
"""
|
|
|
|
@retry(
|
|
stop=stop_after_attempt(3),
|
|
wait=wait_exponential_jitter(initial=0.2, max=8.0, exp_base=3, jitter=0.3),
|
|
reraise=True,
|
|
retry=retry_if_exception_type(
|
|
(
|
|
NetworkConnectionError,
|
|
RequestTimeoutError,
|
|
TooManyRequestsError,
|
|
ServerError,
|
|
)
|
|
),
|
|
before_sleep=before_sleep_log(logging.getLogger("api_client"), logging.WARNING),
|
|
)
|
|
def wrapper(*args, **kwargs):
|
|
try:
|
|
r = func(*args, **kwargs)
|
|
except OAuthError as e:
|
|
LOG.exception("OAuth failed: %s", e)
|
|
raise InvalidCredentialError(
|
|
message="Your token authentication expired, try login again."
|
|
)
|
|
except requests.exceptions.ConnectionError:
|
|
raise NetworkConnectionError()
|
|
except requests.exceptions.Timeout:
|
|
raise RequestTimeoutError()
|
|
except requests.exceptions.RequestException as e:
|
|
raise e
|
|
|
|
# TODO: Handle content as JSON and fallback to text for all responses
|
|
|
|
if r.status_code == 403:
|
|
reason = extract_detail(response=r)
|
|
|
|
raise InvalidCredentialError(
|
|
credential="Failed authentication.", reason=reason
|
|
)
|
|
|
|
if r.status_code == 429:
|
|
raise TooManyRequestsError(reason=r.text)
|
|
|
|
if r.status_code >= 400 and r.status_code < 500:
|
|
error_code = None
|
|
try:
|
|
data = r.json()
|
|
reason = data.get("detail", "Unable to find reason.")
|
|
error_code = data.get("error_code", None)
|
|
except Exception:
|
|
reason = r.reason
|
|
|
|
raise SafetyError(message=reason, error_code=error_code)
|
|
|
|
if r.status_code >= 500 and r.status_code < 600:
|
|
reason = extract_detail(response=r)
|
|
LOG.debug("ServerError %s -> Response returned: %s", r.status_code, r.text)
|
|
raise ServerError(reason=reason)
|
|
|
|
data = None
|
|
|
|
try:
|
|
data = r.json()
|
|
except json.JSONDecodeError as e:
|
|
raise ServerError(message=f"Bad JSON response from the server: {e}")
|
|
|
|
return data
|
|
|
|
return wrapper
|
|
|
|
|
|
class SafetyAuthSession(OAuth2Session):
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
"""
|
|
Initialize the SafetyAuthSession.
|
|
|
|
Args:
|
|
*args (Any): Positional arguments for the parent class.
|
|
**kwargs (Any): Keyword arguments for the parent class.
|
|
"""
|
|
super().__init__(*args, **kwargs)
|
|
self.proxy_required: bool = False
|
|
self.proxy_timeout: Optional[int] = None
|
|
self.api_key = None
|
|
|
|
def get_credential(self) -> Optional[str]:
|
|
"""
|
|
Get the current authentication credential.
|
|
|
|
Returns:
|
|
Optional[str]: The API key, token, or None.
|
|
"""
|
|
if self.api_key:
|
|
return self.api_key
|
|
|
|
if self.token:
|
|
return SafetyContext().account
|
|
|
|
return None
|
|
|
|
def is_using_auth_credentials(self) -> bool:
|
|
"""
|
|
Check if the session is using authentication credentials.
|
|
|
|
This does NOT check if the client is authenticated.
|
|
|
|
Returns:
|
|
bool: True if using authentication credentials, False otherwise.
|
|
"""
|
|
return self.get_authentication_type() != AuthenticationType.none
|
|
|
|
def get_authentication_type(self) -> AuthenticationType:
|
|
"""
|
|
Get the type of authentication being used.
|
|
|
|
Returns:
|
|
AuthenticationType: The type of authentication.
|
|
"""
|
|
if self.api_key:
|
|
return AuthenticationType.api_key
|
|
|
|
if self.token:
|
|
return AuthenticationType.token
|
|
|
|
return AuthenticationType.none
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
withhold_token: bool = False,
|
|
auth: Optional[Tuple] = None,
|
|
bearer: bool = True,
|
|
**kwargs: Any,
|
|
) -> requests.Response:
|
|
"""
|
|
Make an HTTP request with the appropriate authentication.
|
|
|
|
Use the right auth parameter for Safety supported auth types.
|
|
|
|
Args:
|
|
method (str): The HTTP method.
|
|
url (str): The URL to request.
|
|
withhold_token (bool): Whether to withhold the token.
|
|
auth (Optional[Tuple]): The authentication tuple.
|
|
bearer (bool): Whether to use bearer authentication.
|
|
**kwargs (Any): Additional keyword arguments.
|
|
|
|
Returns:
|
|
requests.Response: The HTTP response.
|
|
|
|
Raises:
|
|
Exception: If the request fails.
|
|
"""
|
|
# By default use the token_auth
|
|
TIMEOUT_KEYWARD = "timeout"
|
|
func_timeout = (
|
|
kwargs[TIMEOUT_KEYWARD] if TIMEOUT_KEYWARD in kwargs else REQUEST_TIMEOUT
|
|
)
|
|
|
|
if "headers" not in kwargs:
|
|
kwargs["headers"] = {}
|
|
|
|
kwargs["headers"].update(get_meta_http_headers())
|
|
|
|
if self.api_key:
|
|
kwargs["headers"]["X-Api-Key"] = self.api_key
|
|
|
|
if not self.token or not bearer:
|
|
# Fallback to no token auth
|
|
auth = ()
|
|
|
|
# Override proxies
|
|
if self.proxies:
|
|
kwargs["proxies"] = self.proxies
|
|
|
|
if self.proxy_timeout:
|
|
kwargs["timeout"] = int(self.proxy_timeout) / 1000
|
|
|
|
if ("proxies" not in kwargs or not self.proxies) and self.proxy_required:
|
|
output_exception(
|
|
"Proxy connection is required but there is not a proxy setup.", # type: ignore
|
|
exit_code_output=True,
|
|
)
|
|
|
|
request_func = super(SafetyAuthSession, self).request
|
|
params = {
|
|
"method": method,
|
|
"url": url,
|
|
"withhold_token": withhold_token,
|
|
"auth": auth,
|
|
}
|
|
params.update(kwargs)
|
|
|
|
try:
|
|
return request_func(**params)
|
|
except Exception as e:
|
|
LOG.debug("Request failed: %s", e)
|
|
|
|
if self.proxy_required:
|
|
output_exception(
|
|
f"Proxy is required but the connection failed because: {e}", # type: ignore
|
|
exit_code_output=True,
|
|
)
|
|
|
|
if "proxies" in kwargs or self.proxies:
|
|
params["proxies"] = {}
|
|
params["timeout"] = func_timeout
|
|
self.proxies = {}
|
|
message = (
|
|
"The proxy configuration failed to function and was disregarded."
|
|
)
|
|
LOG.debug(message)
|
|
if message not in [
|
|
a["message"] for a in SafetyContext.local_announcements
|
|
]:
|
|
SafetyContext.local_announcements.append(
|
|
{"message": message, "type": "warning", "local": True}
|
|
)
|
|
|
|
return request_func(**params)
|
|
|
|
raise e
|
|
|
|
def fetch_openid_config(self) -> Any:
|
|
"""
|
|
Fetch the OpenID configuration from the authorization server.
|
|
|
|
Returns:
|
|
Any: The OpenID configuration.
|
|
"""
|
|
|
|
try:
|
|
openid_config = self.get(
|
|
url=OPENID_CONFIG_URL, timeout=REQUEST_TIMEOUT
|
|
).json()
|
|
except Exception as e:
|
|
LOG.debug("Unable to load the openID config: %s", e)
|
|
openid_config = {}
|
|
|
|
return openid_config
|
|
|
|
@parse_response
|
|
def fetch_user_info(self) -> Any:
|
|
"""
|
|
Fetch user information from the authorization server.
|
|
|
|
Returns:
|
|
Any: The user information.
|
|
"""
|
|
USER_INFO_ENDPOINT = f"{AUTH_SERVER_URL}/userinfo"
|
|
|
|
r = self.get(url=USER_INFO_ENDPOINT)
|
|
|
|
return r
|
|
|
|
@parse_response
|
|
def check_project(
|
|
self,
|
|
scan_stage: str,
|
|
safety_source: str,
|
|
project_slug: Optional[str] = None,
|
|
git_origin: Optional[str] = None,
|
|
project_slug_source: Optional[str] = None,
|
|
) -> Any:
|
|
"""
|
|
Check project information.
|
|
|
|
Args:
|
|
scan_stage (str): The scan stage.
|
|
safety_source (str): The safety source.
|
|
project_slug (Optional[str]): The project slug.
|
|
git_origin (Optional[str]): The git origin.
|
|
project_slug_source (Optional[str]): The project slug source.
|
|
|
|
Returns:
|
|
Any: The project information.
|
|
"""
|
|
|
|
data = {
|
|
"scan_stage": scan_stage,
|
|
"safety_source": safety_source,
|
|
"project_slug": project_slug,
|
|
"project_slug_source": project_slug_source,
|
|
"git_origin": git_origin,
|
|
}
|
|
|
|
r = self.post(url=PLATFORM_API_PROJECT_CHECK_ENDPOINT, json=data)
|
|
|
|
return r
|
|
|
|
@parse_response
|
|
def project(self, project_id: str) -> Any:
|
|
"""
|
|
Get project information.
|
|
|
|
Args:
|
|
project_id (str): The project ID.
|
|
|
|
Returns:
|
|
Any: The project information.
|
|
"""
|
|
data = {"project": project_id}
|
|
|
|
return self.get(url=PLATFORM_API_PROJECT_ENDPOINT, params=data)
|
|
|
|
@parse_response
|
|
def download_policy(
|
|
self, project_id: Optional[str], stage: Stage, branch: Optional[str]
|
|
) -> Any:
|
|
"""
|
|
Download the project policy.
|
|
|
|
Args:
|
|
project_id (Optional[str]): The project ID.
|
|
stage (Stage): The stage.
|
|
branch (Optional[str]): The branch.
|
|
|
|
Returns:
|
|
Any: The policy data.
|
|
"""
|
|
data = {
|
|
"project": project_id,
|
|
"stage": STAGE_ID_MAPPING[stage],
|
|
"branch": branch,
|
|
}
|
|
|
|
return self.get(url=PLATFORM_API_POLICY_ENDPOINT, params=data)
|
|
|
|
@parse_response
|
|
def project_scan_request(self, project_id: str) -> Any:
|
|
"""
|
|
Request a project scan.
|
|
|
|
Args:
|
|
project_id (str): The project ID.
|
|
|
|
Returns:
|
|
Any: The scan request result.
|
|
"""
|
|
data = {"project_id": project_id}
|
|
|
|
return self.post(url=PLATFORM_API_PROJECT_SCAN_REQUEST_ENDPOINT, json=data)
|
|
|
|
@parse_response
|
|
def upload_report(self, json_report: str) -> Any:
|
|
"""
|
|
Upload a scan report.
|
|
|
|
Args:
|
|
json_report (str): The JSON report.
|
|
|
|
Returns:
|
|
Any: The upload result.
|
|
"""
|
|
|
|
return self.post(
|
|
url=PLATFORM_API_PROJECT_UPLOAD_SCAN_ENDPOINT,
|
|
data=json_report,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
def upload_requirements(self, json_payload: str) -> Any:
|
|
"""
|
|
Upload a scan report.
|
|
Args:
|
|
json_payload (str): The JSON payload to upload.
|
|
Returns:
|
|
Any: The result of the upload operation.
|
|
"""
|
|
return self.post(
|
|
url=PLATFORM_API_REQUIREMENTS_UPLOAD_SCAN_ENDPOINT,
|
|
data=json.dumps(json_payload),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
@parse_response
|
|
def check_updates(
|
|
self,
|
|
version: int,
|
|
safety_version: Optional[str] = None,
|
|
python_version: Optional[str] = None,
|
|
os_type: Optional[str] = None,
|
|
os_release: Optional[str] = None,
|
|
os_description: Optional[str] = None,
|
|
) -> Any:
|
|
"""
|
|
Check for updates.
|
|
|
|
Args:
|
|
version (int): The version.
|
|
safety_version (Optional[str]): The Safety version.
|
|
python_version (Optional[str]): The Python version.
|
|
os_type (Optional[str]): The OS type.
|
|
os_release (Optional[str]): The OS release.
|
|
os_description (Optional[str]): The OS description.
|
|
|
|
Returns:
|
|
Any: The update check result.
|
|
"""
|
|
data = {
|
|
"version": version,
|
|
"safety_version": safety_version,
|
|
"python_version": python_version,
|
|
"os_type": os_type,
|
|
"os_release": os_release,
|
|
"os_description": os_description,
|
|
}
|
|
|
|
return self.get(url=PLATFORM_API_CHECK_UPDATES_ENDPOINT, params=data)
|
|
|
|
@parse_response
|
|
def audit_packages(
|
|
self, packages: List[str], ecosystem: Literal["pypi", "npmjs"]
|
|
) -> Any:
|
|
"""
|
|
Audits packages for vulnerabilities
|
|
Args:
|
|
packages: list of package specifiers
|
|
ecosystem: the ecosystem to audit
|
|
|
|
Returns:
|
|
Any: The packages audit result.
|
|
"""
|
|
url = (
|
|
FIREWALL_AUDIT_NPMJS_PACKAGES_ENDPOINT
|
|
if ecosystem == "npmjs"
|
|
else FIREWALL_AUDIT_PYPI_PACKAGES_ENDPOINT
|
|
)
|
|
|
|
data = {"packages": [{"package_specifier": package} for package in packages]}
|
|
|
|
return self.post(url=url, json=data)
|
|
|
|
@parse_response
|
|
def initialize(self) -> Any:
|
|
"""
|
|
Initialize a run.
|
|
|
|
Returns:
|
|
Any: The initialization result.
|
|
"""
|
|
try:
|
|
response = self.get(
|
|
url=PLATFORM_API_INITIALIZE_ENDPOINT,
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=5,
|
|
)
|
|
return response
|
|
except requests.exceptions.Timeout:
|
|
LOG.error("Auth request to initialize timed out after 5 seconds.")
|
|
except Exception:
|
|
LOG.exception("Exception trying to auth initialize", exc_info=True)
|
|
return None
|
|
|
|
|
|
class S3PresignedAdapter(HTTPAdapter):
|
|
def send( # type: ignore
|
|
self, request: requests.PreparedRequest, **kwargs: Any
|
|
) -> requests.Response:
|
|
"""
|
|
Send a request, removing the Authorization header.
|
|
|
|
Args:
|
|
request (requests.PreparedRequest): The prepared request.
|
|
**kwargs (Any): Additional keyword arguments.
|
|
|
|
Returns:
|
|
requests.Response: The response.
|
|
"""
|
|
request.headers.pop("Authorization", None)
|
|
return super().send(request, **kwargs)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def is_jupyter_notebook() -> bool:
|
|
"""
|
|
Detects if the code is running in a Jupyter notebook environment, including
|
|
various cloud-hosted Jupyter notebooks.
|
|
|
|
Returns:
|
|
bool: True if the environment is identified as a Jupyter notebook (or
|
|
equivalent cloud-based environment), False otherwise.
|
|
|
|
Supported environments:
|
|
- Google Colab
|
|
- Amazon SageMaker
|
|
- Azure Notebooks
|
|
- Kaggle Notebooks
|
|
- Databricks Notebooks
|
|
- Datalore by JetBrains
|
|
- Paperspace Gradient Notebooks
|
|
- Classic Jupyter Notebook and JupyterLab
|
|
"""
|
|
if (
|
|
(
|
|
importlib.util.find_spec("google")
|
|
and importlib.util.find_spec("google.colab")
|
|
)
|
|
is not None
|
|
or importlib.util.find_spec("sagemaker") is not None
|
|
or importlib.util.find_spec("azureml") is not None
|
|
or importlib.util.find_spec("kaggle") is not None
|
|
or importlib.util.find_spec("dbutils") is not None
|
|
or importlib.util.find_spec("datalore") is not None
|
|
or importlib.util.find_spec("gradient") is not None
|
|
):
|
|
return True
|
|
|
|
# Detect classic Jupyter Notebook, JupyterLab, and other IPython kernel-based environments
|
|
try:
|
|
from IPython import get_ipython # type: ignore
|
|
|
|
ipython = get_ipython()
|
|
if ipython is not None and "IPKernelApp" in ipython.config:
|
|
return True
|
|
except (ImportError, AttributeError, NameError):
|
|
pass
|
|
|
|
return False
|
|
|
|
|
|
def save_flags_config(flags: Dict[FeatureType, bool]) -> None:
|
|
"""
|
|
Save feature flags configuration to file.
|
|
|
|
This function attempts to save feature flags to the configuration file
|
|
but will fail silently if unable to do so (e.g., due to permission issues
|
|
or disk problems). Silent failure is chosen to prevent configuration issues
|
|
from disrupting core application functionality.
|
|
|
|
Note that if saving fails, the application will continue using existing
|
|
or default flag values until the next restart.
|
|
|
|
Args:
|
|
flags: Dictionary mapping feature types to their enabled/disabled state
|
|
|
|
The operation will be logged (with stack trace) if it fails.
|
|
"""
|
|
import configparser
|
|
|
|
from safety.constants import CONFIG_FILE_USER
|
|
|
|
config = configparser.ConfigParser()
|
|
config.read(CONFIG_FILE_USER)
|
|
|
|
flag_settings = {key.name.upper(): str(value) for key, value in flags.items()}
|
|
|
|
if not config.has_section("settings"):
|
|
config.add_section("settings")
|
|
|
|
settings = dict(config.items("settings"))
|
|
settings.update(flag_settings)
|
|
|
|
for key, value in settings.items():
|
|
config.set("settings", key, value)
|
|
|
|
try:
|
|
with open(CONFIG_FILE_USER, "w") as config_file:
|
|
config.write(config_file)
|
|
except Exception:
|
|
LOG.exception("Unable to save flags configuration.")
|
|
|
|
|
|
def get_feature_name(feature: FeatureType, as_attr: bool = False) -> str:
|
|
"""Returns a formatted feature name with enabled suffix.
|
|
|
|
Args:
|
|
feature: The feature to format the name for
|
|
as_attr: If True, formats for attribute usage (underscore),
|
|
otherwise uses hyphen
|
|
|
|
Returns:
|
|
Formatted feature name string with enabled suffix
|
|
"""
|
|
name = feature.name.lower()
|
|
separator = "_" if as_attr else "-"
|
|
return f"{name}{separator}enabled"
|
|
|
|
|
|
def str_to_bool(value) -> Optional[bool]:
|
|
"""Convert basic string representations to boolean."""
|
|
if isinstance(value, bool):
|
|
return value
|
|
|
|
if isinstance(value, str):
|
|
value = value.lower().strip()
|
|
if value in ("true"):
|
|
return True
|
|
if value in ("false"):
|
|
return False
|
|
|
|
return None
|
|
|
|
|
|
def initialize(ctx: Any, refresh: bool = True) -> None:
|
|
"""
|
|
Initializes the run by loading settings.
|
|
|
|
Args:
|
|
ctx (Any): The context object.
|
|
refresh (bool): Whether to refresh settings from the server. Defaults to True.
|
|
"""
|
|
settings = None
|
|
current_values = {}
|
|
|
|
if not ctx.obj:
|
|
ctx.obj = SafetyCLI()
|
|
|
|
for feature in FeatureType:
|
|
value = get_config_setting(feature.name)
|
|
if value is not None:
|
|
current_values[feature] = str_to_bool(value)
|
|
|
|
if refresh:
|
|
try:
|
|
settings = ctx.obj.auth.client.initialize() # type: ignore
|
|
except Exception:
|
|
LOG.info("Unable to initialize, continue with default values.")
|
|
|
|
if settings:
|
|
for feature in FeatureType:
|
|
server_value = str_to_bool(settings.get(feature.config_key))
|
|
if server_value is not None:
|
|
if (
|
|
feature not in current_values
|
|
or current_values[feature] != server_value
|
|
):
|
|
current_values[feature] = server_value
|
|
|
|
save_flags_config(current_values)
|
|
|
|
for feature, value in current_values.items():
|
|
if value is not None:
|
|
setattr(ctx.obj, feature.attr_name, value)
|