Skip to content

Commit b0a9628

Browse files
committed
Add Library Schema, Helpers, and Metadata
1 parent 64ae7a0 commit b0a9628

File tree

25 files changed

+10136
-1427
lines changed

25 files changed

+10136
-1427
lines changed

buildingmotif/bin/cli.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
from pathlib import Path
77

88
from buildingmotif import BuildingMOTIF
9+
from buildingmotif.bin.library import add_commands
910
from buildingmotif.dataclasses import Library
1011
from buildingmotif.ingresses.bacnet import BACnetNetwork
1112

1213
cli = argparse.ArgumentParser(
1314
prog="buildingmotif", description="CLI Interface for common BuildingMOTIF tasks"
1415
)
1516
subparsers = cli.add_subparsers(dest="subcommand")
17+
1618
subcommands = {}
1719
log = logging.getLogger()
1820
log.setLevel(logging.INFO)
@@ -39,6 +41,22 @@ def decorator(func):
3941
return decorator
4042

4143

44+
def subparser(*subparser_args, parent=subparsers):
45+
"""Decorates a function and makes it available as a subparser"""
46+
47+
def decorator(func):
48+
subcommand(*subparser_args, parent=parent)(func)
49+
50+
subparser = subcommands[func].add_subparsers(dest=f"{func.__name__}_subcommand")
51+
52+
def subcommand_decorator(*subparser_args, parent=subparser):
53+
return subcommand(*subparser_args, parent=parent)
54+
55+
return subcommand_decorator
56+
57+
return decorator
58+
59+
4260
def get_db_uri(args) -> str:
4361
"""
4462
Fetches the db uri from args, or prints the usage
@@ -163,6 +181,13 @@ def scan(args):
163181
bacnet_network.dump(Path(args.output_file))
164182

165183

184+
@subparser()
185+
def library(args):
186+
"""A collection of commands for working with libraries"""
187+
188+
189+
add_commands(library)
190+
166191
# entrypoint is actually defined in pyproject.toml; this is here for convenience/testing
167192
if __name__ == "__main__":
168193
app()

buildingmotif/bin/cli_helpers.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from enum import Enum
2+
from typing import Union
3+
4+
from pygit2.config import Config
5+
6+
7+
class Color(Enum):
8+
"""ANSI color codes for terminal text formatting.
9+
10+
Usage:
11+
Color.GREEN("Example text"), produces green text.
12+
Color.GREEN + "Example text" + Color.RESET, is the same as Color.GREEN("Example text")
13+
"""
14+
15+
BLACK = "\033[30m"
16+
RED = "\033[31m"
17+
GREEN = "\033[32m"
18+
YELLOW = "\033[33m"
19+
BLUE = "\033[34m"
20+
MAGENTA = "\033[35m"
21+
CYAN = "\033[36m"
22+
LIGHT_GRAY = "\033[37m"
23+
GRAY = "\033[90m"
24+
LIGHT_RED = "\033[91m"
25+
LIGHT_GREEN = "\033[92m"
26+
LIGHT_YELLOW = "\033[93m"
27+
LIGHT_BLUE = "\033[94m"
28+
LIGHT_MAGENTA = "\033[95m"
29+
LIGHT_CYAN = "\033[96m"
30+
WHITE = "\033[97m"
31+
RESET = "\033[0m"
32+
33+
def __call__(self, text):
34+
return f"{self.value}{text}{Color.RESET.value}"
35+
36+
def __str__(self):
37+
return self.value
38+
39+
40+
def print_tree(
41+
tree: dict[Union[str, tuple[str, str]], Union[dict, None]], indent=4
42+
) -> None:
43+
"""Print a tree like dict to the console."""
44+
45+
def _tree_to_str(
46+
item: Union[str, tuple[str, str]],
47+
tree: Union[dict, None] = None,
48+
level: int = 0,
49+
indent: int = 4,
50+
) -> str:
51+
""""""
52+
description = ""
53+
if isinstance(item, tuple):
54+
name, description = item
55+
else:
56+
name = item
57+
58+
if level % 5 == 0:
59+
name = Color.BLUE(name)
60+
elif level % 5 == 1:
61+
name = Color.MAGENTA(name)
62+
elif level % 5 == 2:
63+
name = Color.GREEN(name)
64+
elif level % 5 == 3:
65+
name = Color.CYAN(name)
66+
elif level % 5 == 4:
67+
name = Color.RED(name)
68+
69+
title = f"{name} {description}".strip()
70+
71+
lines = [title]
72+
if tree is None:
73+
return title
74+
for index, (subitem, subtree) in enumerate(tree.items()):
75+
subtree_lines = _tree_to_str(subitem, subtree, level + 1, indent).split(
76+
"\n"
77+
)
78+
last = index == len(tree) - 1
79+
80+
for line_index, line in enumerate(subtree_lines):
81+
prefix = " " * indent
82+
if last:
83+
if line_index == 0:
84+
prefix = f"└{'─' * (indent - 2)} "
85+
else:
86+
if line_index == 0:
87+
prefix = f"├{'─' * (indent - 2)} "
88+
else:
89+
prefix = f"│{' ' * (indent - 2)} "
90+
subtree_lines[line_index] = f"{prefix}{line}"
91+
lines.extend(subtree_lines)
92+
return "\n".join(lines)
93+
94+
lines = []
95+
for subitem, subtree in tree.items():
96+
lines.append(_tree_to_str(subitem, subtree, 0, indent))
97+
print("\n".join(lines))
98+
99+
100+
def get_input(
101+
prompt: str,
102+
default: Union[str, None] = None,
103+
optional: bool = False,
104+
input_type: Union[type] = str,
105+
) -> str | int | float | bool | None:
106+
"""
107+
Helper function to get input from the user with a prompt.
108+
If default is provided, it will be used if the user just presses Enter.
109+
If optional is False, the user must provide an input.
110+
"""
111+
parenthetical = f" [{Color.BLUE(default)}]" if default is not None else ""
112+
if optional and default is not None:
113+
parenthetical = f" [{Color.BLUE(default)}, {Color.MAGENTA('n to skip')}]"
114+
115+
if input_type is bool:
116+
parenthetical = f"{parenthetical} {Color.MAGENTA('(y/n)')}"
117+
118+
while True:
119+
user_input = input(f"{Color.GREEN(prompt)}{parenthetical}: ")
120+
if not user_input:
121+
if default is not None:
122+
user_input = default
123+
elif optional:
124+
return None
125+
else:
126+
print("This field is required. Please provide a value.")
127+
continue
128+
try:
129+
if user_input == "n" and optional:
130+
return None
131+
if input_type in [int, float, str]:
132+
return input_type(user_input)
133+
elif input_type is bool:
134+
true_input = user_input.lower() in ["true", "1", "yes", "y"]
135+
false_input = user_input.lower() in ["false", "0", "no", "n"]
136+
if true_input:
137+
return True
138+
elif false_input:
139+
return False
140+
raise ValueError(f"Invalid input for boolean: {user_input}")
141+
return input_type(user_input)
142+
except ValueError:
143+
print(
144+
f"{Color.RED}Invalid input. Please enter a valid {input_type.__name__}.{Color.RESET}"
145+
)
146+
147+
148+
def git_global_config() -> dict[str, str]:
149+
"""
150+
Fetches the global git configuration.
151+
"""
152+
config = Config.get_global_config()
153+
return {value.name: value.value for value in config}
154+
155+
156+
def arg(*argnames, **kwargs):
157+
"""Helper for defining arguments on subcommands"""
158+
return argnames, kwargs

buildingmotif/bin/library.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from pathlib import Path
2+
3+
from jinja2 import Environment
4+
5+
from buildingmotif import BuildingMOTIF
6+
from buildingmotif.bin.cli_helpers import (
7+
Color,
8+
arg,
9+
get_input,
10+
git_global_config,
11+
print_tree,
12+
)
13+
from buildingmotif.dataclasses.library import Library
14+
from buildingmotif.dataclasses.shape_collection import find_imports
15+
16+
17+
def add_commands(library):
18+
@library(
19+
arg("path", help="Path to the library directory"),
20+
)
21+
def init(args):
22+
"""Initialize a library"""
23+
library_path = Path(args.path).resolve()
24+
25+
template_file = (
26+
Path(__file__).resolve().parents[1] / "resources" / "library.template.yml"
27+
)
28+
29+
user_name = git_global_config().get("user.name")
30+
user_email = git_global_config().get("user.email")
31+
default_user = None
32+
if user_name and user_email:
33+
default_user = f"{user_name} <{user_email}>"
34+
35+
env = Environment()
36+
template = env.from_string(template_file.read_text())
37+
38+
repeat = True
39+
40+
library_arguments = {}
41+
while repeat:
42+
print(
43+
f"\n{Color.CYAN}This command will guide you through creating your {Color.GREEN}library.yaml{Color.CYAN} file.{Color.RESET}\n"
44+
)
45+
46+
library_arguments["name"] = get_input(
47+
"Library name",
48+
default=library_arguments.get("name", library_path.name),
49+
input_type=str,
50+
)
51+
library_arguments["version"] = get_input(
52+
"Version",
53+
default=library_arguments.get("version", "0.1.0"),
54+
input_type=str,
55+
)
56+
library_arguments["description"] = get_input(
57+
"Description",
58+
default=library_arguments.get("description", ""),
59+
input_type=str,
60+
)
61+
library_arguments["author"] = get_input(
62+
"Author",
63+
default=library_arguments.get("author", default_user),
64+
optional=True,
65+
input_type=str,
66+
)
67+
library_arguments["home"] = get_input(
68+
"Home page URL",
69+
default=library_arguments.get("home", None),
70+
optional=True,
71+
input_type=str,
72+
)
73+
74+
print(
75+
f"\n{Color.CYAN}Please confirm that the following information is correct:{Color.RESET}\n"
76+
)
77+
print(
78+
f"{Color.GREEN}Name: {Color.BLUE}{library_arguments['name']}{Color.RESET}"
79+
)
80+
print(
81+
f"{Color.GREEN}Version: {Color.BLUE}{library_arguments['version']}{Color.RESET}"
82+
)
83+
print(
84+
f"{Color.GREEN}Description: {Color.BLUE}{library_arguments['description']}{Color.RESET}"
85+
)
86+
print(
87+
f"{Color.GREEN}Author: {Color.BLUE}{library_arguments.get('author', '') or ''}{Color.RESET}"
88+
)
89+
print(
90+
f"{Color.GREEN}Home page: {Color.BLUE}{library_arguments.get('home', '') or ''}{Color.RESET}"
91+
)
92+
print("\n")
93+
confirm = get_input(
94+
"Is this information correct?", default="y", input_type=bool
95+
)
96+
repeat = not confirm
97+
print("\n")
98+
99+
library_path.mkdir(parents=True, exist_ok=True)
100+
library_file = library_path / "library.yml"
101+
if library_file.exists():
102+
overwrite = get_input(
103+
f"Library file '{library_file}' already exists. Overwrite?",
104+
default="n",
105+
input_type=bool,
106+
)
107+
if not overwrite:
108+
print(f"{Color.RED}Library initialization aborted.{Color.RESET}")
109+
return
110+
library_file.write_text(template.render(library_arguments))
111+
112+
@library(
113+
arg("path", help="Path to the library directory"),
114+
)
115+
def load(args):
116+
"""Load a library"""
117+
BuildingMOTIF("sqlite:///").setup_tables()
118+
# library_path = Path(args.path).resolve()
119+
Library.load(directory=args.path)
120+
121+
@library(
122+
arg("path", help="Path to the library directory"),
123+
arg("--depth", help="Depth of the audit", type=int, default=3),
124+
)
125+
def audit(args):
126+
"""Generate an audit report of a library's dependencies"""
127+
128+
print("Setting up empty BuildingMOTIF")
129+
BuildingMOTIF("sqlite:///").setup_tables()
130+
print("\nLoading Library")
131+
library = Library.load(
132+
directory=args.path, run_shacl_inference=False, infer_templates=False
133+
)
134+
print("\nResolving Imports")
135+
imports = find_imports(library.get_shape_collection().graph, depth=args.depth)
136+
137+
def imports_to_tree(imports):
138+
tree = {}
139+
for name, ((graph, source), imp) in imports.items():
140+
description = Color.RED("Not Found")
141+
if source == "library":
142+
description = Color.GREEN("Found in library")
143+
elif source == "shape_collection":
144+
description = Color.GREEN("Found in shape collection")
145+
elif source == "internet":
146+
description = Color.GREEN("Found on the internet")
147+
item = (name, f" - {description}")
148+
tree[item] = imports_to_tree(imp)
149+
return tree
150+
151+
print_tree(imports_to_tree(imports))

0 commit comments

Comments
 (0)