diff --git a/pyproject.toml b/pyproject.toml index fa90f9b1..b3c0b4bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,37 +86,8 @@ git = "hermes.commands.process.git:process" git_add_contributors = "hermes.commands.process.git:add_contributors" git_add_branch = "hermes.commands.process.git:add_branch" -[tool.poetry.plugins."hermes.deposit.prepare"] -invenio = "hermes.commands.deposit.invenio:prepare" -file = "hermes.commands.deposit.file:dummy_noop" - -[tool.poetry.plugins."hermes.deposit.map"] -invenio = "hermes.commands.deposit.invenio:map_metadata" -file = "hermes.commands.deposit.file:map_metadata" - -[tool.poetry.plugins."hermes.deposit.create_initial_version"] -invenio = "hermes.commands.deposit.invenio:create_initial_version" -file = "hermes.commands.deposit.file:dummy_noop" - -[tool.poetry.plugins."hermes.deposit.create_new_version"] -invenio = "hermes.commands.deposit.invenio:create_new_version" -file = "hermes.commands.deposit.file:dummy_noop" - -[tool.poetry.plugins."hermes.deposit.update_metadata"] -invenio = "hermes.commands.deposit.invenio:update_metadata" -file = "hermes.commands.deposit.file:dummy_noop" - -[tool.poetry.plugins."hermes.deposit.delete_artifacts"] -invenio = "hermes.commands.deposit.invenio:delete_artifacts" -file = "hermes.commands.deposit.file:dummy_noop" - -[tool.poetry.plugins."hermes.deposit.upload_artifacts"] -invenio = "hermes.commands.deposit.invenio:upload_artifacts" -file = "hermes.commands.deposit.file:dummy_noop" - -[tool.poetry.plugins."hermes.deposit.publish"] -invenio = "hermes.commands.deposit.invenio:publish" -file = "hermes.commands.deposit.file:publish" +[tool.poetry.plugins."hermes.deposit"] +invenio = "hermes.commands.deposit.invenio:InvenioDepositPlugin" [tool.poetry.plugins."hermes.postprocess"] config_record_id = "hermes.commands.postprocess.invenio:config_record_id" diff --git a/src/hermes/commands/deposit/invenio.py b/src/hermes/commands/deposit/invenio.py index bbc4ab28..b38565fb 100644 --- a/src/hermes/commands/deposit/invenio.py +++ b/src/hermes/commands/deposit/invenio.py @@ -23,287 +23,311 @@ from hermes.model.path import ContextPath from hermes.utils import hermes_user_agent -_DEFAULT_LICENSES_API_PATH = "api/licenses" -_DEFAULT_COMMUNITIES_API_PATH = "api/communities" -_DEFAULT_DEPOSITIONS_API_PATH = "api/deposit/depositions" +# TODO: Move common functionality into base class +# TODO: Add type annotations to aid subclass implementation +class InvenioDepositPlugin: + default_licenses_api_path = "api/licenses" + default_communities_api_path = "api/communities" + default_depositions_api_path = "api/deposit/depositions" + + def __init__(self, click_ctx: click.Context, ctx: CodeMetaContext) -> None: + self.click_ctx = click_ctx + self.ctx = ctx + + def run(self): + # TODO: Decide here which of initial/new/... to run? + steps = [ + "prepare", + "map", + "create_initial_version", + "create_new_version", + "update_metadata", + "delete_artifacts", + "upload_artifacts", + "publish", + ] + + for step in steps: + getattr(self, step)() + + + def prepare(self): + """Prepare the deposition on an Invenio-based platform. + + In this function we do the following: + + - resolve the latest published version of this publication (if any) + - check whether the current version (given in the CodeMeta) was already published + - check whether we have a valid license identifier (if any) + - check wether the communities are valid (if configured) + - check access modalities (access right, access conditions, embargo data, existence + of license) + - check whether required configuration options are present + - check whether an auth token is given + - update ``ctx`` with metadata collected during the checks + """ + + if not self.click_ctx.params["auth_token"]: + raise DepositionUnauthorizedError("No auth token given for deposition platform") + + invenio_path = ContextPath.parse("deposit.invenio") + invenio_config = config.get("deposit").get("invenio", {}) + rec_id, rec_meta = _resolve_latest_invenio_id(self.ctx) + + version = self.ctx["codemeta"].get("version") + if rec_meta and (version == rec_meta.get("version")): + raise ValueError(f"Version {version} already deposited.") + + self.ctx.update(invenio_path['latestRecord'], {'id': rec_id, 'metadata': rec_meta}) + + site_url = invenio_config.get("site_url") + if site_url is None: + raise MisconfigurationError("deposit.invenio.site_url is not configured") + + licenses_api_path = invenio_config.get("api_paths", {}).get( + "licenses", self.default_licenses_api_path + ) + licenses_api_url = f"{site_url}/{licenses_api_path}" + license = _get_license_identifier(self.ctx, licenses_api_url) + self.ctx.update(invenio_path["license"], license) -def prepare(click_ctx: click.Context, ctx: CodeMetaContext): - """Prepare the deposition on an Invenio-based platform. + communities_api_path = invenio_config.get("api_paths", {}).get( + "communities", self.default_communities_api_path + ) + communities_api_url = f"{site_url}/{communities_api_path}" + communities = _get_community_identifiers(self.ctx, communities_api_url) + self.ctx.update(invenio_path["communities"], communities) - In this function we do the following: + access_right, embargo_date, access_conditions = _get_access_modalities(license) + self.ctx.update(invenio_path["access_right"], access_right) + self.ctx.update(invenio_path["embargo_date"], embargo_date) + self.ctx.update(invenio_path["access_conditions"], access_conditions) - - resolve the latest published version of this publication (if any) - - check whether the current version (given in the CodeMeta) was already published - - check whether we have a valid license identifier (if any) - - check wether the communities are valid (if configured) - - check access modalities (access right, access conditions, embargo data, existence - of license) - - check whether required configuration options are present - - check whether an auth token is given - - update ``ctx`` with metadata collected during the checks - """ - if not click_ctx.params["auth_token"]: - raise DepositionUnauthorizedError("No auth token given for deposition platform") + def map(self): + """Map the harvested metadata onto the Invenio schema.""" - invenio_path = ContextPath.parse("deposit.invenio") - invenio_config = config.get("deposit").get("invenio", {}) - rec_id, rec_meta = _resolve_latest_invenio_id(ctx) + deposition_metadata = _codemeta_to_invenio_deposition(self.ctx) - version = ctx["codemeta"].get("version") - if rec_meta and (version == rec_meta.get("version")): - raise ValueError(f"Version {version} already deposited.") + metadata_path = ContextPath.parse("deposit.invenio.depositionMetadata") + self.ctx.update(metadata_path, deposition_metadata) - ctx.update(invenio_path['latestRecord'], {'id': rec_id, 'metadata': rec_meta}) + # Store a snapshot of the mapped data within the cache, useful for analysis, debugging, etc + with open(self.ctx.get_cache("deposit", "invenio", create=True), 'w') as invenio_json: + json.dump(deposition_metadata, invenio_json, indent=' ') - site_url = invenio_config.get("site_url") - if site_url is None: - raise MisconfigurationError("deposit.invenio.site_url is not configured") - licenses_api_path = invenio_config.get("api_paths", {}).get( - "licenses", _DEFAULT_LICENSES_API_PATH - ) - licenses_api_url = f"{site_url}/{licenses_api_path}" - license = _get_license_identifier(ctx, licenses_api_url) - ctx.update(invenio_path["license"], license) + def create_initial_version(self): + """Create an initial version of a publication. - communities_api_path = invenio_config.get("api_paths", {}).get( - "communities", _DEFAULT_COMMUNITIES_API_PATH - ) - communities_api_url = f"{site_url}/{communities_api_path}" - communities = _get_community_identifiers(ctx, communities_api_url) - ctx.update(invenio_path["communities"], communities) + If a previous publication exists, this function does nothing, leaving the work for + :func:`create_new_version`. + """ - access_right, embargo_date, access_conditions = _get_access_modalities(license) - ctx.update(invenio_path["access_right"], access_right) - ctx.update(invenio_path["embargo_date"], embargo_date) - ctx.update(invenio_path["access_conditions"], access_conditions) + invenio_path = ContextPath.parse("deposit.invenio") + invenio_ctx = self.ctx[invenio_path] + latest_record_id = invenio_ctx.get("latestRecord", {}).get("id") + if latest_record_id is not None: + # A previous version exists. This means that we need to create a new version in + # the next step. Thus, there is nothing to do here. + return -def map_metadata(click_ctx: click.Context, ctx: CodeMetaContext): - """Map the harvested metadata onto the Invenio schema.""" + if not self.click_ctx.params['initial']: + raise RuntimeError("Please use `--initial` to make an initial deposition.") - deposition_metadata = _codemeta_to_invenio_deposition(ctx) + _log = logging.getLogger("cli.deposit.invenio") - metadata_path = ContextPath.parse("deposit.invenio.depositionMetadata") - ctx.update(metadata_path, deposition_metadata) + invenio_config = config.get("deposit").get("invenio", {}) + site_url = invenio_config["site_url"] + depositions_api_path = invenio_config.get("api_paths", {}).get( + "depositions", self.default_depositions_api_path + ) - # Store a snapshot of the mapped data within the cache, useful for analysis, debugging, etc - with open(ctx.get_cache("deposit", "invenio", create=True), 'w') as invenio_json: - json.dump(deposition_metadata, invenio_json, indent=' ') + deposition_metadata = invenio_ctx["depositionMetadata"] + deposit_url = f"{site_url}/{depositions_api_path}" + response = requests.post( + deposit_url, + json={"metadata": deposition_metadata}, + headers={ + "User-Agent": hermes_user_agent, + "Authorization": f"Bearer {self.click_ctx.params['auth_token']}", + } + ) -def create_initial_version(click_ctx: click.Context, ctx: CodeMetaContext): - """Create an initial version of a publication. + if not response.ok: + raise RuntimeError(f"Could not create initial deposit {deposit_url!r}") - If a previous publication exists, this function does nothing, leaving the work for - :func:`create_new_version`. - """ + deposit = response.json() + _log.debug("Created initial version deposit: %s", deposit["links"]["html"]) + with open(self.ctx.get_cache('deposit', 'deposit', create=True), 'w') as deposit_file: + json.dump(deposit, deposit_file, indent=4) - invenio_path = ContextPath.parse("deposit.invenio") - invenio_ctx = ctx[invenio_path] - latest_record_id = invenio_ctx.get("latestRecord", {}).get("id") + self.ctx.update(invenio_path["links"]["bucket"], deposit["links"]["bucket"]) + self.ctx.update(invenio_path["links"]["publish"], deposit["links"]["publish"]) - if latest_record_id is not None: - # A previous version exists. This means that we need to create a new version in - # the next step. Thus, there is nothing to do here. - return - if not click_ctx.params['initial']: - raise RuntimeError("Please use `--initial` to make an initial deposition.") + def create_new_version(self): + """Create a new version of an existing publication. - _log = logging.getLogger("cli.deposit.invenio") + If no previous publication exists, this function does nothing because + :func:`create_initial_version` will have done the work. + """ - invenio_config = config.get("deposit").get("invenio", {}) - site_url = invenio_config["site_url"] - depositions_api_path = invenio_config.get("api_paths", {}).get( - "depositions", _DEFAULT_DEPOSITIONS_API_PATH - ) + invenio_path = ContextPath.parse("deposit.invenio") + invenio_ctx = self.ctx[invenio_path] + latest_record_id = invenio_ctx.get("latestRecord", {}).get("id") - deposition_metadata = invenio_ctx["depositionMetadata"] + if latest_record_id is None: + # No previous version exists. This means that an initial version was created in + # the previous step. Thus, there is nothing to do here. + return - deposit_url = f"{site_url}/{depositions_api_path}" - response = requests.post( - deposit_url, - json={"metadata": deposition_metadata}, - headers={ + session = requests.Session() + session.headers = { "User-Agent": hermes_user_agent, - "Authorization": f"Bearer {click_ctx.params['auth_token']}", + "Authorization": f"Bearer {self.click_ctx.params['auth_token']}", } - ) - - if not response.ok: - raise RuntimeError(f"Could not create initial deposit {deposit_url!r}") - - deposit = response.json() - _log.debug("Created initial version deposit: %s", deposit["links"]["html"]) - with open(ctx.get_cache('deposit', 'deposit', create=True), 'w') as deposit_file: - json.dump(deposit, deposit_file, indent=4) - - ctx.update(invenio_path["links"]["bucket"], deposit["links"]["bucket"]) - ctx.update(invenio_path["links"]["publish"], deposit["links"]["publish"]) - - -def create_new_version(click_ctx: click.Context, ctx: CodeMetaContext): - """Create a new version of an existing publication. - - If no previous publication exists, this function does nothing because - :func:`create_initial_version` will have done the work. - """ - invenio_path = ContextPath.parse("deposit.invenio") - invenio_ctx = ctx[invenio_path] - latest_record_id = invenio_ctx.get("latestRecord", {}).get("id") + invenio_config = config.get("deposit").get("invenio", {}) + site_url = invenio_config["site_url"] + depositions_api_path = invenio_config.get("api_paths", {}).get( + "depositions", self.default_depositions_api_path + ) - if latest_record_id is None: - # No previous version exists. This means that an initial version was created in - # the previous step. Thus, there is nothing to do here. - return + # Get current deposit + deposit_url = f"{site_url}/{depositions_api_path}/{latest_record_id}" + response = session.get(deposit_url) + if not response.ok: + raise RuntimeError(f"Failed to get current deposit {deposit_url!r}") - session = requests.Session() - session.headers = { - "User-Agent": hermes_user_agent, - "Authorization": f"Bearer {click_ctx.params['auth_token']}", - } + # Create a new version using the newversion action + deposit_url = response.json()["links"]["newversion"] + response = session.post(deposit_url) + if not response.ok: + raise RuntimeError(f"Could not create new version deposit {deposit_url!r}") - invenio_config = config.get("deposit").get("invenio", {}) - site_url = invenio_config["site_url"] - depositions_api_path = invenio_config.get("api_paths", {}).get( - "depositions", _DEFAULT_DEPOSITIONS_API_PATH - ) + # Store link to latest draft to be used in :func:`update_metadata`. + old_deposit = response.json() + self.ctx.update(invenio_path["links"]["latestDraft"], old_deposit['links']['latest_draft']) - # Get current deposit - deposit_url = f"{site_url}/{depositions_api_path}/{latest_record_id}" - response = session.get(deposit_url) - if not response.ok: - raise RuntimeError(f"Failed to get current deposit {deposit_url!r}") - # Create a new version using the newversion action - deposit_url = response.json()["links"]["newversion"] - response = session.post(deposit_url) - if not response.ok: - raise RuntimeError(f"Could not create new version deposit {deposit_url!r}") + def update_metadata(self): + """Update the metadata of a draft. - # Store link to latest draft to be used in :func:`update_metadata`. - old_deposit = response.json() - ctx.update(invenio_path["links"]["latestDraft"], old_deposit['links']['latest_draft']) + If no draft is found in the context, it is assumed that no metadata has to be + updated (e.g. because an initial version was created already containing the + metadata). + """ + invenio_path = ContextPath.parse("deposit.invenio") + invenio_ctx = self.ctx[invenio_path] + draft_url = invenio_ctx.get("links", {}).get("latestDraft") -def update_metadata(click_ctx: click.Context, ctx: CodeMetaContext): - """Update the metadata of a draft. + if draft_url is None: + return - If no draft is found in the context, it is assumed that no metadata has to be - updated (e.g. because an initial version was created already containing the - metadata). - """ + _log = logging.getLogger("cli.deposit.invenio") - invenio_path = ContextPath.parse("deposit.invenio") - invenio_ctx = ctx[invenio_path] - draft_url = invenio_ctx.get("links", {}).get("latestDraft") + deposition_metadata = invenio_ctx["depositionMetadata"] - if draft_url is None: - return + response = requests.put( + draft_url, + json={"metadata": deposition_metadata}, + headers={ + "User-Agent": hermes_user_agent, + "Authorization": f"Bearer {self.click_ctx.params['auth_token']}", + } + ) - _log = logging.getLogger("cli.deposit.invenio") + if not response.ok: + raise RuntimeError(f"Could not update metadata of draft {draft_url!r}") - deposition_metadata = invenio_ctx["depositionMetadata"] + deposit = response.json() + _log.debug("Created new version deposit: %s", deposit["links"]["html"]) + with open(self.ctx.get_cache('deposit', 'deposit', create=True), 'w') as deposit_file: + json.dump(deposit, deposit_file, indent=4) - response = requests.put( - draft_url, - json={"metadata": deposition_metadata}, - headers={ - "User-Agent": hermes_user_agent, - "Authorization": f"Bearer {click_ctx.params['auth_token']}", - } - ) + self.ctx.update(invenio_path["links"]["bucket"], deposit["links"]["bucket"]) + self.ctx.update(invenio_path["links"]["publish"], deposit["links"]["publish"]) - if not response.ok: - raise RuntimeError(f"Could not update metadata of draft {draft_url!r}") - deposit = response.json() - _log.debug("Created new version deposit: %s", deposit["links"]["html"]) - with open(ctx.get_cache('deposit', 'deposit', create=True), 'w') as deposit_file: - json.dump(deposit, deposit_file, indent=4) + def delete_artifacts(self): + """Delete existing file artifacts. - ctx.update(invenio_path["links"]["bucket"], deposit["links"]["bucket"]) - ctx.update(invenio_path["links"]["publish"], deposit["links"]["publish"]) + This is done so that files which existed in an earlier publication but don't exist + any more, are removed. Otherwise they would cause an error because the didn't change + between versions. + """ + # TODO: This needs to be implemented! + pass -def delete_artifacts(click_ctx: click.Context, ctx: CodeMetaContext): - """Delete existing file artifacts. + def upload_artifacts(self): + """Upload file artifacts to the deposit. - This is done so that files which existed in an earlier publication but don't exist - any more, are removed. Otherwise they would cause an error because the didn't change - between versions. - """ - # TODO: This needs to be implemented! - pass + We'll use the bucket API rather than the files API as it supports file sizes above + 100MB. The URL to the bucket of the deposit is taken from the context at + ``deposit.invenio.links.bucket``. + """ + bucket_url_path = ContextPath.parse("deposit.invenio.links.bucket") + bucket_url = self.ctx[bucket_url_path] -def upload_artifacts(click_ctx: click.Context, ctx: CodeMetaContext): - """Upload file artifacts to the deposit. + session = requests.Session() + session.headers = { + "User-Agent": hermes_user_agent, + "Authorization": f"Bearer {self.click_ctx.params['auth_token']}", + } - We'll use the bucket API rather than the files API as it supports file sizes above - 100MB. The URL to the bucket of the deposit is taken from the context at - ``deposit.invenio.links.bucket``. - """ + files: list[click.Path] = self.click_ctx.params["file"] + for path_arg in files: + path = Path(path_arg) - bucket_url_path = ContextPath.parse("deposit.invenio.links.bucket") - bucket_url = ctx[bucket_url_path] + # This should not happen, as Click shall not accept dirs as arguments already. Zero trust anyway. + if not path.is_file(): + raise ValueError("Any given argument to be included in the deposit must be a file.") - session = requests.Session() - session.headers = { - "User-Agent": hermes_user_agent, - "Authorization": f"Bearer {click_ctx.params['auth_token']}", - } - - files: list[click.Path] = click_ctx.params["file"] - for path_arg in files: - path = Path(path_arg) - - # This should not happen, as Click shall not accept dirs as arguments already. Zero trust anyway. - if not path.is_file(): - raise ValueError("Any given argument to be included in the deposit must be a file.") - - with open(path, "rb") as file_content: - response = session.put( - f"{bucket_url}/{path.name}", - data=file_content - ) - if not response.ok: - raise RuntimeError(f"Could not upload file {path.name!r} into bucket {bucket_url!r}") + with open(path, "rb") as file_content: + response = session.put( + f"{bucket_url}/{path.name}", + data=file_content + ) + if not response.ok: + raise RuntimeError(f"Could not upload file {path.name!r} into bucket {bucket_url!r}") - # This can potentially be used to verify the checksum - # file_resource = response.json() + # This can potentially be used to verify the checksum + # file_resource = response.json() -def publish(click_ctx: click.Context, ctx: CodeMetaContext): - """Publish the deposited record. + def publish(self): + """Publish the deposited record. - This is done by doing a POST request to the publication URL stored in the context at - ``deposit.invenio.links.publish``. - """ + This is done by doing a POST request to the publication URL stored in the context at + ``deposit.invenio.links.publish``. + """ - _log = logging.getLogger("cli.deposit.invenio") + _log = logging.getLogger("cli.deposit.invenio") - publish_url_path = ContextPath.parse("deposit.invenio.links.publish") - publish_url = ctx[publish_url_path] + publish_url_path = ContextPath.parse("deposit.invenio.links.publish") + publish_url = self.ctx[publish_url_path] - response = requests.post( - publish_url, - headers={ - "User-Agent": hermes_user_agent, - "Authorization": f"Bearer {click_ctx.params['auth_token']}" - } - ) + response = requests.post( + publish_url, + headers={ + "User-Agent": hermes_user_agent, + "Authorization": f"Bearer {self.click_ctx.params['auth_token']}" + } + ) - if not response.ok: - _log.debug(response.text) - raise RuntimeError(f"Could not publish deposit via {publish_url!r}") + if not response.ok: + _log.debug(response.text) + raise RuntimeError(f"Could not publish deposit via {publish_url!r}") - record = response.json() - _log.info("Published record: %s", record["links"]["record_html"]) + record = response.json() + _log.info("Published record: %s", record["links"]["record_html"]) def _resolve_latest_invenio_id(ctx: CodeMetaContext) -> t.Tuple[str, dict]: diff --git a/src/hermes/commands/workflow.py b/src/hermes/commands/workflow.py index 39b15b39..13515fd2 100644 --- a/src/hermes/commands/workflow.py +++ b/src/hermes/commands/workflow.py @@ -189,65 +189,36 @@ def deposit(click_ctx: click.Context, initial, auth_token, file): deposit_config = config.get("deposit") - # This is used as the default value for all entry point names for the deposit step - target_platform = deposit_config.get("target", "invenio") - - entry_point_groups = [ - "hermes.deposit.prepare", - "hermes.deposit.map", - "hermes.deposit.create_initial_version", - "hermes.deposit.create_new_version", - "hermes.deposit.update_metadata", - "hermes.deposit.delete_artifacts", - "hermes.deposit.upload_artifacts", - "hermes.deposit.publish", - ] - - # For each group, an entry point can be configured via ``deposit_config`` using the - # the part after the last dot as the config key. If no such key is found, the target - # platform value is used to search for an entry point in the respective group. - selected_entry_points = { - group: deposit_config.get(group.split(".")[-1], target_platform) - for group in entry_point_groups - } - - # Try to load all entrypoints first, so we don't fail because of misconfigured - # entry points while some tasks of the deposition step were already started. (E.g. - # new version was already created on the deposition platform but artifact upload - # fails due to the entry point not being found.) - loaded_entry_points = [] - for group, name in selected_entry_points.items(): - try: - ep, *eps = metadata.entry_points(group=group, name=name) - except ValueError: # not enough values to unpack - if name != target_platform: - _log.error( - f"Explicitly configured entry point name {name!r} " - f"not found in group {group!r}" - ) - click_ctx.exit(1) - _log.debug( - f"Group {group!r} has no entry point with name {name!r}; skipping" - ) - continue - + # TODO: Rename group → ? + group = "hermes.deposit" + # TODO: Is having a default a good idea? + # TODO: Should we allow a list here so that multiple plugins are run? + plugin = deposit_config.get("target", "invenio") + + try: + ep, *eps = metadata.entry_points(group=group, name=plugin) if eps: # Entry point names in these groups refer to the deposition platforms. For # each platform, only a single implementation should exist. Otherwise we # would not be able to decide which implementation to choose. _log.error( - f"Entry point name {name!r} is not unique within group {group!r}" + f"Entry point name {plugin!r} is not unique within group {group!r}" ) click_ctx.exit(1) + except ValueError: # not enough values to unpack + _log.error(f"Entry point name {plugin!r} not found in group {group!r}") + click_ctx.exit(1) - loaded_entry_points.append(ep.load()) + # TODO: Could this raise an exception? + # TODO: Add "BaseDepositionPlugin" as type annotation + deposit_plugin_class = ep.load() + deposit_plugin = deposit_plugin_class(click_ctx, ctx) - for entry_point in loaded_entry_points: - try: - entry_point(click_ctx, ctx) - except (RuntimeError, MisconfigurationError) as e: - _log.error(f"Error in {group!r} entry point {name!r}: {e}") - click_ctx.exit(1) + try: + deposit_plugin.run() + except (RuntimeError, MisconfigurationError) as e: + _log.error(f"Error in {group!r} entry point {plugin!r}: {e}") + click_ctx.exit(1) @click.group(invoke_without_command=True)