Skip to content

Commit

Permalink
Desktop robustness updates (#53)
Browse files Browse the repository at this point in the history
* update path defaults

* add new configuration parameter configuration

* allow specification of configuration; more gracefully handle errors

* revise how settings are loaded

* check for free ports

* disable check for now

* set current config version to a variable

* update

* add some basic readme docs

* make configuration name based on config version.
store static files based on bmds-ui version

* fix w/ upstream bmds main

* hyphen
  • Loading branch information
shapiromatron authored Aug 3, 2024
1 parent bb76a92 commit bfbc081
Show file tree
Hide file tree
Showing 16 changed files with 206 additions and 91 deletions.
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,31 @@
# BMDS UI

A user interface foe execution of dose-response data using the US EPA's Benchmark Dose Modeling Software [BMDS](https://www.epa.gov/bmds). Under the hood, this web application uses the [BMDS Python interface](https://pypi.python.org/pypi/bmds).
BMDS UI is a user interface for running [pybmds](https://pypi.org/project/pybmds/), a python package for dose-response modeling using the US EPA's Benchmark Dose Modeling Software ([BMDS](https://www.epa.gov/bmds)). The user interface is a web application, but has two different deployment options:

* It can deployed as a web application, such as [BMDS Online](https://bmdsonline.epa.gov)
* It can be deployed locally as a desktop application, which we call **BMDS Desktop**.


**BMDS Desktop application home screen:**
![](./docs/img/bmds-desktop.jpg)

**An example of the the user interface for model results:**
![](./docs/img/bmds-output.jpg)


## BMDS Online vs. BMDS Desktop

BMDS Desktop is designed to run locally on your desktop computer in fully offline mode; it does not interact with any resources on the internet after initial installation. BMDS Online can be deployed online publicly or internally at a company or organization. Key differences between the software are described below:

**Item**|**Desktop**|**Online**
:-----:|:-----:|:-----:
Permission|BMDS Desktop runs on your computer in fully offline mode.|No login is required for the online application. An administrative account can be used to view the admin page
Analysis Visibility|The Desktop home page all analyses in a database|You must have the URL to an analysis in order to view
Analysis Deletion|Analyses have no deletion date|Analyses are automatically deleted on N days from current date, where N is a configurable parameter
Analysis organization|Analyses can be starred and labelled, and you can search on stars and labels|Analyses have no filtering available
Database technology|Uses a sqlite database file (single file)|Uses a PostgreSQL database (better for concurrency)

### BMDS Desktop Startup Screen

The BMDS Desktop has a startup screen where you can select which database file you'd like to use in your application. You can have multiple databases on your computer, one per project for example:
![](./docs/img/desktop-startup.jpg)
29 changes: 26 additions & 3 deletions bmds_ui/desktop/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from .. import __version__
from ..main.settings import desktop
from .config import Database, DesktopConfig
from .config import Database, DesktopConfig, get_app_home, get_version_path
from .log import log, stream


Expand All @@ -29,7 +29,24 @@ def sync_persistent_data():

def setup_django_environment(db: Database):
"""Set the active django database to the current path and setup the database."""
app_home = get_app_home()

desktop.DATABASES["default"]["NAME"] = str(db.path)

version = get_version_path(__version__)
public_data_root = app_home / "public" / version
logs_path = app_home / "logs" / version

public_data_root.mkdir(exist_ok=True, parents=False)
logs_path.mkdir(exist_ok=True, parents=False)

desktop.PUBLIC_DATA_ROOT = public_data_root
desktop.STATIC_ROOT = public_data_root / "static"
desktop.MEDIA_ROOT = public_data_root / "media"

desktop.LOGS_PATH = logs_path
desktop.LOGGING = desktop.setup_logging(logs_path)

django.setup()


Expand Down Expand Up @@ -88,6 +105,11 @@ def __init__(self):

def start(self, config: DesktopConfig, db: Database):
if self.thread is None:
log.info("Searching for free ports")
config.server.find_free_port()
log.info(f"Free port found: {config.server.port}")
config.server.wait_till_free()
log.info(f"Starting application on {config.server.web_address}")
self.thread = AppThread(config=config, db=db, daemon=True)
self.thread.start()

Expand All @@ -98,14 +120,15 @@ def stop(self):


def get_latest_version(package: str) -> tuple[datetime, Version]:
raise ValueError("TODO - implement when we're clear to release")
url = f"https://pypi.org/pypi/{package}/json"
try:
resp = urlopen(url, timeout=5) # noqa: S310
except URLError:
parsed = urlparse("https://pypi.org/pypi/")
parsed = urlparse(url)
raise ValueError(
f"Could not check latest version; unable to reach {parsed.scheme}://{parsed.netloc}."
)
) from URLError
data = json.loads(resp.read().decode("utf-8"))
latest_str = list(data["releases"].keys())[-1]
upload_time = data["releases"][latest_str][0]["upload_time"]
Expand Down
25 changes: 21 additions & 4 deletions bmds_ui/desktop/cli.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import argparse
import os
import sys
from pathlib import Path

from .. import __version__
from .app import BmdsDesktopTui
from .config import Config
from .config import Config, get_default_config_path
from .exceptions import DesktopException
from .log import setup_logging


def get_app() -> BmdsDesktopTui:
def get_app(config: str | None = None) -> BmdsDesktopTui:
if config:
p = Path(config).expanduser().resolve()
os.environ["BMDS_CONFIG"] = str(p)
setup_logging()
os.environ["DJANGO_SETTINGS_MODULE"] = "bmds_ui.main.settings.desktop"
Config.get()
Expand All @@ -20,9 +26,20 @@ def show_version():


def main():
parser = argparse.ArgumentParser(description="BMDS Desktop Startup Interface")
parser = argparse.ArgumentParser(description=f"BMDS Desktop ({__version__})")
parser.add_argument("--version", "-V", action="store_true", help="Show version")
parser.add_argument(
"--config",
metavar="config",
action="store",
help=f'Configuration path (Default: "{get_default_config_path()}")',
type=str,
)
args = parser.parse_args()
if args.version:
return show_version()
get_app().run()
try:
get_app(config=args.config).run()
except DesktopException as err:
sys.stderr.write(str(err) + "\n")
exit(code=1)
92 changes: 66 additions & 26 deletions bmds_ui/desktop/config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import os
import platform
import re
import socket
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import ClassVar, Self
from uuid import UUID, uuid4

from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator

from .. import __version__
from .exceptions import DesktopException


def now() -> datetime:
Expand Down Expand Up @@ -51,9 +53,38 @@ class WebServer(BaseModel):
host: str = "127.0.0.1"
port: int = 5555

def is_free(self) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind((self.host, self.port))
return True
except OSError:
return False

def wait_till_free(self, timeout_sec=60):
seconds_slept = 0
while self.is_free() is False and seconds_slept <= timeout_sec:
time.sleep(1)
seconds_slept += 1
if seconds_slept > timeout_sec:
raise ValueError(f"Cannot secure connection; already in use? {self.host}:{self.port}")

def find_free_port(self):
while True:
if self.is_free():
return
self.port = self.port + 1

@property
def web_address(self):
return f"http://{self.host}:{self.port}"


LATEST_CONFIG_VERSION = 1


class DesktopConfig(BaseModel):
version: int = 1
version: int = LATEST_CONFIG_VERSION
server: WebServer
databases: list[Database] = []
created: datetime = Field(default_factory=now)
Expand All @@ -73,27 +104,34 @@ def remove_db(self, db):
self.databases.remove(db)


def get_version_path() -> str:
def get_version_path(version: str) -> str:
"""Get major/minor version path, ignoring patch or alpha/beta markers in version name"""
if m := re.match(r"^(\d+).(\d+)", __version__):
if m := re.match(r"^(\d+).(\d+)", version):
return f"{m[1]}_{m[2]}"
raise ValueError("Cannot parse version string")


def get_app_home() -> Path:
# if a custom path is specified, use that instead
if path := os.environ.get("BMDS_APP_HOME"):
return Path(path)
# otherwise fallback to standard locations based on operating system
def get_default_config_path() -> Path:
# get reasonable config path defaults by OS
# adapted from `hatch` source code
# https://github.com/pypa/hatch/blob/3adae6c0dfd5c20dfe9bf6bae19b44a696c22a43/docs/config/hatch.md?plain=1#L5-L15
app_home = Path.home()
version = get_version_path()
match platform.system():
case "Windows":
app_home = app_home / "AppData" / "Roaming" / "bmds" / version
app_home = app_home / "AppData" / "Local" / "bmds"
case "Darwin":
app_home = app_home / "Library" / "Application Support" / "bmds" / version
app_home = app_home / "Library" / "Application Support" / "bmds"
case "Linux" | _:
app_home = app_home / ".bmds" / version
config = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser().resolve()
app_home = config / "bmds"
return app_home


def get_app_home(path_str: str | None = None) -> Path:
"""Get path for storing configuration data for this application."""
# if a custom path is specified, use that instead
path_str = path_str or os.environ.get("BMDS_CONFIG")
app_home = Path(path_str) if path_str else get_default_config_path()
app_home.mkdir(parents=True, exist_ok=True)
return app_home

Expand All @@ -105,28 +143,30 @@ class Config:

@classmethod
def get_config_path(cls) -> Path:
last_config = get_app_home() / "latest.txt"
if last_config.exists():
path = Path(last_config.read_text())
if path.exists():
return path
# if the path doesn't exist, create a new default configuration and persist to disk
default_config = get_app_home() / "config.json"
default_config.write_text(DesktopConfig.default().model_dump_json(indent=2))
last_config.write_text(str((default_config).resolve()))
return default_config
# if configuration file doesn't exist, create one. return the file
config = get_app_home() / f"config-v{LATEST_CONFIG_VERSION}.json"
if not config.exists():
config.write_text(DesktopConfig.default().model_dump_json(indent=2))
return config

@classmethod
def get(cls) -> DesktopConfig:
if cls._config:
return cls._config
cls._config_path = cls.get_config_path()
cls._config = DesktopConfig.model_validate_json(cls._config_path.read_text())
if not cls._config_path.exists():
raise DesktopException(f"Configuration file not found: {cls._config_path}")
try:
cls._config = DesktopConfig.model_validate_json(cls._config_path.read_text())
except ValidationError as err:
raise DesktopException(
f"Cannot parse configuration: {cls._config_path}\n\nSpecific error:{err}"
)
return cls._config

@classmethod
def sync(cls):
# write to disk
if cls._config is None or cls._config_path is None:
raise ValueError()
raise DesktopException()
cls._config_path.write_text(cls._config.model_dump_json(indent=2))
2 changes: 1 addition & 1 deletion bmds_ui/desktop/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def version_check(check: bool = False) -> str:
return "**Status:** Ready to check - this requires an internet connection."
current = get_installed_version()
try:
latest_date, latest = get_latest_version("bmds") # TODO - change to bmds-desktop, on pypi?
latest_date, latest = get_latest_version("bmds") # TODO - change to bmds-ui
except ValueError as err:
return str(err)
try:
Expand Down
2 changes: 2 additions & 0 deletions bmds_ui/desktop/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class DesktopException(Exception):
pass
75 changes: 39 additions & 36 deletions bmds_ui/main/settings/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,43 +37,46 @@
}
}

LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
"formatters": {"basic": {"format": "%(levelname)s %(asctime)s %(name)s %(message)s"}},
"handlers": {
"mail_admins": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "django.utils.log.AdminEmailHandler",
},
"console": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "basic"},
"file": {
"level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler",
"formatter": "basic",
"filename": str(LOGS_PATH / "django.log"),
"maxBytes": 10 * 1024 * 1024, # 10 MB
"backupCount": 10,

def setup_logging(path: Path):
return {
"version": 1,
"disable_existing_loggers": False,
"filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
"formatters": {"basic": {"format": "%(levelname)s %(asctime)s %(name)s %(message)s"}},
"handlers": {
"mail_admins": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "django.utils.log.AdminEmailHandler",
},
"console": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "basic"},
"file": {
"level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler",
"formatter": "basic",
"filename": str(path / "django.log"),
"maxBytes": 10 * 1024 * 1024, # 10 MB
"backupCount": 10,
},
"requests": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"formatter": "basic",
"filename": str(path / "requests.log"),
"maxBytes": 10 * 1024 * 1024, # 10 MB
"backupCount": 10,
},
"null": {"class": "logging.NullHandler"},
},
"requests": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"formatter": "basic",
"filename": str(LOGS_PATH / "requests.log"),
"maxBytes": 10 * 1024 * 1024, # 10 MB
"backupCount": 10,
"loggers": {
"": {"handlers": ["console"], "level": "INFO"},
"django": {"handlers": ["console"], "propagate": False, "level": "INFO"},
"django.request": {"handlers": ["console"], "level": "ERROR", "propagate": True},
"bmds_ui": {"handlers": ["console"], "propagate": False, "level": "INFO"},
"bmds_ui.request": {"handlers": ["console"], "propagate": False, "level": "INFO"},
},
"null": {"class": "logging.NullHandler"},
},
"loggers": {
"": {"handlers": ["console"], "level": "INFO"},
"django": {"handlers": ["console"], "propagate": False, "level": "INFO"},
"django.request": {"handlers": ["console"], "level": "ERROR", "propagate": True},
"bmds_ui": {"handlers": ["console"], "propagate": False, "level": "INFO"},
"bmds_ui.request": {"handlers": ["console"], "propagate": False, "level": "INFO"},
},
}
}


CONTACT_US_LINK = "https://ecomments.epa.gov/bmds/"
Binary file added docs/img/bmds-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/bmds-output.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/desktop-startup.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit bfbc081

Please sign in to comment.