Skip to content

Commit a94f1e8

Browse files
committed
Allow installation of platform dependencies from local path
This change allows the action to be used for easy compilation testing of platforms. The easiest way to install the platform's tools dependencies is via Board Manager. If the platform from the local path was also installed via Board Manager (as identified by them having the same name value, the Board Manager platform will be replaced by the one from the local path.
1 parent 6cf0122 commit a94f1e8

File tree

3 files changed

+230
-0
lines changed

3 files changed

+230
-0
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ YAML-format list of platform dependencies to install.
2222

2323
Default `""`. If no `platforms` input is provided, the board's dependency will be automatically determined from the `fqbn` input and the latest version of that platform will be installed via Board Manager.
2424

25+
If a platform dependency from a non-Board Manager source of the same name as another Board Manager source platform dependency is defined, they will both be installed, with the non-Board Manager dependency overwriting the Board Manager platform installation. This permits testing against a non-release version of a platform while using Board Manager to install the platform's tools dependencies.
26+
Example:
27+
```yaml
28+
platforms: |
29+
# Install the latest release of Arduino SAMD Boards and its toolchain via Board Manager
30+
- name: "arduino:samd"
31+
# Install the platform from the root of the repository, replacing the BM installed platform
32+
- source-path: "."
33+
name: "arduino:samd"
34+
```
35+
2536
#### Sources:
2637

2738
##### Board Manager
@@ -30,6 +41,12 @@ Keys:
3041
- `name` - platform name in the form of `VENDOR:ARCHITECTURE`.
3142
- `version` - version of the platform to install. Default is the latest version.
3243

44+
##### Local path
45+
46+
Keys:
47+
- `source-path` - path to install as a platform. Relative paths are assumed to be relative to the root of the repository.
48+
- `name` - platform name in the form of `VENDOR:ARCHITECTURE`.
49+
3350
### `libraries`
3451

3552
YAML-format list of library dependencies to install.

compilesketches/compilesketches.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ class RunCommandOutput(enum.Enum):
7777

7878
arduino_cli_installation_path = pathlib.Path.home().joinpath("bin")
7979
arduino_cli_user_directory_path = pathlib.Path.home().joinpath("Arduino")
80+
arduino_cli_data_directory_path = pathlib.Path.home().joinpath(".arduino15")
8081
libraries_path = arduino_cli_user_directory_path.joinpath("libraries")
82+
user_platforms_path = arduino_cli_user_directory_path.joinpath("hardware")
83+
board_manager_platforms_path = arduino_cli_data_directory_path.joinpath("packages")
8184

8285
report_fqbn_key = "fqbn"
8386
report_sketch_key = "sketch"
@@ -206,6 +209,8 @@ def install_arduino_cli(self):
206209

207210
# Configure the location of the Arduino CLI user directory
208211
os.environ["ARDUINO_DIRECTORIES_USER"] = str(self.arduino_cli_user_directory_path)
212+
# Configure the location of the Arduino CLI data directory
213+
os.environ["ARDUINO_DIRECTORIES_DATA"] = str(self.arduino_cli_data_directory_path)
209214

210215
def verbose_print(self, *print_arguments):
211216
"""Print log output when in verbose mode"""
@@ -226,6 +231,9 @@ def install_platforms(self):
226231
# override system will work
227232
self.install_platforms_from_board_manager(platform_list=platform_list.manager)
228233

234+
if len(platform_list.path) > 0:
235+
self.install_platforms_from_path(platform_list=platform_list.path)
236+
229237
def get_fqbn_platform_dependency(self):
230238
"""Return the platform dependency definition automatically generated from the FQBN."""
231239
# Extract the platform name from the FQBN (e.g., arduino:avr:uno => arduino:avr)
@@ -384,6 +392,80 @@ def run_command(self, command, enable_output=RunCommandOutput.ON_FAILURE, exit_o
384392

385393
return command_data
386394

395+
def install_platforms_from_path(self, platform_list):
396+
"""Install libraries from local paths
397+
398+
Keyword arguments:
399+
platform_list -- Dependencies object containing lists of dictionaries defining platform dependencies of each
400+
source type
401+
"""
402+
for platform in platform_list:
403+
source_path = absolute_path(platform[self.dependency_source_path_key])
404+
self.verbose_print("Installing platform from path:", platform[self.dependency_source_path_key])
405+
406+
if not source_path.exists():
407+
print("::error::Platform source path:", platform[self.dependency_source_path_key], "doesn't exist")
408+
sys.exit(1)
409+
410+
platform_installation_path = self.get_platform_installation_path(platform=platform)
411+
412+
# Create the parent path if it doesn't exist already. This must be the immediate parent, whereas
413+
# get_platform_installation_path().platform will be multiple nested folders under the base path
414+
platform_installation_path_parent = (
415+
pathlib.Path(platform_installation_path.base, platform_installation_path.platform).parent
416+
)
417+
platform_installation_path_parent.mkdir(parents=True, exist_ok=True)
418+
419+
# Install the platform by creating a symlink
420+
destination_path = platform_installation_path.base.joinpath(platform_installation_path.platform)
421+
destination_path.symlink_to(target=source_path, target_is_directory=True)
422+
423+
def get_platform_installation_path(self, platform):
424+
"""Return the correct installation path for the given platform
425+
426+
Keyword arguments:
427+
platform -- dictionary defining the platform dependency
428+
"""
429+
430+
class PlatformInstallationPath:
431+
def __init__(self):
432+
self.base = pathlib.PurePath()
433+
self.platform = pathlib.PurePath()
434+
435+
platform_installation_path = PlatformInstallationPath()
436+
437+
platform_vendor = platform[self.dependency_name_key].split(sep=":")[0]
438+
platform_architecture = platform[self.dependency_name_key].rsplit(sep=":", maxsplit=1)[1]
439+
440+
# Default to installing to the sketchbook
441+
platform_installation_path.base = self.user_platforms_path
442+
platform_installation_path.platform = pathlib.PurePath(platform_vendor, platform_architecture)
443+
444+
# I have no clue why this is needed, but arduino-cli core list fails if this isn't done first. The 3rd party
445+
# platforms are still shown in the list even if their index URLs are not specified to the command via the
446+
# --additional-urls option
447+
self.run_arduino_cli_command(command=["core", "update-index"])
448+
# Use Arduino CLI to get the list of installed platforms
449+
command_data = self.run_arduino_cli_command(command=["core", "list", "--format", "json"])
450+
installed_platform_list = json.loads(command_data.stdout)
451+
for installed_platform in installed_platform_list:
452+
if installed_platform["ID"] == platform[self.dependency_name_key]:
453+
# The platform has been installed via Board Manager, so do an overwrite
454+
platform_installation_path.base = self.board_manager_platforms_path
455+
platform_installation_path.platform = (
456+
pathlib.PurePath(platform_vendor,
457+
"hardware",
458+
platform_architecture,
459+
installed_platform["Installed"])
460+
)
461+
462+
# Remove the existing installation so it can be replaced by the installation function
463+
shutil.rmtree(path=platform_installation_path.base.joinpath(platform_installation_path.platform))
464+
465+
break
466+
467+
return platform_installation_path
468+
387469
def get_repository_dependency_ref(self, dependency):
388470
"""Return the appropriate git ref value for a repository dependency
389471

compilesketches/tests/test_compilesketches.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,11 +295,13 @@ def test_compile_sketches(mocker, compilation_success_list, expected_success, do
295295
def test_install_arduino_cli(mocker):
296296
cli_version = "1.2.3"
297297
arduino_cli_installation_path = unittest.mock.sentinel.arduino_cli_installation_path
298+
arduino_cli_data_directory_path = pathlib.PurePath("/foo/arduino_cli_data_directory_path")
298299
arduino_cli_user_directory_path = pathlib.PurePath("/foo/arduino_cli_user_directory_path")
299300

300301
compile_sketches = get_compilesketches_object(cli_version=cli_version)
301302
compile_sketches.arduino_cli_installation_path = arduino_cli_installation_path
302303
compile_sketches.arduino_cli_user_directory_path = arduino_cli_user_directory_path
304+
compile_sketches.arduino_cli_data_directory_path = arduino_cli_data_directory_path
303305

304306
mocker.patch("compilesketches.install_from_download", autospec=True)
305307

@@ -311,6 +313,11 @@ def test_install_arduino_cli(mocker):
311313
destination_parent_path=arduino_cli_installation_path)
312314

313315
assert os.environ["ARDUINO_DIRECTORIES_USER"] == str(arduino_cli_user_directory_path)
316+
assert os.environ["ARDUINO_DIRECTORIES_DATA"] == str(arduino_cli_data_directory_path)
317+
del os.environ["ARDUINO_DIRECTORIES_USER"]
318+
del os.environ["ARDUINO_DIRECTORIES_DATA"]
319+
320+
314321
@pytest.mark.parametrize("platforms", ["", "foo"])
315322
def test_install_platforms(mocker, platforms):
316323
fqbn_platform_dependency = unittest.mock.sentinel.fqbn_platform_dependency
@@ -573,6 +580,130 @@ def test_get_manager_dependency_name(dependency, expected_name):
573580
assert compile_sketches.get_manager_dependency_name(dependency=dependency) == expected_name
574581

575582

583+
@pytest.mark.parametrize(
584+
"path_exists, platform_list",
585+
[(False, [{compilesketches.CompileSketches.dependency_source_path_key: pathlib.Path("Foo")}]),
586+
(True, [{compilesketches.CompileSketches.dependency_source_path_key: pathlib.Path("Foo")}])]
587+
)
588+
def test_install_platforms_from_path(capsys, monkeypatch, mocker, path_exists, platform_list):
589+
class PlatformInstallationPath:
590+
def __init__(self):
591+
self.parent = pathlib.PurePath()
592+
self.name = pathlib.PurePath()
593+
594+
platform_installation_path = PlatformInstallationPath()
595+
platform_installation_path.base = pathlib.Path("/foo/PlatformInstallationPathParent")
596+
platform_installation_path.platform = pathlib.Path("PlatformInstallationPathName")
597+
symlink_source_path = pathlib.Path("/foo/SymlinkSourcePath")
598+
599+
monkeypatch.setenv("GITHUB_WORKSPACE", "/foo/GitHubWorkspace")
600+
601+
compile_sketches = get_compilesketches_object()
602+
603+
mocker.patch.object(pathlib.Path, "exists", autospec=True, return_value=path_exists)
604+
mocker.patch("compilesketches.CompileSketches.get_platform_installation_path",
605+
autospec=True,
606+
return_value=platform_installation_path)
607+
mocker.patch.object(pathlib.Path, "mkdir", autospec=True)
608+
mocker.patch.object(pathlib.Path, "joinpath", autospec=True, return_value=symlink_source_path)
609+
mocker.patch.object(pathlib.Path, "symlink_to", autospec=True)
610+
611+
if not path_exists:
612+
with pytest.raises(expected_exception=SystemExit, match="1"):
613+
compile_sketches.install_platforms_from_path(platform_list=platform_list)
614+
615+
assert capsys.readouterr().out.strip() == (
616+
"::error::Platform source path: "
617+
+ str(platform_list[0][compilesketches.CompileSketches.dependency_source_path_key])
618+
+ " doesn't exist"
619+
)
620+
621+
else:
622+
compile_sketches.install_platforms_from_path(platform_list=platform_list)
623+
624+
get_platform_installation_path_calls = []
625+
joinpath_calls = []
626+
mkdir_calls = []
627+
symlink_to_calls = []
628+
for platform in platform_list:
629+
get_platform_installation_path_calls.append(unittest.mock.call(compile_sketches, platform=platform))
630+
mkdir_calls.append(unittest.mock.call(platform_installation_path.base, parents=True, exist_ok=True))
631+
joinpath_calls.append(unittest.mock.call(platform_installation_path.base,
632+
platform_installation_path.platform))
633+
symlink_to_calls.append(
634+
unittest.mock.call(
635+
symlink_source_path,
636+
target=compilesketches.absolute_path(
637+
platform[compilesketches.CompileSketches.dependency_source_path_key]
638+
),
639+
target_is_directory=True
640+
)
641+
)
642+
643+
# noinspection PyUnresolvedReferences
644+
pathlib.Path.mkdir.assert_has_calls(calls=mkdir_calls)
645+
# noinspection PyUnresolvedReferences
646+
pathlib.Path.joinpath.assert_has_calls(calls=joinpath_calls)
647+
pathlib.Path.symlink_to.assert_has_calls(calls=symlink_to_calls)
648+
649+
650+
@pytest.mark.parametrize(
651+
"platform,"
652+
"command_data_stdout,"
653+
"expected_installation_path_base,"
654+
"expected_installation_path_platform,"
655+
"expected_rmtree",
656+
# No match to previously installed platforms
657+
[({compilesketches.CompileSketches.dependency_name_key: "foo:bar"},
658+
"[{\"ID\": \"asdf:zxcv\"}]",
659+
pathlib.PurePath("/foo/UserPlatformsPath"),
660+
pathlib.PurePath("foo/bar"),
661+
False),
662+
# Match with previously installed platform
663+
({compilesketches.CompileSketches.dependency_name_key: "foo:bar"},
664+
"[{\"ID\": \"foo:bar\", \"Installed\": \"1.2.3\"}]",
665+
pathlib.PurePath("/foo/BoardManagerPlatformsPath"),
666+
pathlib.PurePath("foo/hardware/bar/1.2.3"),
667+
True)]
668+
)
669+
def test_get_platform_installation_path(mocker,
670+
platform,
671+
command_data_stdout,
672+
expected_installation_path_base,
673+
expected_installation_path_platform,
674+
expected_rmtree):
675+
class CommandData:
676+
def __init__(self, stdout):
677+
self.stdout = stdout
678+
679+
command_data = CommandData(stdout=command_data_stdout)
680+
681+
mocker.patch("compilesketches.CompileSketches.run_arduino_cli_command", autospec=True, return_value=command_data)
682+
mocker.patch("shutil.rmtree", autospec=True)
683+
684+
compile_sketches = get_compilesketches_object()
685+
compile_sketches.user_platforms_path = pathlib.PurePath("/foo/UserPlatformsPath")
686+
compile_sketches.board_manager_platforms_path = pathlib.PurePath("/foo/BoardManagerPlatformsPath")
687+
688+
platform_installation_path = compile_sketches.get_platform_installation_path(platform=platform)
689+
assert platform_installation_path.base == expected_installation_path_base
690+
assert platform_installation_path.platform == expected_installation_path_platform
691+
692+
run_arduino_cli_command_calls = [unittest.mock.call(compile_sketches, command=["core", "update-index"]),
693+
unittest.mock.call(compile_sketches, command=["core", "list", "--format", "json"])]
694+
compilesketches.CompileSketches.run_arduino_cli_command.assert_has_calls(calls=run_arduino_cli_command_calls)
695+
696+
# noinspection PyUnresolvedReferences
697+
if expected_rmtree is True:
698+
# noinspection PyUnresolvedReferences
699+
shutil.rmtree.assert_called_once_with(
700+
path=platform_installation_path.base.joinpath(platform_installation_path.platform)
701+
)
702+
else:
703+
# noinspection PyUnresolvedReferences
704+
shutil.rmtree.assert_not_called()
705+
706+
576707
@pytest.mark.parametrize(
577708
"dependency, expected_ref",
578709
[({compilesketches.CompileSketches.dependency_version_key: "1.2.3"}, "1.2.3"),

0 commit comments

Comments
 (0)