Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
112 changes: 83 additions & 29 deletions src/techui_builder/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
@dataclass
class JsonMap:
file: str
display_name: str | None
exists: bool = True
duplicate: bool = False
children: list["JsonMap"] = field(default_factory=list)
Expand Down Expand Up @@ -242,37 +243,27 @@ def create_screens(self):
ioc.yaml files in services"
)

def _generate_json_map(
self, screen_path: Path, dest_path: Path, visited: set[Path] | None = None
) -> JsonMap:
def _get_macros(element: ObjectifiedElement):
if hasattr(element, "macros"):
macros = element.macros.getchildren()
if macros is not None:
return {
str(macro.tag): macro.text
for macro in macros
if macro.text is not None
}
return {}

if visited is None:
visited = set()

current_node = JsonMap(str(screen_path.relative_to(self._write_directory)))
def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap:
"""Recursively generate JSON map from .bob file tree"""

# Create initial node at top of .bob file
current_node = JsonMap(
str(screen_path.relative_to(self._write_directory)),
display_name=None,
)

abs_path = screen_path.absolute()
dest_path = dest_path
if abs_path in visited:
current_node.exists = True
current_node.duplicate = True
return current_node
visited.add(abs_path)

try:
# Create xml tree from .bob file
tree = objectify.parse(abs_path)
root: ObjectifiedElement = tree.getroot()

# Set top level display name from root element
current_node.display_name = self._parse_display_name(
root.name.text, screen_path
)

# Find all <widget> elements
widgets = [
w
Expand All @@ -292,32 +283,38 @@ def _get_macros(element: ObjectifiedElement):
open_display = _get_action_group(widget_elem)
if open_display is None:
continue

# Use file, name, and macro elements
file_elem = open_display.file
name_elem = widget_elem.name.text
macro_dict = self._get_macros(open_display)

macro_dict = _get_macros(open_display)
# case "embedded":
# file_elem = widget_elem.file
# macro_dict = _get_macros(widget_elem)

case _:
continue

# Extract file path from file_elem
file_path = Path(file_elem.text.strip() if file_elem.text else "")

# If file is already a .bob file, skip it
if not file_path.suffix == ".bob":
continue

# Create valid displayName
display_name = self._parse_display_name(name_elem, file_path)

# TODO: misleading var name?
next_file_path = dest_path.joinpath(file_path)

# Crawl the next file
if next_file_path.is_file():
# TODO: investigate non-recursive approaches?
child_node = self._generate_json_map(
next_file_path, dest_path, visited
)
child_node = self._generate_json_map(next_file_path, dest_path)
else:
child_node = JsonMap(str(file_path), exists=False)
child_node = JsonMap(str(file_path), display_name, exists=False)

child_node.macros = macro_dict
# TODO: make this work for only list[JsonMap]
Expand All @@ -330,8 +327,61 @@ def _get_macros(element: ObjectifiedElement):
except Exception as e:
current_node.error = str(e)

self._fix_duplicate_names(current_node)

return current_node

def _get_macros(self, element: ObjectifiedElement):
if hasattr(element, "macros"):
macros = element.macros.getchildren()
if macros is not None:
return {
str(macro.tag): macro.text
for macro in macros
if macro.text is not None
}
return {}

def _parse_display_name(self, name: str | None, file_path: Path) -> str | None:
"""Parse display name from <name> tag or file_path"""

if name:
# Return name tag text as displayName
return name

elif file_path.name:
# Use tail without file ext as displayName
return file_path.name[: -sum(len(suffix) for suffix in file_path.suffixes)]

else:
# Populate displayName with null
return None

def _fix_duplicate_names(self, node: JsonMap) -> None:
"""Recursively fix duplicate display names in children"""
if not node.children:
return

# Count occurrences of each display_name
name_counts: defaultdict[str | None, int] = defaultdict(int)
for child in node.children:
if child.display_name:
name_counts[child.display_name] += 1

# Track which number we're on for each duplicate name
name_indices: defaultdict[str | None, int] = defaultdict(int)

# Update display names for duplicates
for child in node.children:
if child.display_name and name_counts[child.display_name] > 1:
name_indices[child.display_name] += 1
child.display_name = (
f"{child.display_name} {name_indices[child.display_name]}"
)

# Recursively fix children
self._fix_duplicate_names(child)

def write_json_map(
self,
synoptic: Path = Path("example/t01-services/synoptic/index.bob"),
Expand Down Expand Up @@ -383,6 +433,10 @@ def _check_default(key: str, value: Any):

d[key] = val

# Rename display_name to displayName for JSON camel case convention
if "display_name" in d:
d["displayName"] = d.pop("display_name")

return d


Expand Down
23 changes: 21 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,32 @@ def test_files():
@pytest.fixture
def example_json_map():
# Create test json map with child json map
test_map_child = JsonMap("test_child_bob.bob", exists=False)
test_map = JsonMap("test_bob.bob")
test_map_child = JsonMap("test_child_bob.bob", "Detector", exists=False)
test_map = JsonMap("test_bob.bob", "Display")
test_map.children.append(test_map_child)

return test_map


@pytest.fixture
def example_display_names_json():
# Create test json map with correct display names
test_map_det1 = JsonMap("test_child_bob.bob", "Detector 1", exists=False)
test_map_det2 = JsonMap("test_child_bob.bob", "Detector 2", exists=False)
test_map_dev1 = JsonMap("test_child_bob.bob", "Device 1", exists=False)
test_map_dev2 = JsonMap("test_child_bob.bob", "Device 2", exists=False)
test_map = JsonMap("test_bob.bob", "Beamline")

test_map_dev1.children.append(test_map_det1)
test_map_dev1.children.append(test_map_det2)
test_map_dev2.children.append(test_map_det1)
test_map_dev2.children.append(test_map_det2)
test_map.children.append(test_map_dev1)
test_map.children.append(test_map_dev2)

return test_map


@pytest.fixture
def generator():
synoptic_dir = Path(__file__).parent.joinpath(Path("t01-services/synoptic"))
Expand Down
77 changes: 57 additions & 20 deletions tests/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,9 @@ def test_write_json_map_no_synoptic(builder):


def test_write_json_map(builder):
test_map = JsonMap(str(Path(__file__).parent.joinpath("test_files/test_bob.bob")))
test_map = JsonMap(
str(Path(__file__).parent.joinpath("test_files/test_bob.bob")), None
)

# We don't want cover _generate_json_map in this test
builder._generate_json_map = Mock(return_value=test_map)
Expand Down Expand Up @@ -312,6 +314,56 @@ def test_generate_json_map(builder_with_test_files, example_json_map, test_files
# assert test_json_map == example_json_map


def test_parse_display_name_with_name(builder):
"""Test parse display name when <name> tag is present"""
display_name = builder._parse_display_name(
"<name>", Path("/path/to/filename.pvi.bob")
)
assert display_name == "<name>"


def test_parse_display_name_from_filepath(builder):
"""Test parse display name when only filepath is present"""
display_name = builder._parse_display_name(None, Path("/path/to/filename.pvi.bob"))
assert display_name == "filename"


def test_parse_display_name_returns_none(builder):
"""Test parse display ensures JSON displayName will return null otherwise"""
display_name = builder._parse_display_name(None, Path(""))

assert display_name is None


def test_fix_duplicate_names_recursive(builder, example_display_names_json):
"""Test duplicate names are enumerated correctly for all children"""

test_display_names_json = JsonMap(
str(Path(__file__).parent.joinpath("test_files/test_bob.bob")), None
)

test_display_names_json_det1 = JsonMap(
"test_child_bob.bob", "Detector", exists=False
)
test_display_names_json_det2 = JsonMap(
"test_child_bob.bob", "Detector", exists=False
)
test_display_names_json_dev1 = JsonMap("test_child_bob.bob", "Device", exists=False)
test_display_names_json_dev2 = JsonMap("test_child_bob.bob", "Device", exists=False)
test_display_names_json = JsonMap("test_bob.bob", "Beamline")

test_display_names_json_dev1.children.append(test_display_names_json_det1)
test_display_names_json_dev1.children.append(test_display_names_json_det2)
test_display_names_json_dev2.children.append(test_display_names_json_det1)
test_display_names_json_dev2.children.append(test_display_names_json_det2)
test_display_names_json.children.append(test_display_names_json_dev1)
test_display_names_json.children.append(test_display_names_json_dev2)

builder._fix_duplicate_names(test_display_names_json)

assert test_display_names_json == example_display_names_json


def test_generate_json_map_get_macros(
builder_with_test_files, example_json_map, test_files
):
Expand All @@ -336,24 +388,6 @@ def test_generate_json_map_get_macros(
assert test_json_map == example_json_map


def test_generate_json_map_visited_node(
builder_with_test_files, example_json_map, test_files
):
screen_path, dest_path = test_files

visited = {screen_path}
# Clear children as they will never be read
example_json_map.children = []
# Need to set this to true too
example_json_map.duplicate = True

test_json_map = builder_with_test_files._generate_json_map(
screen_path, dest_path, visited
)

assert test_json_map == example_json_map


def test_generate_json_map_xml_parse_error(builder_with_test_files, test_files):
screen_path = Path("tests/test_files/test_bob_bad.bob").absolute()
_, dest_path = test_files
Expand Down Expand Up @@ -381,7 +415,10 @@ def test_serialise_json_map(example_json_map):

assert json_ == {
"file": "test_bob.bob",
"children": [{"file": "test_child_bob.bob", "exists": False}],
"children": [
{"file": "test_child_bob.bob", "displayName": "Detector", "exists": False}
],
"displayName": "Display",
}


Expand Down