builddrone 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- builddrone/__init__.py +0 -0
- builddrone/__main__.py +6 -0
- builddrone/base_module.py +19 -0
- builddrone/cli.py +30 -0
- builddrone/drone_exception.py +10 -0
- builddrone/execution_engine.py +89 -0
- builddrone/module/__init__.py +0 -0
- builddrone/module/filesystem/__init__.py +0 -0
- builddrone/module/filesystem/cleanup_module.py +70 -0
- builddrone/module/filesystem/copy_module.py +72 -0
- builddrone/module/python/__init__.py +0 -0
- builddrone/module/python/build_module.py +26 -0
- builddrone/module/python/install_module.py +40 -0
- builddrone/module/python/pylint_module.py +49 -0
- builddrone/module/python/run_module.py +31 -0
- builddrone/module/python/venv_module.py +68 -0
- builddrone/module/robotframework/__init__.py +0 -0
- builddrone/module/robotframework/rebot_module.py +20 -0
- builddrone/module/robotframework/robotframework_base_module.py +70 -0
- builddrone/module/robotframework/test_module.py +20 -0
- builddrone/runner.py +52 -0
- builddrone-0.1.0.dist-info/METADATA +182 -0
- builddrone-0.1.0.dist-info/RECORD +25 -0
- builddrone-0.1.0.dist-info/WHEEL +5 -0
- builddrone-0.1.0.dist-info/top_level.txt +1 -0
builddrone/__init__.py
ADDED
|
File without changes
|
builddrone/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Base module definition for builddrone modules."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from builddrone.runner import Runner
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseModule(ABC): # pylint: disable=too-few-public-methods
|
|
9
|
+
"""Base class for all builddrone modules."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def run(self, runner: Runner, args: dict) -> None:
|
|
13
|
+
"""Execute the module.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
runner: Runner instance used to execute commands.
|
|
17
|
+
args: Module configuration arguments.
|
|
18
|
+
"""
|
|
19
|
+
raise NotImplementedError
|
builddrone/cli.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Command line interface for Builddrone."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from builddrone.drone_exception import DroneException
|
|
9
|
+
from builddrone.execution_engine import ExecutionEngine
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
"""Create the Builddrone CLI parser."""
|
|
14
|
+
parser = argparse.ArgumentParser(prog="python -m builddrone")
|
|
15
|
+
parser.add_argument("stage", help="Pipeline stage to execute")
|
|
16
|
+
return parser
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
20
|
+
"""Run the Builddrone CLI."""
|
|
21
|
+
parser = build_parser()
|
|
22
|
+
args = parser.parse_args(argv)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
engine = ExecutionEngine({})
|
|
26
|
+
engine.run(args.stage)
|
|
27
|
+
except DroneException as exc:
|
|
28
|
+
parser.exit(1, f"{parser.prog}: error: {exc}\n")
|
|
29
|
+
|
|
30
|
+
return 0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Custom exceptions for the Builddrone package."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DroneException(Exception):
|
|
5
|
+
"""Base exception raised by Builddrone-specific runtime errors.
|
|
6
|
+
|
|
7
|
+
Use this exception as the common parent for errors that originate from
|
|
8
|
+
the Builddrone domain, so callers can catch framework-level failures in a
|
|
9
|
+
single place while still allowing more specific exception types later.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Execution engine for running builddrone pipeline stages."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from builddrone.base_module import BaseModule
|
|
7
|
+
from builddrone.drone_exception import DroneException
|
|
8
|
+
from builddrone.module.filesystem.cleanup_module import (
|
|
9
|
+
CleanupModule as FilesystemCleanupModule,
|
|
10
|
+
)
|
|
11
|
+
from builddrone.module.filesystem.copy_module import FilesystemCopyModule
|
|
12
|
+
from builddrone.module.python.build_module import PythonBuildModule
|
|
13
|
+
from builddrone.module.python.install_module import PythonInstallModule
|
|
14
|
+
from builddrone.module.python.pylint_module import PylintModule
|
|
15
|
+
from builddrone.module.python.run_module import PythonRunModule
|
|
16
|
+
from builddrone.module.python.venv_module import PythonVirtualEnvironmentModule
|
|
17
|
+
from builddrone.module.robotframework.rebot_module import RobotframeworkRebotModule
|
|
18
|
+
from builddrone.module.robotframework.test_module import RobotframeworkTestModule
|
|
19
|
+
from builddrone.runner import Runner
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExecutionEngine: # pylint: disable=too-few-public-methods
|
|
23
|
+
"""Execute configured builddrone stages."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, modules: dict[str, BaseModule]):
|
|
26
|
+
"""Initialize the execution engine.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
modules: Registered builddrone modules.
|
|
30
|
+
"""
|
|
31
|
+
self._modules = modules
|
|
32
|
+
self._config_path = Path("blueprint.json")
|
|
33
|
+
self._register_module("filesystem.cleanup", FilesystemCleanupModule())
|
|
34
|
+
self._register_module("filesystem.copy", FilesystemCopyModule())
|
|
35
|
+
self._register_module("python.build", PythonBuildModule())
|
|
36
|
+
self._register_module("python.install", PythonInstallModule())
|
|
37
|
+
self._register_module("python.run", PythonRunModule())
|
|
38
|
+
self._register_module("python.venv", PythonVirtualEnvironmentModule())
|
|
39
|
+
self._register_module("python.pylint", PylintModule())
|
|
40
|
+
self._register_module("robotframework.test", RobotframeworkTestModule())
|
|
41
|
+
self._register_module("robotframework.rebot", RobotframeworkRebotModule())
|
|
42
|
+
self._runner = Runner()
|
|
43
|
+
|
|
44
|
+
def run(self, stage: str) -> None:
|
|
45
|
+
"""Execute a build stage from a configuration file."""
|
|
46
|
+
config_path = self._config_path
|
|
47
|
+
config_file = config_path.resolve()
|
|
48
|
+
self._runner.set_base_path(config_file.parent)
|
|
49
|
+
config = self._load_config(str(config_path))
|
|
50
|
+
|
|
51
|
+
if stage not in config:
|
|
52
|
+
raise DroneException(f"Stage '{stage}' not found in config")
|
|
53
|
+
|
|
54
|
+
steps = config[stage]
|
|
55
|
+
|
|
56
|
+
if not isinstance(steps, list):
|
|
57
|
+
raise DroneException(f"Stage '{stage}' must be a list of steps")
|
|
58
|
+
|
|
59
|
+
for step in steps:
|
|
60
|
+
self._execute_step(step)
|
|
61
|
+
|
|
62
|
+
def _execute_step(self, step: dict) -> None:
|
|
63
|
+
"""Execute a single build step."""
|
|
64
|
+
if not isinstance(step, dict):
|
|
65
|
+
raise DroneException("Pipeline step must be an object")
|
|
66
|
+
|
|
67
|
+
module_name = step.get("module")
|
|
68
|
+
|
|
69
|
+
if module_name is None or not isinstance(module_name, str):
|
|
70
|
+
raise DroneException("Unknown module name")
|
|
71
|
+
|
|
72
|
+
args = step.get("args", {})
|
|
73
|
+
module = self._modules.get(module_name)
|
|
74
|
+
|
|
75
|
+
if module is None:
|
|
76
|
+
raise DroneException(f"Unknown module: {module_name}")
|
|
77
|
+
|
|
78
|
+
module.run(self._runner, args)
|
|
79
|
+
|
|
80
|
+
def _register_module(self, name: str, module: BaseModule) -> None:
|
|
81
|
+
"""Register a single build module."""
|
|
82
|
+
if name not in self._modules:
|
|
83
|
+
self._modules[name] = module
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _load_config(path: str) -> dict:
|
|
87
|
+
"""Load build configuration from JSON."""
|
|
88
|
+
with open(path, encoding="utf-8") as file:
|
|
89
|
+
return json.load(file)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Filesystem cleanup module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from builddrone.base_module import BaseModule
|
|
8
|
+
from builddrone.drone_exception import DroneException
|
|
9
|
+
from builddrone.runner import Runner
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CleanupModule(BaseModule): # pylint: disable=too-few-public-methods
|
|
13
|
+
"""A module responsible for cleanup operations.
|
|
14
|
+
|
|
15
|
+
This module handles cleanup, such as removing temporary files or folders.
|
|
16
|
+
|
|
17
|
+
Blueprint configuration arguments:
|
|
18
|
+
"files": ["List of files to delete"]
|
|
19
|
+
"folders": ["List of folders to delete"]
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def run(self, runner: Runner, args: dict) -> None:
|
|
23
|
+
runner.logger.info("Cleaning up...")
|
|
24
|
+
base_path = Path(runner.get_base_path())
|
|
25
|
+
self._delete_files(runner, args.get("files", []), base_path)
|
|
26
|
+
self._delete_folders(runner, args.get("folders", []), base_path)
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def _delete_files(runner: Runner, files: list, base_path: Path):
|
|
30
|
+
for file in files:
|
|
31
|
+
resolved_file = Path(file)
|
|
32
|
+
if not os.path.isabs(file):
|
|
33
|
+
resolved_file = base_path / file
|
|
34
|
+
|
|
35
|
+
if not os.path.exists(resolved_file):
|
|
36
|
+
# If file not exists, nothing to delete
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
if os.path.isfile(resolved_file):
|
|
41
|
+
os.remove(resolved_file)
|
|
42
|
+
runner.logger.info(f"Deleted file: {resolved_file}")
|
|
43
|
+
else:
|
|
44
|
+
raise DroneException(f"Is not a file: {resolved_file}")
|
|
45
|
+
except OSError as e:
|
|
46
|
+
msg = f"Error deleting file {resolved_file} : {e}"
|
|
47
|
+
runner.logger.error(msg)
|
|
48
|
+
raise DroneException(msg) from e
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _delete_folders(runner: Runner, folders: list, base_path: Path):
|
|
52
|
+
for folder in folders:
|
|
53
|
+
resolved_folder = Path(folder)
|
|
54
|
+
if not os.path.isabs(folder):
|
|
55
|
+
resolved_folder = base_path / folder
|
|
56
|
+
|
|
57
|
+
if not os.path.exists(resolved_folder):
|
|
58
|
+
# If folder not exists, nothing to delete
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
if os.path.isdir(resolved_folder):
|
|
63
|
+
shutil.rmtree(resolved_folder)
|
|
64
|
+
runner.logger.info(f"Deleted folder: {resolved_folder}")
|
|
65
|
+
else:
|
|
66
|
+
raise DroneException(f"Is not a folder: {resolved_folder}")
|
|
67
|
+
except OSError as e:
|
|
68
|
+
msg = f"Error deleting folder {resolved_folder} : {e}"
|
|
69
|
+
runner.logger.error(msg)
|
|
70
|
+
raise DroneException(msg) from e
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Filesystem copy module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from builddrone.base_module import BaseModule
|
|
8
|
+
from builddrone.drone_exception import DroneException
|
|
9
|
+
from builddrone.runner import Runner
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FilesystemCopyModule(BaseModule): # pylint: disable=too-few-public-methods
|
|
13
|
+
"""A module responsible for copying files from one folder to another.
|
|
14
|
+
|
|
15
|
+
Blueprint configuration arguments:
|
|
16
|
+
"source": "Source directory to copy from"
|
|
17
|
+
"destination": "Destination directory to copy to"
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def run(self, runner: Runner, args: dict) -> None:
|
|
21
|
+
"""Copy all files from a source directory into a destination directory."""
|
|
22
|
+
runner.logger.info("Copying files...")
|
|
23
|
+
source = args.get("source")
|
|
24
|
+
destination = args.get("destination")
|
|
25
|
+
base_path = Path(runner.get_base_path())
|
|
26
|
+
|
|
27
|
+
if not isinstance(source, str) or not source:
|
|
28
|
+
raise DroneException("No source provided for copy")
|
|
29
|
+
|
|
30
|
+
if not isinstance(destination, str) or not destination:
|
|
31
|
+
raise DroneException("No destination provided for copy")
|
|
32
|
+
|
|
33
|
+
self._copy_tree(runner, source, destination, base_path)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _copy_tree(
|
|
37
|
+
runner: Runner, source: str, destination: str, base_path: Path
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Copy a directory tree preserving relative paths."""
|
|
40
|
+
source_path = Path(source)
|
|
41
|
+
destination_path = Path(destination)
|
|
42
|
+
|
|
43
|
+
if not os.path.isabs(source):
|
|
44
|
+
source_path = base_path / source_path
|
|
45
|
+
|
|
46
|
+
if not os.path.isabs(destination):
|
|
47
|
+
destination_path = base_path / destination_path
|
|
48
|
+
|
|
49
|
+
if not os.path.isdir(source_path):
|
|
50
|
+
raise DroneException(f"Source is not a directory: {source_path}")
|
|
51
|
+
|
|
52
|
+
os.makedirs(destination_path, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
for root, _, files in os.walk(source_path):
|
|
55
|
+
relative_root = os.path.relpath(root, source_path)
|
|
56
|
+
target_root = (
|
|
57
|
+
destination_path
|
|
58
|
+
if relative_root == "."
|
|
59
|
+
else os.path.join(destination_path, relative_root)
|
|
60
|
+
)
|
|
61
|
+
os.makedirs(target_root, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
for file_name in files:
|
|
64
|
+
source_file = os.path.join(root, file_name)
|
|
65
|
+
destination_file = os.path.join(target_root, file_name)
|
|
66
|
+
try:
|
|
67
|
+
shutil.copy2(source_file, destination_file)
|
|
68
|
+
runner.logger.info("Copied file: %s", source_file)
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
msg = f"Error copying file {source_file} : {exc}"
|
|
71
|
+
runner.logger.error(msg)
|
|
72
|
+
raise DroneException(msg) from exc
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Python build module."""
|
|
2
|
+
|
|
3
|
+
from builddrone.base_module import BaseModule
|
|
4
|
+
from builddrone.drone_exception import DroneException
|
|
5
|
+
from builddrone.runner import Runner
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PythonBuildModule(BaseModule): # pylint: disable=too-few-public-methods
|
|
9
|
+
"""A module responsible for building the project.
|
|
10
|
+
|
|
11
|
+
Blueprint configuration arguments:
|
|
12
|
+
None
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def run(self, runner: Runner, _args: dict) -> None:
|
|
16
|
+
"""Build the project with ``python -m build``.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
runner: Runner instance used to execute commands.
|
|
20
|
+
_args: Module configuration arguments. Unused.
|
|
21
|
+
"""
|
|
22
|
+
runner.logger.info("Building...")
|
|
23
|
+
exit_code = runner.run(["-m", "build"], cwd=str(runner.get_base_path()))
|
|
24
|
+
|
|
25
|
+
if exit_code != 0:
|
|
26
|
+
raise DroneException(f"Build failed with exit code {exit_code}")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Python install module."""
|
|
2
|
+
|
|
3
|
+
from builddrone.base_module import BaseModule
|
|
4
|
+
from builddrone.drone_exception import DroneException
|
|
5
|
+
from builddrone.runner import Runner
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PythonInstallModule(BaseModule): # pylint: disable=too-few-public-methods
|
|
9
|
+
"""A module responsible for installing Python packages.
|
|
10
|
+
|
|
11
|
+
Blueprint configuration arguments:
|
|
12
|
+
"source": "Package source to install"
|
|
13
|
+
"requirements": "Requirements file to install"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def run(self, runner: Runner, args: dict) -> None:
|
|
17
|
+
"""Install a package source with ``python -m pip install``.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
runner: Runner instance used to execute commands.
|
|
21
|
+
args: Module configuration arguments.
|
|
22
|
+
"""
|
|
23
|
+
runner.logger.info("Installing...")
|
|
24
|
+
source = args.get("source")
|
|
25
|
+
requirements = args.get("requirements")
|
|
26
|
+
|
|
27
|
+
if isinstance(requirements, str) and requirements:
|
|
28
|
+
install_args = ["-r", requirements]
|
|
29
|
+
elif isinstance(source, str) and source:
|
|
30
|
+
install_args = [source]
|
|
31
|
+
else:
|
|
32
|
+
raise DroneException("No source or requirements provided for install")
|
|
33
|
+
|
|
34
|
+
exit_code = runner.run(
|
|
35
|
+
["-m", "pip", "install", *install_args],
|
|
36
|
+
cwd=str(runner.get_base_path()),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if exit_code != 0:
|
|
40
|
+
raise DroneException(f"Install failed with exit code {exit_code}")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Python linting module."""
|
|
2
|
+
|
|
3
|
+
from builddrone.base_module import BaseModule
|
|
4
|
+
from builddrone.drone_exception import DroneException
|
|
5
|
+
from builddrone.runner import Runner
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PylintModule(BaseModule): # pylint: disable=too-few-public-methods
|
|
9
|
+
"""A module responsible for running pylint.
|
|
10
|
+
|
|
11
|
+
Blueprint configuration arguments:
|
|
12
|
+
"paths": ["List of paths to lint"]
|
|
13
|
+
"files": ["Optional list of individual Python files to lint"]
|
|
14
|
+
"ignore": ["Optional list of file or directory base names to skip"]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def run(self, runner: Runner, args: dict) -> None:
|
|
18
|
+
runner.logger.info("Pylint...")
|
|
19
|
+
paths = args.get("paths", [])
|
|
20
|
+
files = args.get("files", [])
|
|
21
|
+
ignore = args.get("ignore", [])
|
|
22
|
+
|
|
23
|
+
if not isinstance(paths, list) or not all(
|
|
24
|
+
isinstance(item, str) and item for item in paths
|
|
25
|
+
):
|
|
26
|
+
raise DroneException("Paths must be a list of non-empty strings")
|
|
27
|
+
|
|
28
|
+
if not isinstance(files, list) or not all(
|
|
29
|
+
isinstance(item, str) and item for item in files
|
|
30
|
+
):
|
|
31
|
+
raise DroneException("Files must be a list of non-empty strings")
|
|
32
|
+
|
|
33
|
+
if not paths and not files:
|
|
34
|
+
raise DroneException("No paths or files provided for pylint")
|
|
35
|
+
|
|
36
|
+
if not isinstance(ignore, list) or not all(
|
|
37
|
+
isinstance(item, str) and item for item in ignore
|
|
38
|
+
):
|
|
39
|
+
raise DroneException("Ignore must be a list of non-empty strings")
|
|
40
|
+
|
|
41
|
+
command = ["-m", "pylint"]
|
|
42
|
+
if ignore:
|
|
43
|
+
command.extend(["--ignore", ",".join(ignore)])
|
|
44
|
+
command.extend([*paths, *files])
|
|
45
|
+
|
|
46
|
+
exit_code = runner.run(command, cwd=str(runner.get_base_path()))
|
|
47
|
+
|
|
48
|
+
if exit_code != 0:
|
|
49
|
+
raise DroneException(f"Pylint failed with exit code {exit_code}")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Python run module."""
|
|
2
|
+
|
|
3
|
+
from builddrone.base_module import BaseModule
|
|
4
|
+
from builddrone.drone_exception import DroneException
|
|
5
|
+
from builddrone.runner import Runner
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PythonRunModule(BaseModule): # pylint: disable=too-few-public-methods
|
|
9
|
+
"""A module responsible for running Python source files.
|
|
10
|
+
|
|
11
|
+
Blueprint configuration arguments:
|
|
12
|
+
"source": "Python file to execute"
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def run(self, runner: Runner, args: dict) -> None:
|
|
16
|
+
"""Run a Python source file with the configured interpreter.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
runner: Runner instance used to execute commands.
|
|
20
|
+
args: Module configuration arguments.
|
|
21
|
+
"""
|
|
22
|
+
runner.logger.info("Running...")
|
|
23
|
+
source = args.get("source")
|
|
24
|
+
|
|
25
|
+
if not isinstance(source, str) or not source:
|
|
26
|
+
raise DroneException("No source provided for run")
|
|
27
|
+
|
|
28
|
+
exit_code = runner.run([source], cwd=str(runner.get_base_path()))
|
|
29
|
+
|
|
30
|
+
if exit_code != 0:
|
|
31
|
+
raise DroneException(f"Run failed with exit code {exit_code}")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Python virtual environment module."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import venv
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from builddrone.base_module import BaseModule
|
|
8
|
+
from builddrone.drone_exception import DroneException
|
|
9
|
+
from builddrone.runner import Runner
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PythonVirtualEnvironmentModule(
|
|
13
|
+
BaseModule
|
|
14
|
+
): # pylint: disable=too-few-public-methods
|
|
15
|
+
"""A module responsible for configuring a Python virtual environment.
|
|
16
|
+
|
|
17
|
+
Blueprint configuration arguments:
|
|
18
|
+
"source": "Virtual environment root path to use; empty string resets the runner"
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def run(self, runner: Runner, args: dict) -> None:
|
|
22
|
+
"""Configure the runner to use a virtual environment interpreter."""
|
|
23
|
+
source = args.get("source")
|
|
24
|
+
|
|
25
|
+
if source == "":
|
|
26
|
+
runner.logger.info("Resetting runner to the current Python interpreter...")
|
|
27
|
+
runner.reset_runner()
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
if not isinstance(source, str) or not source:
|
|
31
|
+
raise DroneException("No source provided for virtual environment")
|
|
32
|
+
|
|
33
|
+
venv_path = Path(source)
|
|
34
|
+
if not venv_path.is_absolute():
|
|
35
|
+
venv_path = Path(runner.get_base_path()) / venv_path
|
|
36
|
+
|
|
37
|
+
python_executable = self._resolve_python_executable(venv_path)
|
|
38
|
+
|
|
39
|
+
if python_executable is None:
|
|
40
|
+
runner.logger.info("Creating virtual environment: %s", venv_path)
|
|
41
|
+
try:
|
|
42
|
+
venv.create(venv_path, with_pip=True)
|
|
43
|
+
except (OSError, RuntimeError, subprocess.CalledProcessError) as exception:
|
|
44
|
+
raise DroneException(
|
|
45
|
+
f"Could not create virtual environment: {venv_path}"
|
|
46
|
+
) from exception
|
|
47
|
+
|
|
48
|
+
python_executable = self._resolve_python_executable(venv_path)
|
|
49
|
+
|
|
50
|
+
if python_executable is None:
|
|
51
|
+
raise DroneException(f"Invalid virtual environment path: {venv_path}")
|
|
52
|
+
|
|
53
|
+
runner.logger.info("Using virtual environment: %s", venv_path)
|
|
54
|
+
runner.set_runner(str(python_executable))
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _resolve_python_executable(venv_path: Path) -> Path | None:
|
|
58
|
+
"""Resolve the interpreter path inside the virtual environment."""
|
|
59
|
+
candidate_paths = (
|
|
60
|
+
venv_path / "Scripts" / "python.exe",
|
|
61
|
+
venv_path / "bin" / "python",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
for candidate in candidate_paths:
|
|
65
|
+
if candidate.is_file():
|
|
66
|
+
return candidate
|
|
67
|
+
|
|
68
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Robot Framework rebot module."""
|
|
2
|
+
|
|
3
|
+
from builddrone.module.robotframework.robotframework_base_module import (
|
|
4
|
+
RobotframeworkBaseModule,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RobotframeworkRebotModule( # pylint: disable=too-few-public-methods
|
|
9
|
+
RobotframeworkBaseModule
|
|
10
|
+
):
|
|
11
|
+
"""A module responsible for running Robot Framework's rebot command.
|
|
12
|
+
|
|
13
|
+
Blueprint configuration arguments:
|
|
14
|
+
"arguments": "Dictionary of CLI arguments to pass to rebot"
|
|
15
|
+
"cwd": "Optional working directory to run rebot from"
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
command_prefix = ["-m", "robot.rebot"]
|
|
19
|
+
log_message = "Rebot..."
|
|
20
|
+
failure_label = "Rebot"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Shared helpers for Robot Framework modules."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from builddrone.base_module import BaseModule
|
|
7
|
+
from builddrone.drone_exception import DroneException
|
|
8
|
+
from builddrone.runner import Runner
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RobotframeworkBaseModule(
|
|
12
|
+
BaseModule, ABC
|
|
13
|
+
): # pylint: disable=too-few-public-methods
|
|
14
|
+
"""Common behavior for Robot Framework command modules."""
|
|
15
|
+
|
|
16
|
+
command_prefix: list[str] = []
|
|
17
|
+
log_message: str = ""
|
|
18
|
+
failure_label: str = ""
|
|
19
|
+
|
|
20
|
+
def run(self, runner: Runner, args: dict) -> None:
|
|
21
|
+
"""Run a Robot Framework command with expanded CLI arguments."""
|
|
22
|
+
runner.logger.info(self.log_message)
|
|
23
|
+
arguments = args.get("arguments", {})
|
|
24
|
+
cwd = args.get("cwd")
|
|
25
|
+
|
|
26
|
+
if not isinstance(arguments, dict):
|
|
27
|
+
raise DroneException("Arguments must be a dictionary")
|
|
28
|
+
|
|
29
|
+
if cwd is not None and not isinstance(cwd, (str, Path)):
|
|
30
|
+
raise DroneException("Cwd must be a path or string")
|
|
31
|
+
|
|
32
|
+
working_directory = Path(runner.get_base_path())
|
|
33
|
+
if cwd is not None:
|
|
34
|
+
configured_cwd = Path(cwd)
|
|
35
|
+
working_directory = (
|
|
36
|
+
configured_cwd
|
|
37
|
+
if configured_cwd.is_absolute()
|
|
38
|
+
else working_directory / configured_cwd
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
command = self._build_command(arguments)
|
|
42
|
+
exit_code = runner.run(command, cwd=str(working_directory))
|
|
43
|
+
|
|
44
|
+
if exit_code != 0:
|
|
45
|
+
raise DroneException(
|
|
46
|
+
f"{self.failure_label} failed with exit code {exit_code}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _build_command(self, arguments: dict) -> list[str]:
|
|
50
|
+
"""Convert a dictionary of CLI options into a Robot Framework command."""
|
|
51
|
+
command: list[str] = list(self.command_prefix)
|
|
52
|
+
|
|
53
|
+
for key, value in arguments.items():
|
|
54
|
+
if isinstance(value, bool):
|
|
55
|
+
if value:
|
|
56
|
+
command.append(str(key))
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
if isinstance(value, (list, tuple)):
|
|
60
|
+
command.append(str(key))
|
|
61
|
+
command.extend(str(item) for item in value)
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if value is None:
|
|
65
|
+
command.append(str(key))
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
command.extend([str(key), str(value)])
|
|
69
|
+
|
|
70
|
+
return command
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Robot Framework test module."""
|
|
2
|
+
|
|
3
|
+
from builddrone.module.robotframework.robotframework_base_module import (
|
|
4
|
+
RobotframeworkBaseModule,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RobotframeworkTestModule( # pylint: disable=too-few-public-methods
|
|
9
|
+
RobotframeworkBaseModule
|
|
10
|
+
):
|
|
11
|
+
"""A module responsible for running Robot Framework test suites.
|
|
12
|
+
|
|
13
|
+
Blueprint configuration arguments:
|
|
14
|
+
"arguments": "Dictionary of CLI arguments to pass to robot"
|
|
15
|
+
"cwd": "Optional working directory to run robot from"
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
command_prefix = ["-m", "robot"]
|
|
19
|
+
log_message = "Robot..."
|
|
20
|
+
failure_label = "Robot"
|
builddrone/runner.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Runner module for executing build commands."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from builddrone.drone_exception import DroneException
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Runner:
|
|
13
|
+
"""Execute build commands using a configured Python interpreter."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
"""Initialize the runner."""
|
|
17
|
+
logging.basicConfig(level=logging.INFO)
|
|
18
|
+
self.logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
self._python_path = sys.executable
|
|
21
|
+
self._base_path: Path | None = None
|
|
22
|
+
if not self._python_path:
|
|
23
|
+
raise DroneException("Python executable not found")
|
|
24
|
+
|
|
25
|
+
def set_runner(self, python_path):
|
|
26
|
+
"""Set the Python executable used for command execution."""
|
|
27
|
+
if os.path.exists(python_path) and os.path.isfile(python_path):
|
|
28
|
+
self._python_path = python_path
|
|
29
|
+
|
|
30
|
+
def reset_runner(self):
|
|
31
|
+
"""Reset the runner to use the current Python interpreter."""
|
|
32
|
+
self._python_path = sys.executable
|
|
33
|
+
if not self._python_path:
|
|
34
|
+
raise DroneException("Python executable not found")
|
|
35
|
+
|
|
36
|
+
def set_base_path(self, base_path: str | Path | None) -> None:
|
|
37
|
+
"""Set the base directory used for relative paths."""
|
|
38
|
+
self._base_path = None if base_path is None else Path(base_path)
|
|
39
|
+
|
|
40
|
+
def get_base_path(self) -> Path:
|
|
41
|
+
"""Return the base directory used for relative paths."""
|
|
42
|
+
return self._base_path or Path.cwd()
|
|
43
|
+
|
|
44
|
+
def run(self, cmd, cwd=None) -> int:
|
|
45
|
+
"""Execute a Python command and return the exit code."""
|
|
46
|
+
full_cmd = [self._python_path] + cmd
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
full_cmd,
|
|
49
|
+
cwd=cwd,
|
|
50
|
+
check=False,
|
|
51
|
+
)
|
|
52
|
+
return result.returncode
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: builddrone
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Modular build framework
|
|
5
|
+
Author: Andreas Sekulski
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: build; extra == "dev"
|
|
10
|
+
Requires-Dist: twine; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest; extra == "dev"
|
|
12
|
+
Requires-Dist: black; extra == "dev"
|
|
13
|
+
Requires-Dist: isort; extra == "dev"
|
|
14
|
+
Requires-Dist: pylint; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# Builddrone
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
Builddrone is a JSON-driven build orchestration framework and command-line
|
|
21
|
+
interface for Python projects. A pipeline is made up of named stages, and each
|
|
22
|
+
stage executes an ordered list of registered modules.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
Install the project and its development tools from the repository root:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python -m pip install -e ".[dev]"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Builddrone requires Python 3.10 or newer.
|
|
33
|
+
|
|
34
|
+
## Blueprint format
|
|
35
|
+
|
|
36
|
+
Builddrone loads `blueprint.json` from the current working directory. The
|
|
37
|
+
top-level keys are stage names, and each stage contains module steps:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"build": [
|
|
42
|
+
{
|
|
43
|
+
"module": "python.venv",
|
|
44
|
+
"args": {"source": ".venv"}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"module": "python.install",
|
|
48
|
+
"args": {"requirements": "requirements.txt"}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"module": "python.run",
|
|
52
|
+
"args": {"source": "main.py"}
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Relative paths are resolved from the directory containing `blueprint.json`.
|
|
59
|
+
Each step runs only after the preceding step succeeds.
|
|
60
|
+
|
|
61
|
+
## Command-line usage
|
|
62
|
+
|
|
63
|
+
Run a stage from the directory containing its blueprint:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
cd example/python
|
|
67
|
+
python -m builddrone build
|
|
68
|
+
python -m builddrone cleanup
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The Python example creates `.venv` automatically, installs its requirements,
|
|
72
|
+
lints and runs `main.py`, and builds a wheel and source distribution with
|
|
73
|
+
`python -m build`. The separate `cleanup` stage removes the temporary
|
|
74
|
+
environment and generated build artifacts.
|
|
75
|
+
|
|
76
|
+
The Robot Framework example runs similarly:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cd example/robotframework
|
|
80
|
+
python -m builddrone test
|
|
81
|
+
python -m builddrone cleanup
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Its `robotframework.test` step writes `results/output.xml`, and
|
|
85
|
+
`robotframework.rebot` converts that result into reports under
|
|
86
|
+
`results/rebot`.
|
|
87
|
+
|
|
88
|
+
## Built-in modules
|
|
89
|
+
|
|
90
|
+
### Filesystem
|
|
91
|
+
|
|
92
|
+
| Module | Arguments | Purpose |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `filesystem.copy` | `source`, `destination` | Copy a directory tree. |
|
|
95
|
+
| `filesystem.cleanup` | `files`, `folders` | Remove listed files and folders. |
|
|
96
|
+
|
|
97
|
+
### Python
|
|
98
|
+
|
|
99
|
+
| Module | Arguments | Purpose |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `python.venv` | `source` | Select an environment, creating it with pip if needed. An empty source resets the runner. |
|
|
102
|
+
| `python.install` | `requirements` or `source` | Install requirements or a package with pip. |
|
|
103
|
+
| `python.run` | `source` | Run a Python source file. |
|
|
104
|
+
| `python.build` | none | Run `python -m build`. |
|
|
105
|
+
| `python.pylint` | `paths`, `files`, `ignore` | Run Pylint against paths or individual files. |
|
|
106
|
+
|
|
107
|
+
### Robot Framework
|
|
108
|
+
|
|
109
|
+
Both Robot Framework modules accept an `arguments` dictionary and an optional
|
|
110
|
+
`cwd`:
|
|
111
|
+
|
|
112
|
+
| Module | Purpose |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `robotframework.test` | Run `python -m robot`. |
|
|
115
|
+
| `robotframework.rebot` | Run `python -m robot.rebot` to post-process results. |
|
|
116
|
+
|
|
117
|
+
Dictionary values are converted to command-line arguments. A `null` value is a
|
|
118
|
+
flag or positional argument, a string receives a following value, and a list
|
|
119
|
+
receives multiple values:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"module": "robotframework.test",
|
|
124
|
+
"args": {
|
|
125
|
+
"arguments": {
|
|
126
|
+
"--outputdir": "results",
|
|
127
|
+
"tests": null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Using Builddrone as a framework
|
|
134
|
+
|
|
135
|
+
Built-in modules are registered by `ExecutionEngine`. Custom modules can be
|
|
136
|
+
added by passing them to the constructor:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from builddrone.base_module import BaseModule
|
|
140
|
+
from builddrone.execution_engine import ExecutionEngine
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class GreetingModule(BaseModule):
|
|
144
|
+
def run(self, runner, args):
|
|
145
|
+
runner.logger.info("Hello from %s", args.get("name", "Builddrone"))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
engine = ExecutionEngine({"custom.greeting": GreetingModule()})
|
|
149
|
+
engine.run("build")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The custom module can then be used in `blueprint.json`:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"build": [
|
|
157
|
+
{
|
|
158
|
+
"module": "custom.greeting",
|
|
159
|
+
"args": {"name": "Builddrone"}
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Every module receives a `Runner` and a dictionary of arguments. The runner
|
|
166
|
+
executes commands with the currently selected Python interpreter and exposes
|
|
167
|
+
the blueprint's base directory for relative paths.
|
|
168
|
+
|
|
169
|
+
## Execution flow
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
python -m builddrone <stage>
|
|
173
|
+
|
|
|
174
|
+
v
|
|
175
|
+
load blueprint.json -> select stage -> execute steps -> report failures
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Any non-zero command exit code raises `DroneException` and stops the stage.
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT License
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
builddrone/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
builddrone/__main__.py,sha256=ywngXh63RbvJp9nA2PJmI8n3Cqh044yywZe4_NG2P3s,127
|
|
3
|
+
builddrone/base_module.py,sha256=qFOM19wOY-RidGMiEzsrS5-G2aHHQYZRzr2bG3gctEc,524
|
|
4
|
+
builddrone/cli.py,sha256=mmC7f2dvkJ_2JzSmBNgMxgaQIkkpeQGKf4ETduQypJM,824
|
|
5
|
+
builddrone/drone_exception.py,sha256=Hb8FW7nnX3l1U5BbfNToulMuzhEv2sOJlKWX269U_8Q,392
|
|
6
|
+
builddrone/execution_engine.py,sha256=xrBAvyNynIJm3j5gy2LCMLW6BIf2v2Btwz6omXj6kYw,3597
|
|
7
|
+
builddrone/runner.py,sha256=rM3cvaNJrJEDfZ3S_LF2LflZV-mti0uhRqV4PTyR_yU,1730
|
|
8
|
+
builddrone/module/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
builddrone/module/filesystem/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
builddrone/module/filesystem/cleanup_module.py,sha256=7BfH1Z9W5bgSahGYatrRp6KUTLLiidePgp-c-oJVneA,2602
|
|
11
|
+
builddrone/module/filesystem/copy_module.py,sha256=yH8lwtRBnWbxFeQkrlbcZaufc3ywyQOX73jz-9h46UY,2688
|
|
12
|
+
builddrone/module/python/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
builddrone/module/python/build_module.py,sha256=lSPzzME9HsaZgyYVsXVjLtvdw1xQexQuyCafdPGFHjg,849
|
|
14
|
+
builddrone/module/python/install_module.py,sha256=r5-FMbfpIu8uwKzilEZnAK-VT5D3k0pRzGwt-booeIw,1391
|
|
15
|
+
builddrone/module/python/pylint_module.py,sha256=pKs8WlP7IzHxjbimfuNxXxyGJ7gaifhfX5nrAVZPm1U,1795
|
|
16
|
+
builddrone/module/python/run_module.py,sha256=W55SGS4NtkD2U5oa5u--Mwa0iAuktJCE0xZCKCMJVoY,1034
|
|
17
|
+
builddrone/module/python/venv_module.py,sha256=P6QIMsCQ3OssBXCXulMbIwm9JPAKqjrYoRYjW-7aOTI,2392
|
|
18
|
+
builddrone/module/robotframework/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
builddrone/module/robotframework/rebot_module.py,sha256=EszcfKSR_TT7rmCBkXel-wZQEadlCUCC2ul2hDfYgnk,607
|
|
20
|
+
builddrone/module/robotframework/robotframework_base_module.py,sha256=v1KWtBaAgnLX4SAqJBmWpAqxDF4F79OMOgdkret18rw,2287
|
|
21
|
+
builddrone/module/robotframework/test_module.py,sha256=4md-kQMXI3D4I8zvM2AvesU0WqY0AMoJ_W-lHkY5P7Y,595
|
|
22
|
+
builddrone-0.1.0.dist-info/METADATA,sha256=1yrHfgdkcynN4m3lmmIUZNhxAJfYPDRdwev4mDjQpVY,4624
|
|
23
|
+
builddrone-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
24
|
+
builddrone-0.1.0.dist-info/top_level.txt,sha256=gUWOrFMfu2uJaRS6sDDChaoIsMOVkzqKU_SPDMkKRQ8,11
|
|
25
|
+
builddrone-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
builddrone
|