Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App: implement managing multiple projects (#107) #109

Merged
merged 38 commits into from
Dec 20, 2019
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
05f13c4
App: implement managing multiple projects (#107)
keggsmurph21 Dec 18, 2019
ca46724
App: save `project_hash` after initializing a Project
keggsmurph21 Dec 18, 2019
7c46ccb
Project: change constructor pattern & save location
keggsmurph21 Dec 18, 2019
1c15aca
Create directories in 0o755 mode (instead of default 0o777)
keggsmurph21 Dec 18, 2019
fd59bfd
Project: remove old comments
keggsmurph21 Dec 18, 2019
b2d62bf
Project: check if root_path is a directory
keggsmurph21 Dec 18, 2019
746f9ac
App: Add types to constructor args, allow "headless" mode
keggsmurph21 Dec 19, 2019
274beae
App: add tests
keggsmurph21 Dec 19, 2019
30c5c7a
Trace+XHair: fix type errors when running `pytest`
keggsmurph21 Dec 19, 2019
61a18b6
Update ultratrace2/model/files/bundle.py
keggsmurph21 Dec 19, 2019
f0f6154
FileBundleList: Make `cls.exclude_dirs` a `FrozenSet`
keggsmurph21 Dec 19, 2019
5ef78a0
Merge branch 'manage-multiple-projects' of https://github.com/swatpho…
keggsmurph21 Dec 19, 2019
6235875
Update ultratrace2/model/files/bundle.py
keggsmurph21 Dec 19, 2019
25aae65
Update ultratrace2/model/files/bundle.py
keggsmurph21 Dec 19, 2019
67424ae
Update ultratrace2/model/project.py
keggsmurph21 Dec 19, 2019
2ea02f0
Update ultratrace2/model/project.py
keggsmurph21 Dec 19, 2019
2bee499
Project: Use more descriptive error messages in ::get_by_path()
keggsmurph21 Dec 19, 2019
3929e00
Merge branch 'manage-multiple-projects' of https://github.com/swatpho…
keggsmurph21 Dec 19, 2019
935d941
Update ultratrace2/model/project.py
keggsmurph21 Dec 19, 2019
cc4b696
Update ultratrace2/model/project.py
keggsmurph21 Dec 19, 2019
5f2b6f8
Update ultratrace2/model/project.py
keggsmurph21 Dec 19, 2019
dc181b5
Update ultratrace2/model/project.py
keggsmurph21 Dec 19, 2019
f7b7f7a
App: be more explicit about allowed parameters to App constructor
keggsmurph21 Dec 19, 2019
0cb2668
App: change parameter name in one test
keggsmurph21 Dec 19, 2019
c6b590a
Project: add tests for ::load()
keggsmurph21 Dec 19, 2019
be61113
nox: Also calculate test coverage when running pytest
keggsmurph21 Dec 19, 2019
a518eb8
FileBundle: Simplify logic of `has_*_impl` initialization
keggsmurph21 Dec 19, 2019
318c618
Files: simplify inheritance of file implementations
keggsmurph21 Dec 19, 2019
04c8af2
Remove `python-magic` dependency in favor of `mimetypes`
keggsmurph21 Dec 19, 2019
cf17cff
Registry: Implement plugin-based architecture for loading files
keggsmurph21 Dec 19, 2019
a452e98
FileLoaderBase: constrain ::from_file return type
keggsmurph21 Dec 20, 2019
e38b084
Update ultratrace2/model/files/loaders/base.py
keggsmurph21 Dec 20, 2019
336f414
DICOMLoader: raise FileLoadError from InvalidDicomError
keggsmurph21 Dec 20, 2019
fa457a1
Registry: remove unnecessary `global` statements
keggsmurph21 Dec 20, 2019
7450611
Project: Don't funnel ::load() errors into RuntimeErrors
keggsmurph21 Dec 20, 2019
e6895ba
FileBundle: Pass responsibility for handling FileLoadError to caller
keggsmurph21 Dec 20, 2019
2669b54
FileLoaderBase: Require `path: str` abstract attribute
keggsmurph21 Dec 20, 2019
d2b3759
Merge branch 'master' into manage-multiple-projects
keggsmurph21 Dec 20, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions ultratrace2/app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from argparse import Namespace
from tkinter.filedialog import askdirectory as choose_dir
from typing import Optional

Expand All @@ -6,27 +7,30 @@


class App:
def __init__(self, args): # FIXME: be more granular here
def __init__(self, args: Namespace):
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved

if args.path is None:
headless = getattr(args, "headless", False)

path: Optional[str] = getattr(args, "path", None)
if path is None and not headless:
path = choose_dir()
if not path:
raise ValueError("You must choose a directory to open")
else:
path = args.path
if not path:
raise ValueError("You must choose a directory to open")

self.project: Project = Project.get_by_path(path)

self.project = Project(path)
self.gui = GUI(theme=args.theme)
if not headless:
self.gui = GUI(theme=args.theme)

def main(self):
def main(self) -> None:
pass


# singleton
app: Optional[App] = None


def initialize_app(args) -> App: # FIXME: be more granular here
def initialize_app(args: Namespace) -> App:
global app
app = App(args)
return app
49 changes: 27 additions & 22 deletions ultratrace2/model/files/bundle.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
import os

from typing import Dict, List, Set
from typing import Dict, Sequence, FrozenSet

from .impls import Sound, Alignment, ImageSet

Expand Down Expand Up @@ -36,35 +36,48 @@ def __repr__(self):

class FileBundleList:

exclude_dirs: Set[str] = set(
exclude_dirs: FrozenSet[str] = frozenset(
[
".git",
"node_modules",
"__pycache__",
".ultratrace",
# FIXME: add more ignoreable dirs
]
)

def __init__(self, path: str, extra_exclude_dirs: List[str] = []):
def __init__(self, bundles: Dict[str, FileBundle]):

# FIXME: implement `extra_exclude_dirs` as a command-line arg
for extra_exclude_dir in extra_exclude_dirs:
self.exclude_dirs.add(extra_exclude_dir)
self.current_bundle = None
self.bundles: Dict[str, FileBundle] = bundles

self.path = path
self.has_alignment_impl = False
self.has_image_impl = False
self.has_sound_impl = False
self.has_alignment_impl: bool = False
self.has_image_impl: bool = False
self.has_sound_impl: bool = False

self.current_bundle = None
for bundle in bundles.values():
if not self.has_alignment_impl and bundle.alignment_file.has_impl():
self.has_alignment_impl = True
if not self.has_image_impl and bundle.image_file.has_impl():
self.has_image_impl = True
if not self.has_sound_impl and bundle.sound_file.has_impl():
self.has_sound_impl = True

@classmethod
def build_from_dir(
cls, root_path: str, extra_exclude_dirs: Sequence[str] = []
) -> "FileBundleList":

# FIXME: implement `extra_exclude_dirs` as a command-line arg
exclude_dirs = cls.exclude_dirs.union(extra_exclude_dirs)

bundles: Dict[str, FileBundle] = {}

# NB: `topdown=True` increases runtime cost from O(n) -> O(n^2), but it allows us to
# modify `dirs` in-place so that we can skip certain directories. For more info,
# see https://stackoverflow.com/questions/19859840/excluding-directories-in-os-walk
for path, dirs, filenames in os.walk(path, topdown=True):
dirs[:] = [d for d in dirs if d not in self.exclude_dirs]
for path, dirs, filenames in os.walk(root_path, topdown=True):
dirs[:] = [d for d in dirs if d not in exclude_dirs]
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved

for filename in filenames:

Expand All @@ -83,12 +96,4 @@ def __init__(self, path: str, extra_exclude_dirs: List[str] = []):
if not bundles[name].interpret(filepath):
logger.warning(f"unrecognized filetype: {filepath}")

# FIXME: do this when we add to our data structure
for filename, bundle in bundles.items():
# build up self.bundles here
if not self.has_alignment_impl and bundle.alignment_file.has_impl():
self.has_alignment_impl = True
if not self.has_image_impl and bundle.image_file.has_impl():
self.has_image_impl = True
if not self.has_sound_impl and bundle.sound_file.has_impl():
self.has_sound_impl = True
return cls(bundles)
2 changes: 1 addition & 1 deletion ultratrace2/model/files/impls/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def load(self) -> None:
"Invalid DICOM ({self.path}), unknown shape {pixels.shape}"
)

os.mkdir(self.png_path)
os.mkdir(self.png_path, mode=0o755)
for i in tqdm(range(frames), desc="converting to PNG"):
filename = os.path.join(self.png_path, f"{i:06}.png")
arr = pixels[i, :, :] if is_greyscale else pixels[i, :, :, :]
Expand Down
61 changes: 53 additions & 8 deletions ultratrace2/model/project.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,68 @@
import logging
import os
import pickle

from .trace import TraceList
from .files.bundle import FileBundleList

logger = logging.getLogger(__name__)


class Project:
def __init__(self, path: str):
if not os.path.exists(path):
raise ValueError(f"cannot initialize project at {path}")
self.root_path = os.path.realpath(os.path.abspath(path)) # absolute path
self.traces = TraceList()
self.files = FileBundleList(self.root_path)
def __init__(self, traces: TraceList, files: FileBundleList):
"""
Internal function: to construct a Project, either call ::get_by_path()
"""
self.traces = traces
self.files = files

def save(self):
raise NotImplementedError()

@classmethod
def load(cls):
raise NotImplementedError()
def load(cls, save_file) -> "Project":
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved
try:
with open(save_file, "rb") as fp:
project = pickle.load(fp)
assert isinstance(project, Project)
return project
except Exception as e:
logger.error(e)
raise RuntimeError(str(e))
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved

@classmethod
def get_by_path(cls, root_path) -> "Project":
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved

root_path = os.path.realpath(os.path.abspath(root_path)) # absolute path
if not os.path.exists(root_path) or not os.path.isdir(root_path):
raise ValueError(
f"cannot initialize project at {root_path}: not a directory"
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved
)

save_dir = Project.get_save_dir(root_path)
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved
if not os.path.exists(save_dir):
os.mkdir(save_dir, mode=0o755)

save_file = Project.get_save_file(root_path)
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved
try:
return Project.load(save_file)
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved
except RuntimeError:
logger.info(
f"Unable to find existing project at {root_path}, creating new one..."
)

traces = TraceList()
file_bundles = FileBundleList.build_from_dir(root_path)
return Project(traces, file_bundles)
keggsmurph21 marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def get_save_dir(path: str) -> str:
return os.path.join(path, ".ultratrace")

@staticmethod
def get_save_file(path: str) -> str:
save_dir = Project.get_save_dir(path)
return os.path.join(save_dir, "project.pkl")

def filepath(self):
raise NotImplementedError()
Expand Down
4 changes: 2 additions & 2 deletions ultratrace2/model/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class Trace:
def __init__(self, name: str, color: Color):
self.id: UUID = uuid4()
self.is_visible: bool = True
self.xhairs: Dict[FileBundle, Dict[int, Set[XHair]]] = {}
self.xhairs: Dict["FileBundle", Dict[int, Set[XHair]]] = {}
self.name = name
self.color = color

Expand Down Expand Up @@ -45,7 +45,7 @@ def show(self) -> None:
def hide(self) -> None:
self.is_visible = False

def add_xhair(self, bundle: FileBundle, frame: int, x: float, y: float) -> None:
def add_xhair(self, bundle: "FileBundle", frame: int, x: float, y: float) -> None:
if bundle not in self.xhairs:
self.xhairs[bundle] = {}
if frame not in self.xhairs[bundle]:
Expand Down
4 changes: 2 additions & 2 deletions ultratrace2/model/xhair.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


class XHair:
def __init__(self, trace: Trace, x: float, y: float):
def __init__(self, trace: "Trace", x: float, y: float):

self.id = uuid4()
self.trace = trace
Expand Down Expand Up @@ -49,5 +49,5 @@ def move(self, x: float, y: float) -> None:
self.x = x
self.y = y

def get_color(self) -> Color:
def get_color(self) -> "Color":
return self.trace.get_color()
Empty file added ultratrace2/tests/__init__.py
Empty file.
36 changes: 36 additions & 0 deletions ultratrace2/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from argparse import Namespace
from pathlib import Path

import pytest

from .. import app as app_py
from ..app import App, initialize_app


@pytest.mark.parametrize(
"args,expected",
[
(Namespace(headless=True), ValueError),
(Namespace(headless=True, path="/path/to/nowhere"), ValueError),
(Namespace(headless=True, path="/dev/null"), ValueError), # not a directory
(Namespace(headless=True, path="/"), PermissionError), # not writeable
],
)
def test_initialize_app_invalid(args: Namespace, expected: Exception, tmpdir) -> None:
app_py.app = None # overwrite global object
with pytest.raises(expected):
initialize_app(args)


@pytest.mark.parametrize("args", [(Namespace(headless=True)),]) # noqa: E231
def test_initialize_app_valid(args: Namespace, tmp_path: Path) -> None:
# overwrite global object
app_py.app = None
# initialize an empty dir
path = tmp_path / "ultratrace-test-app"
path.mkdir()
# save that to the Namespace
args.path = path
# initialize
app = initialize_app(args)
assert isinstance(app, App)