Skip to content

Commit

Permalink
Added CLI app framework
Browse files Browse the repository at this point in the history
Close #6
  • Loading branch information
nfx committed Dec 27, 2023
1 parent 5f5c818 commit 6ce908c
Show file tree
Hide file tree
Showing 9 changed files with 389 additions and 119 deletions.
19 changes: 16 additions & 3 deletions labs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,22 @@
name: blueprint
description: Common libraries for Databricks Labs
install:
script: src/databricks/labs/ucx/install.py
entrypoint: src/databricks/labs/ucx/cli.py
script: src/databricks/labs/blueprint/__init__.py
entrypoint: src/databricks/labs/blueprint/__main__.py
min_python: 3.10
commands:
- name: init
- name: me
description: shows current username
flags:
- name: greeting
default: Hello
description: Greeting prefix
- name: workspaces
is_account: true
description: shows current workspaces
- name: init-project
is_unauthenticated: true
description: initializes new project
flags:
- name: target
description: target folder
28 changes: 12 additions & 16 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build]
sources = ["src"]
include = ["src"]

[project]
name = "databricks-labs-blueprint"
dynamic = ["version"]
Expand All @@ -14,9 +6,6 @@ readme = "README.md"
license-files = { paths = ["LICENSE", "NOTICE"] }
requires-python = ">=3.10.6" # latest available in DBR 13.2
keywords = ["Databricks"]
authors = [
{ name = "Serge Smertin", email = "[email protected]" },
]
classifiers = [
"Development Status :: 3 - Alpha",
"License :: Other/Proprietary License",
Expand All @@ -25,11 +14,19 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: Implementation :: CPython",
]
dependencies = ["databricks-sdk~=0.16.0"]
dependencies = ["databricks-sdk"]

[project.urls]
Issues = "https://github.com/databricks/blueprint/issues"
Source = "https://github.com/databricks/blueprint"
Issues = "https://github.com/databrickslabs/blueprint/issues"
Source = "https://github.com/databrickslabs/blueprint"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build]
sources = ["src"]
include = ["src"]

[tool.hatch.version]
path = "src/databricks/labs/blueprint/__about__.py"
Expand Down Expand Up @@ -68,7 +65,6 @@ verify = ["black --check .",
"mypy ."]

[tool.isort]
skip_glob = ["notebooks/*.py"]
profile = "black"

[tool.pytest.ini_options]
Expand All @@ -93,7 +89,7 @@ branch = true
parallel = true

[tool.coverage.report]
omit = ["*/working-copy/*", "*/fresh_wheel_file/*"]
omit = ["*/working-copy/*", 'src/databricks/labs/blueprint/__main__.py']
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
Expand Down
2 changes: 1 addition & 1 deletion src/databricks/labs/blueprint/__about__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# DO NOT MODIFY THIS FILE
# DO NOT MODIFY THIS FILE BY HAND
__version__ = "0.0.1"
120 changes: 120 additions & 0 deletions src/databricks/labs/blueprint/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from pathlib import Path

from databricks.labs.blueprint.cli import App
from databricks.labs.blueprint.entrypoint import (
find_project_root,
get_logger,
relative_paths,
)
from databricks.labs.blueprint.tui import Prompts

blueprint = App(__file__)
logger = get_logger(__file__)

main_py_file = '''from databricks.sdk import AccountClient, WorkspaceClient
from databricks.labs.blueprint.entrypoint import get_logger
from databricks.labs.blueprint.cli import App
__app__ = App(__file__)
logger = get_logger(__file__)
@__app__.command
def me(w: WorkspaceClient, greeting: str):
"""Shows current username"""
logger.info(f"{greeting}, {w.current_user.me().user_name}!")
@__app__.command(is_account=True)
def workspaces(a: AccountClient):
"""Shows workspaces"""
for ws in a.workspaces.list():
logger.info(f"Workspace: {ws.workspace_name} ({ws.workspace_id})")
if "__main__" == __name__:
__app__()
'''

labs_yml_file = """---
name: __app__
description: Common libraries for Databricks Labs
install:
script: src/databricks/labs/__app__/__init__.py
entrypoint: src/databricks/labs/__app__/__main__.py
min_python: 3.10
- name: me
description: shows current username
flags:
- name: greeting
default: Hello
description: Greeting prefix
- name: workspaces
is_account: true
description: shows current workspaces
"""


@blueprint.command(is_unauthenticated=True)
def init_project(target):
"""Creates the required boilerplate structure"""
prompts = Prompts()

project_root = find_project_root()
target_folder = Path(target)

project_name = prompts.question("Name of the project", default=target_folder.name)
src_dir, dst_dir = relative_paths(project_root, target_folder.absolute())

ignore_names = {
".git",
".venv",
".databricks",
".mypy_cache",
".idea",
".coverage",
"htmlcov",
"__pycache__",
"tests",
".databricks-login.json",
"coverage.xml",
"dist",
}
queue: list[Path] = [src_dir] # type: ignore[annotation-unchecked]
while queue:
current = queue.pop(0)
if current.name in ignore_names:
continue
if current.is_file():
relative_file_name = current.as_posix().replace("blueprint", project_name)
dst_file = dst_dir / relative_file_name
dst_file.parent.mkdir(exist_ok=True, parents=True)
with current.open("r") as r, dst_file.open("w") as w:
content = r.read().replace("blueprint", project_name)
content = content.replace("databricks-sdk", "databricks-labs-blueprint")
w.write(content)
continue
virtual_env_marker = current / "pyvenv.cfg"
if virtual_env_marker.exists():
continue
for file in current.iterdir():
if file.as_posix() == "src/databricks/labs/blueprint":
continue
queue.append(file)
inner_package_dir = dst_dir / "src" / "databricks" / "labs" / project_name
inner_package_dir.mkdir(parents=True, exist_ok=True)
with (inner_package_dir / "__main__.py").open("w") as f:
f.write(main_py_file.replace("__app__", project_name))
with (inner_package_dir / "__init__.py").open("w") as f:
f.write(f"from databricks.labs.{project_name}.__about__ import __version__")
with (inner_package_dir / "__about__.py").open("w") as f:
f.write('# DO NOT MODIFY THIS FILE BY HAND\n__version__ = "0.0.1"\n')
with (dst_dir / "labs.yml").open("w") as f:
f.write(labs_yml_file.replace("__app__", project_name))
with (dst_dir / "CODEOWNERS").open("w") as f:
f.write(f"* @nfx\n/src @databrickslabs/{project_name}-write\n/tests @databrickslabs/{project_name}-write\n")


if "__main__" == __name__:
blueprint()
79 changes: 79 additions & 0 deletions src/databricks/labs/blueprint/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import json
import logging
from dataclasses import dataclass
from typing import Callable

from databricks.sdk import AccountClient, WorkspaceClient

from databricks.labs.blueprint.entrypoint import get_logger, run_main
from databricks.labs.blueprint.wheels import ProductInfo


@dataclass
class Command:
name: str
description: str
fn: Callable[..., None]
is_account: bool = False
is_unauthenticated: bool = False

def needs_workspace_client(self):
if self.is_unauthenticated:
return False
if self.is_account:
return False
return True


class App:
def __init__(self, __file: str):
self._mapping: dict[str, Command] = {}
self._logger = get_logger(__file)
self._product_info = ProductInfo()

def command(self, is_account: bool = False, is_unauthenticated: bool = False):
def decorator(func):
command_name = func.__name__.replace("_", "-")
if not func.__doc__:
raise SyntaxError(f"{func.__name__} must have some doc comment")
self._mapping[command_name] = Command(
name=command_name,
description=func.__doc__,
fn=func,
is_account=is_account,
is_unauthenticated=is_unauthenticated,
)
return func

return decorator

def _route(self, raw):
payload = json.loads(raw)
command = payload["command"]
if command not in self._mapping:
msg = f"cannot find command: {command}"
raise KeyError(msg)
flags = payload["flags"]
log_level = flags.pop("log_level")
if log_level == "disabled":
log_level = "info"
databricks_logger = logging.getLogger("databricks")
databricks_logger.setLevel(log_level.upper())
kwargs = {k.replace("-", "_"): v for k, v in flags.items()}
try:
product_name = self._product_info.product_name()
product_version = self._product_info.version()
if self._mapping[command].needs_workspace_client():
kwargs["w"] = WorkspaceClient(product=product_name, product_version=product_version)
elif self._mapping[command].is_account:
kwargs["a"] = AccountClient(product=product_name, product_version=product_version)
self._mapping[command].fn(**kwargs)
except Exception as err:
logger = self._logger.getChild(command)
if log_level.lower() in ("debug", "trace"):
logger.error(f"Failed to call {command}", exc_info=err)
else:
logger.error(f"{err.__class__.__name__}: {err}")

def __call__(self):
run_main(self._route)
7 changes: 5 additions & 2 deletions src/databricks/labs/blueprint/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ def get_logger(file_name: str):
entrypoint = Path(file_name).absolute()

relative = entrypoint.relative_to(project_root).as_posix()
relative = relative.lstrip("src" + os.sep)
relative = relative.rstrip(".py")
relative = relative.removeprefix("src" + os.sep)
relative = relative.removesuffix("/__main__.py")
relative = relative.removesuffix("/__init__.py")
relative = relative.removesuffix("/cli.py")
relative = relative.removesuffix(".py")
module_name = relative.replace(os.sep, ".")

logger = logging.getLogger(module_name)
Expand Down
Loading

0 comments on commit 6ce908c

Please sign in to comment.