Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ToolCache typing #964

Merged
merged 3 commits into from
Nov 14, 2022
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
1 change: 1 addition & 0 deletions changes/964.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``ToolCache`` and its tools are now typed to better support IDE hints and auto-completion.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ precision = 1
exclude_lines = [
"pragma: no cover",
"@(abc\\.)?abstractmethod",
"NotImplementedError\\(\\)"
"NotImplementedError\\(\\)",
"if TYPE_CHECKING:",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this after discovering Ned said it should be part of the default exclude.

I'll revert back to # pragma: no cover if you're not a fan.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd actually prefer it here; manual #pragma calls are sometimes inevitable, but are a code smell IMHO. My comment was more about having a fairly big block of code that isn't ever executed that only exists to keep an IDE happy, and the resulting problems ensuring that code is correct.

]

[tool.isort]
Expand Down
6 changes: 6 additions & 0 deletions src/briefcase/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
NetworkFailure,
)
from briefcase.integrations.base import ToolCache
from briefcase.integrations.download import Download
from briefcase.integrations.subprocess import Subprocess


class InvalidTemplateRepository(BriefcaseCommandError):
Expand Down Expand Up @@ -146,6 +148,10 @@ def __init__(
base_path=self.data_path / "tools",
)

# Immediately add tools that must be always available
Subprocess.verify(tools=self.tools)
Download.verify(tools=self.tools)

self.global_config = None
self._path_index = {}

Expand Down
9 changes: 5 additions & 4 deletions src/briefcase/integrations/android_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
InvalidDeviceError,
MissingToolError,
)
from briefcase.integrations.base import Tool, ToolCache
from briefcase.integrations.java import JDK

DEVICE_NOT_FOUND = re.compile(r"^error: device '[^']*' not found")
Expand All @@ -36,11 +37,11 @@ def __init__(self, device):
)


class AndroidSDK:
class AndroidSDK(Tool):
name = "android_sdk"
full_name = "Android SDK"

def __init__(self, tools, root_path):
def __init__(self, tools: ToolCache, root_path: Path):
self.tools = tools
self.dot_android_path = self.tools.home_path / ".android"
self.root_path = root_path
Expand Down Expand Up @@ -129,7 +130,7 @@ def emulator_abi(self):
)

@classmethod
def verify(cls, tools, install=True):
def verify(cls, tools: ToolCache, install=True):
"""Verify an Android SDK is available.

If the ANDROID_SDK_ROOT environment variable is set, that location will
Expand Down Expand Up @@ -1100,7 +1101,7 @@ def start_emulator(self, avd):


class ADB:
def __init__(self, tools, device):
def __init__(self, tools: ToolCache, device: str):
"""An API integration for the Android Debug Bridge (ADB).

:param tools: ToolCache of available tools
Expand Down
42 changes: 35 additions & 7 deletions src/briefcase/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,29 @@
from collections import defaultdict
from collections.abc import Mapping
from pathlib import Path
from typing import DefaultDict
from typing import TYPE_CHECKING, DefaultDict

import requests
from cookiecutter.main import cookiecutter

from briefcase.config import AppConfig
from briefcase.console import Console, Log
from briefcase.integrations.download import Download
from briefcase.integrations.subprocess import Subprocess

if TYPE_CHECKING:
# Tools are imported only for type checking
# to avoid circular import errors.
import git as git_
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was almost ready to approve this PR... and then I remembered something.

That opens up a bunch of questions:

  1. On what condition will TYPE_CHECKING be true?
  2. Is it possible for the user to have TYPE_CHECKING be true and not have a working git install?
  3. Is there any meaningful fallback/error recovery to use in this case?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was almost ready to approve this PR... and then I remembered something.

That opens up a bunch of questions:

  1. On what condition will TYPE_CHECKING be true?

TYPE_CHECKING probably shouldn't ever be True; even for type checkers like mypy, they should only assume TYPE_CHECKING is the same as True.

  1. Is it possible for the user to have TYPE_CHECKING be true and not have a working git install?

It's probably possible if something monkeypatched typing.TYPE_CHECKING at interpreter startup. But that definitely isn't the intended way to "assume" TYPE_CHECKING is True when relevant. I really hope the stronger promise of the design of TYPE_CHECKING is that it is always False at runtime.

  1. Is there any meaningful fallback/error recovery to use in this case?

Not currently. But if we wanted to ensure this was avoided, we could change if TYPE_CHECKING: to simply if False:. Although, for instance, pytest explicitly moved from if False: once Python 3.5 could use typing.TYPE_CHECKING.


from briefcase.integrations.android_sdk import AndroidSDK
from briefcase.integrations.docker import Docker, DockerAppContext
from briefcase.integrations.download import Download
from briefcase.integrations.flatpak import Flatpak
from briefcase.integrations.java import JDK
from briefcase.integrations.linuxdeploy import LinuxDeploy
from briefcase.integrations.rcedit import RCEdit
from briefcase.integrations.subprocess import Subprocess
from briefcase.integrations.visualstudio import VisualStudio
from briefcase.integrations.wix import WiX


# TODO: Implement Tool base class
Expand All @@ -25,11 +39,29 @@ class Tool(ABC):


class ToolCache(Mapping):
# Briefcase tools
android_sdk: AndroidSDK
app_context: Subprocess | DockerAppContext
docker: Docker
download: Download
flatpak: Flatpak
git: git_
java: JDK
linuxdeploy: LinuxDeploy
rcedit: RCEdit
subprocess: Subprocess
visualstudio: VisualStudio
wix: WiX
xcode: bool
xcode_cli: bool

# Python stdlib tools
platform = platform
os = os
shutil = shutil
sys = sys

# Third party tools
cookiecutter = staticmethod(cookiecutter)
requests = requests

Expand Down Expand Up @@ -71,10 +103,6 @@ def __init__(
)
)

# Built-in tools without any external dependencies
Subprocess.verify(tools=self)
Download.verify(tools=self)

def __getitem__(self, app: AppConfig) -> ToolCache:
return self.app_tools[app]

Expand Down
19 changes: 12 additions & 7 deletions src/briefcase/integrations/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
import sys
from pathlib import Path

from briefcase.config import AppConfig
from briefcase.exceptions import BriefcaseCommandError
from briefcase.integrations.base import Tool, ToolCache


class Docker:
class Docker(Tool):
name = "docker"
full_name = "Docker"

WRONG_DOCKER_VERSION_ERROR = """\
Briefcase requires Docker 19 or higher, but you are currently running
version {docker_version}. Visit:
Expand Down Expand Up @@ -107,11 +112,11 @@ class Docker:
},
}

def __init__(self, tools):
def __init__(self, tools: ToolCache):
self.tools = tools

@classmethod
def verify(cls, tools):
def verify(cls, tools: ToolCache):
"""Verify Docker is installed and operational."""
# short circuit since already verified and available
if hasattr(tools, "docker"):
Expand Down Expand Up @@ -179,8 +184,8 @@ def verify(cls, tools):
return tools.docker


class DockerAppContext:
def __init__(self, tools, app):
class DockerAppContext(Tool):
def __init__(self, tools: ToolCache, app: AppConfig):
self.tools = tools
self.app = app

Expand All @@ -198,8 +203,8 @@ def docker_data_path(self):
@classmethod
def verify(
cls,
tools,
app,
tools: ToolCache,
app: AppConfig,
image_tag: str,
dockerfile_path: Path,
app_base_path: Path,
Expand Down
16 changes: 10 additions & 6 deletions src/briefcase/integrations/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@
MissingNetworkResourceError,
NetworkFailure,
)
from briefcase.integrations.base import Tool, ToolCache


class Download:
def __init__(self, tools):
class Download(Tool):
name = "download"
full_name = "Download"

def __init__(self, tools: ToolCache):
self.tools = tools

@classmethod
def verify(cls, tools):
def verify(cls, tools: ToolCache):
"""Make downloader available in tool cache."""
# short circuit since already verified and available
if hasattr(tools, "download"):
Expand All @@ -31,9 +35,9 @@ def file(self, url, download_path, role=None):
"""Download a given URL, caching it. If it has already been downloaded,
return the value that has been cached.

This is a utility method used to obtain assets used by the
install process. The cached filename will be the filename portion of
the URL, appended to the download path.
This is a utility method used to obtain assets used by the installation
process. The cached filename will be the filename portion of the URL,
appended to the download path.

:param url: The URL to download
:param download_path: The path to the download cache folder. This path
Expand Down
10 changes: 7 additions & 3 deletions src/briefcase/integrations/flatpak.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import subprocess

from briefcase.exceptions import BriefcaseCommandError
from briefcase.integrations.base import Tool, ToolCache


class Flatpak:
class Flatpak(Tool):
name = "flatpak"
full_name = "Flatpak"

DEFAULT_REPO_ALIAS = "flathub"
DEFAULT_REPO_URL = "https://flathub.org/repo/flathub.flatpakrepo"

DEFAULT_RUNTIME = "org.freedesktop.Platform"
DEFAULT_RUNTIME_VERSION = "21.08"
DEFAULT_SDK = "org.freedesktop.Sdk"

def __init__(self, tools):
def __init__(self, tools: ToolCache):
self.tools = tools

@classmethod
def verify(cls, tools):
def verify(cls, tools: ToolCache):
"""Verify that the Flatpak toolchain is available.

:param tools: ToolCache of available tools
Expand Down
3 changes: 2 additions & 1 deletion src/briefcase/integrations/git.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from briefcase.exceptions import BriefcaseCommandError
from briefcase.integrations.base import ToolCache


def verify_git_is_installed(tools):
def verify_git_is_installed(tools: ToolCache):
"""Verify if git is installed.

Unfortunately, `import git` triggers a call on the operating system
Expand Down
7 changes: 4 additions & 3 deletions src/briefcase/integrations/java.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
MissingToolError,
NonManagedToolError,
)
from briefcase.integrations.base import Tool, ToolCache


class JDK:
class JDK(Tool):
name = "java"
full_name = "Java JDK"

def __init__(self, tools, java_home):
def __init__(self, tools: ToolCache, java_home: Path):
self.tools = tools

# As of April 10 2020, 8u242-b08 is the current AdoptOpenJDK
Expand Down Expand Up @@ -43,7 +44,7 @@ def adoptOpenJDK_download_url(self):
)

@classmethod
def verify(cls, tools, install=True):
def verify(cls, tools: ToolCache, install=True):
"""Verify that a Java 8 JDK exists.

If ``JAVA_HOME`` is set, try that version. If it is a JRE, or its *not*
Expand Down
11 changes: 8 additions & 3 deletions src/briefcase/integrations/linuxdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CorruptToolError,
MissingToolError,
)
from briefcase.integrations.base import Tool, ToolCache

ELF_HEADER_IDENT = bytes.fromhex("7F454C46")
ELF_PATCH_OFFSET = 0x08
Expand All @@ -17,7 +18,11 @@


class LinuxDeployBase:
def __init__(self, tools, **kwargs):
name: str
full_name: str
install_msg: str

def __init__(self, tools: ToolCache, **kwargs):
self.tools = tools

@property
Expand Down Expand Up @@ -62,7 +67,7 @@ def prepare_executable(self):
self.patch_elf_header()

@classmethod
def verify(cls, tools, install=True, **kwargs):
def verify(cls, tools: ToolCache, install=True, **kwargs):
"""Verify that linuxdeploy tool or plugin is available.

:param tools: ToolCache of available tools
Expand Down Expand Up @@ -290,7 +295,7 @@ def download_url(self):
return self._download_url


class LinuxDeploy(LinuxDeployBase):
class LinuxDeploy(LinuxDeployBase, Tool):
name = "linuxdeploy"
full_name = "linuxdeploy"
install_msg = "linuxdeploy was not found; downloading and installing..."
Expand Down
7 changes: 4 additions & 3 deletions src/briefcase/integrations/rcedit.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from briefcase.exceptions import MissingToolError
from briefcase.integrations.base import Tool, ToolCache


class RCEdit:
class RCEdit(Tool):
name = "rcedit"
full_name = "RCEdit"

def __init__(self, tools):
def __init__(self, tools: ToolCache):
self.tools = tools

@property
Expand All @@ -19,7 +20,7 @@ def rcedit_path(self):
return self.tools.base_path / "rcedit-x64.exe"

@classmethod
def verify(cls, tools, install=True):
def verify(cls, tools: ToolCache, install=True):
"""Verify that rcedit is available.

:param tools: ToolCache of available tools
Expand Down
Loading