CAM: Handle import of unknown shape types gracefully; allow case insensitive filenames, and allow invalid tool ID data type
This commit is contained in:
@@ -314,7 +314,7 @@ class TestPathToolAssetManager(unittest.TestCase):
|
||||
with self.assertRaises(FileNotFoundError) as cm:
|
||||
manager.get_raw(non_existent_uri, store="non_existent_store")
|
||||
self.assertIn(
|
||||
"Asset 'type://id/1' not found in stores '['non_existent_store']'.", str(cm.exception)
|
||||
"Asset 'type://id/1' not found in stores '['non_existent_store']'", str(cm.exception)
|
||||
)
|
||||
|
||||
def test_is_empty(self):
|
||||
|
||||
@@ -171,6 +171,7 @@ SET(PathPythonToolsToolBitModels_SRCS
|
||||
Path/Tool/toolbit/models/base.py
|
||||
Path/Tool/toolbit/models/bullnose.py
|
||||
Path/Tool/toolbit/models/chamfer.py
|
||||
Path/Tool/toolbit/models/custom.py
|
||||
Path/Tool/toolbit/models/dovetail.py
|
||||
Path/Tool/toolbit/models/drill.py
|
||||
Path/Tool/toolbit/models/endmill.py
|
||||
@@ -248,6 +249,7 @@ SET(PathPythonToolsShapeModels_SRCS
|
||||
Path/Tool/shape/models/base.py
|
||||
Path/Tool/shape/models/bullnose.py
|
||||
Path/Tool/shape/models/chamfer.py
|
||||
Path/Tool/shape/models/custom.py
|
||||
Path/Tool/shape/models/dovetail.py
|
||||
Path/Tool/shape/models/drill.py
|
||||
Path/Tool/shape/models/endmill.py
|
||||
|
||||
@@ -346,7 +346,7 @@ class AssetManager:
|
||||
|
||||
if all_construction_data is None:
|
||||
# This means the top-level asset itself was not found by _fetch_...
|
||||
raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'.")
|
||||
raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'")
|
||||
|
||||
# Step 2: Synchronously build the asset tree (and call from_bytes)
|
||||
# This happens in the current thread (which is assumed to be the main UI thread)
|
||||
@@ -415,7 +415,7 @@ class AssetManager:
|
||||
# Consistent with get(), if the top-level asset is not found,
|
||||
# raise FileNotFoundError.
|
||||
raise FileNotFoundError(
|
||||
f"Asset '{asset_uri_obj}' not found in stores '{stores_list}' (async path)."
|
||||
f"Asset '{asset_uri_obj}' not found in stores '{stores_list}' (async path)"
|
||||
)
|
||||
# return None # Alternative: if Optional[Asset] means asset might not exist
|
||||
|
||||
@@ -459,7 +459,7 @@ class AssetManager:
|
||||
f"GetRawAsync: Asset {asset_uri_obj} not found in store {current_store_name}"
|
||||
)
|
||||
continue
|
||||
raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'.")
|
||||
raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'")
|
||||
|
||||
try:
|
||||
return asyncio.run(_fetch_raw_async(stores_list))
|
||||
@@ -499,7 +499,7 @@ class AssetManager:
|
||||
)
|
||||
continue
|
||||
|
||||
raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'.")
|
||||
raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'")
|
||||
|
||||
def get_bulk(
|
||||
self,
|
||||
|
||||
@@ -26,6 +26,15 @@ from ..uri import AssetUri
|
||||
from .base import AssetStore
|
||||
|
||||
|
||||
def _resolve_case_insensitive(path: pathlib.Path) -> pathlib.Path:
|
||||
if path.is_file():
|
||||
return path
|
||||
try:
|
||||
return next(path.parent.glob(path.name, case_sensitive=False))
|
||||
except StopIteration:
|
||||
return path
|
||||
|
||||
|
||||
class FileStore(AssetStore):
|
||||
"""
|
||||
Asset store implementation for the local filesystem with optional
|
||||
@@ -114,7 +123,7 @@ class FileStore(AssetStore):
|
||||
regex_parts.append(f"(?P<{keywords[i]}>.*)")
|
||||
|
||||
pattern_regex_str = "".join(regex_parts)
|
||||
match_obj = re.fullmatch(pattern_regex_str, path_str_posix)
|
||||
match_obj = re.fullmatch(pattern_regex_str, path_str_posix, re.I)
|
||||
|
||||
if not match_obj:
|
||||
raise ValueError(
|
||||
@@ -258,6 +267,7 @@ class FileStore(AssetStore):
|
||||
)
|
||||
path_to_read = self._uri_to_path(request_uri)
|
||||
|
||||
path_to_read = _resolve_case_insensitive(path_to_read)
|
||||
try:
|
||||
with open(path_to_read, mode="rb") as f:
|
||||
return f.read()
|
||||
@@ -307,6 +317,7 @@ class FileStore(AssetStore):
|
||||
)
|
||||
|
||||
path = self._uri_to_path(target_uri_for_path)
|
||||
path = _resolve_case_insensitive(path)
|
||||
if path.is_file():
|
||||
paths_to_delete.append(path)
|
||||
|
||||
@@ -380,6 +391,7 @@ class FileStore(AssetStore):
|
||||
params=uri.params,
|
||||
)
|
||||
asset_path = self._uri_to_path(next_uri)
|
||||
asset_path = _resolve_case_insensitive(asset_path)
|
||||
|
||||
# If the file is versioned, then the new version should not yet exist.
|
||||
# Double check to be sure.
|
||||
@@ -472,6 +484,7 @@ class FileStore(AssetStore):
|
||||
params=uri.params,
|
||||
)
|
||||
path_to_asset = self._uri_to_path(path_check_uri)
|
||||
path_to_asset = _resolve_case_insensitive(path_to_asset)
|
||||
if path_to_asset.is_file():
|
||||
return [path_check_uri] # Returns URI with version "1" and original params
|
||||
return []
|
||||
|
||||
@@ -61,9 +61,13 @@ def ensure_toolbits_have_shape_type(asset_manager: AssetManager, store_name: str
|
||||
Path.Log.error(f"ToolBit {uri} missing shape ID")
|
||||
continue
|
||||
|
||||
shape_class = ToolBitShape.get_shape_class_from_id(shape_id)
|
||||
try:
|
||||
shape_class = ToolBitShape.get_shape_class_from_id(shape_id)
|
||||
except Exception as e:
|
||||
Path.Log.error(f"Failed to load toolbit {uri}: {e}. Skipping")
|
||||
continue
|
||||
if not shape_class:
|
||||
Path.Log.warning(f"Toolbit {uri} has no shape-type attribute, and failed to infer it")
|
||||
Path.Log.error(f"Toolbit {uri} has no shape-type attribute, and failed to infer it")
|
||||
continue
|
||||
attrs["shape-type"] = shape_class.name
|
||||
Path.Log.info(f"Migrating toolbit {uri}: Adding shape-type attribute '{shape_class.name}'")
|
||||
|
||||
@@ -31,7 +31,6 @@ import PathScripts.PathUtilsGui as PathUtilsGui
|
||||
from PySide import QtGui, QtCore
|
||||
from functools import partial
|
||||
from typing import List, Tuple
|
||||
from ...assets import AssetUri
|
||||
from ...camassets import cam_assets, ensure_assets_initialized
|
||||
from ...toolbit import ToolBit
|
||||
from .editor import LibraryEditor
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
# Import the base class and all concrete shape classes
|
||||
from .models.base import ToolBitShape
|
||||
from .models.ballend import ToolBitShapeBallend
|
||||
from .models.bullnose import ToolBitShapeBullnose
|
||||
from .models.chamfer import ToolBitShapeChamfer
|
||||
from .models.custom import ToolBitShapeCustom
|
||||
from .models.dovetail import ToolBitShapeDovetail
|
||||
from .models.drill import ToolBitShapeDrill
|
||||
from .models.endmill import ToolBitShapeEndmill
|
||||
@@ -14,7 +16,6 @@ from .models.reamer import ToolBitShapeReamer
|
||||
from .models.slittingsaw import ToolBitShapeSlittingSaw
|
||||
from .models.tap import ToolBitShapeTap
|
||||
from .models.threadmill import ToolBitShapeThreadMill
|
||||
from .models.bullnose import ToolBitShapeBullnose
|
||||
from .models.vbit import ToolBitShapeVBit
|
||||
from .models.icon import (
|
||||
ToolBitShapeIcon,
|
||||
@@ -29,7 +30,9 @@ TOOL_BIT_SHAPE_NAMES = sorted([cls.name for cls in ToolBitShape.__subclasses__()
|
||||
__all__ = [
|
||||
"ToolBitShape",
|
||||
"ToolBitShapeBallend",
|
||||
"ToolBitShapeBullnose",
|
||||
"ToolBitShapeChamfer",
|
||||
"ToolBitShapeCustom",
|
||||
"ToolBitShapeDovetail",
|
||||
"ToolBitShapeDrill",
|
||||
"ToolBitShapeEndmill",
|
||||
@@ -39,7 +42,6 @@ __all__ = [
|
||||
"ToolBitShapeSlittingSaw",
|
||||
"ToolBitShapeTap",
|
||||
"ToolBitShapeThreadMill",
|
||||
"ToolBitShapeBullnose",
|
||||
"ToolBitShapeVBit",
|
||||
"TOOL_BIT_SHAPE_NAMES",
|
||||
"ToolBitShapeIcon",
|
||||
|
||||
@@ -123,9 +123,7 @@ class ToolBitShape(Asset):
|
||||
shape_classes = {c.name: c for c in ToolBitShape.__subclasses__()}
|
||||
shape_class = shape_classes.get(body_obj.Label)
|
||||
if not shape_class:
|
||||
raise ValueError(
|
||||
f"No ToolBitShape subclass found matching Body label '{body_obj.Label}' in {doc}"
|
||||
)
|
||||
return ToolBitShape.get_subclass_by_id("Custom")
|
||||
return shape_class
|
||||
|
||||
@classmethod
|
||||
@@ -133,18 +131,18 @@ class ToolBitShape(Asset):
|
||||
cls,
|
||||
shape_id: str,
|
||||
shape_type: str | None = None,
|
||||
default: Type["ToolBitShape"] = None,
|
||||
) -> Type["ToolBitShape"]:
|
||||
default: Type["ToolBitShape"] | None = None,
|
||||
) -> Optional[Type["ToolBitShape"]]:
|
||||
"""
|
||||
Extracts the shape class from the given ID and shape_type, retrieving it
|
||||
from the asset manager if necessary.
|
||||
"""
|
||||
# Best method: if the shape-type is specified, use that.
|
||||
if shape_type:
|
||||
return cls.get_subclass_by_name(shape_type)
|
||||
return ToolBitShape.get_subclass_by_name(shape_type)
|
||||
|
||||
# If no shape type is specified, try to find the shape class from the ID.
|
||||
shape_class = cls.get_subclass_by_name(shape_id)
|
||||
shape_class = ToolBitShape.get_subclass_by_name(shape_id)
|
||||
if shape_class:
|
||||
return shape_class
|
||||
|
||||
@@ -155,8 +153,11 @@ class ToolBitShape(Asset):
|
||||
" negatively impact performance."
|
||||
)
|
||||
shape_asset_uri = ToolBitShape.resolve_name(shape_id)
|
||||
data = cam_assets.get_raw(shape_asset_uri)
|
||||
if data:
|
||||
try:
|
||||
data = cam_assets.get_raw(shape_asset_uri)
|
||||
except FileNotFoundError:
|
||||
pass # rely on fallback below
|
||||
else:
|
||||
try:
|
||||
shape_class = ToolBitShape.get_shape_class_from_bytes(data)
|
||||
except ValueError:
|
||||
@@ -167,28 +168,14 @@ class ToolBitShape(Asset):
|
||||
# Otherwise use the default, if we have one.
|
||||
shape_types = [c.name for c in ToolBitShape.__subclasses__()]
|
||||
if default is not None:
|
||||
Path.Log.warning(
|
||||
Path.Log.debug(
|
||||
f'Failed to infer shape type from {shape_id}, using "{default.name}".'
|
||||
f" To fix, name the body in the shape file to one of: {shape_types}"
|
||||
)
|
||||
return default
|
||||
|
||||
# If all else fails, try to guess the shape class from the ID.
|
||||
shape_class = ToolBitShape.guess_subclass_from_name(shape_id)
|
||||
if shape_class:
|
||||
Path.Log.warning(
|
||||
f'Failed to infer shape type from "{shape_id}",'
|
||||
f' guessing "{shape_class.name}".'
|
||||
f" To fix, name the body in the shape file to one of: {shape_types}"
|
||||
)
|
||||
return shape_class
|
||||
|
||||
# Default to endmill if nothing else works
|
||||
Path.Log.warning(
|
||||
f"Failed to infer shape type from {shape_id}."
|
||||
f" To fix, name the body in the shape file to one of: {shape_types}"
|
||||
)
|
||||
return None
|
||||
# Default to Custom if nothing else works
|
||||
return ToolBitShape.get_subclass_by_name("Custom")
|
||||
|
||||
@classmethod
|
||||
def get_shape_class_from_bytes(cls, data: bytes) -> Type["ToolBitShape"]:
|
||||
@@ -231,11 +218,7 @@ class ToolBitShape(Asset):
|
||||
|
||||
# Find the correct subclass based on the body label
|
||||
shape_class = cls.get_subclass_by_name(body_label)
|
||||
if not shape_class:
|
||||
raise ValueError(
|
||||
f"No ToolBitShape subclass found matching Body label '{body_label}'"
|
||||
)
|
||||
return shape_class
|
||||
return shape_class or ToolBitShape.get_subclass_by_id("Custom")
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
raise ValueError("Invalid FCStd file data (not a valid zip archive)")
|
||||
@@ -351,23 +334,7 @@ class ToolBitShape(Asset):
|
||||
shape_class = ToolBitShape.get_shape_class_from_bytes(data)
|
||||
except Exception as e:
|
||||
Path.Log.debug(f"{id}: Failed to determine shape class from bytes: {e}")
|
||||
shape_types = [c.name for c in ToolBitShape.__subclasses__()]
|
||||
shape_class = ToolBitShape.guess_subclass_from_name(id)
|
||||
if shape_class:
|
||||
Path.Log.warning(
|
||||
f"{id}: failed to infer shape type from bytes,"
|
||||
f' guessing "{shape_class.name}". To fix, name'
|
||||
f" the body in the shape file to one of: {shape_types}"
|
||||
)
|
||||
else:
|
||||
Path.Log.warning(
|
||||
f"{id}: failed to infer shape type from bytes,"
|
||||
f' using "endmill". To fix, name'
|
||||
f" the body in the shape file to one of: {shape_types}"
|
||||
)
|
||||
from .endmill import ToolBitShapeEndmill
|
||||
|
||||
shape_class = ToolBitShapeEndmill
|
||||
shape_class = ToolBitShape.get_shape_class_from_id("Custom")
|
||||
|
||||
# Load properties from the temporary document
|
||||
props_obj = ToolBitShape._find_property_object(temp_doc)
|
||||
@@ -537,7 +504,7 @@ class ToolBitShape(Asset):
|
||||
# 2. If the input is a filename (with extension), assume the asset
|
||||
# name is the base name.
|
||||
asset_name = identifier
|
||||
if identifier.endswith(".fcstd"):
|
||||
if pathlib.Path(identifier).suffix.lower() == ".fcstd":
|
||||
asset_name = os.path.splitext(os.path.basename(identifier))[0]
|
||||
|
||||
# 3. Use get_subclass_by_name to try to resolve alias to a class.
|
||||
|
||||
43
src/Mod/CAM/Path/Tool/shape/models/custom.py
Normal file
43
src/Mod/CAM/Path/Tool/shape/models/custom.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
|
||||
# * *
|
||||
# * This program is free software; you can redistribute it and/or modify *
|
||||
# * it under the terms of the GNU Lesser General Public License (LGPL) *
|
||||
# * as published by the Free Software Foundation; either version 2 of *
|
||||
# * the License, or (at your option) any later version. *
|
||||
# * for detail see the LICENCE text file. *
|
||||
# * *
|
||||
# * This program is distributed in the hope that it will be useful, *
|
||||
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
# * GNU Library General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Library General Public *
|
||||
# * License along with this program; if not, write to the Free Software *
|
||||
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
import FreeCAD
|
||||
from typing import Tuple, Mapping
|
||||
from .base import ToolBitShape
|
||||
|
||||
|
||||
class ToolBitShapeCustom(ToolBitShape):
|
||||
"""
|
||||
Schema-less tool, used as a fallback when loading legacy tools
|
||||
for backward compatibility
|
||||
"""
|
||||
|
||||
name: str = "Custom"
|
||||
aliases = ("custom",)
|
||||
|
||||
@classmethod
|
||||
def schema(cls) -> Mapping[str, Tuple[str, str]]:
|
||||
return {}
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return FreeCAD.Qt.translate("ToolBitShape", "Unknown custom shape")
|
||||
@@ -4,7 +4,9 @@
|
||||
# Import the base class and all concrete shape classes
|
||||
from .models.base import ToolBit
|
||||
from .models.ballend import ToolBitBallend
|
||||
from .models.bullnose import ToolBitBullnose
|
||||
from .models.chamfer import ToolBitChamfer
|
||||
from .models.custom import ToolBitCustom
|
||||
from .models.dovetail import ToolBitDovetail
|
||||
from .models.drill import ToolBitDrill
|
||||
from .models.endmill import ToolBitEndmill
|
||||
@@ -14,14 +16,15 @@ from .models.reamer import ToolBitReamer
|
||||
from .models.slittingsaw import ToolBitSlittingSaw
|
||||
from .models.tap import ToolBitTap
|
||||
from .models.threadmill import ToolBitThreadMill
|
||||
from .models.bullnose import ToolBitBullnose
|
||||
from .models.vbit import ToolBitVBit
|
||||
|
||||
# Define __all__ for explicit public interface
|
||||
__all__ = [
|
||||
"ToolBit",
|
||||
"ToolBitBallend",
|
||||
"ToolBitBullnose",
|
||||
"ToolBitChamfer",
|
||||
"ToolBitCustom",
|
||||
"ToolBitDovetail",
|
||||
"ToolBitDrill",
|
||||
"ToolBitEndmill",
|
||||
@@ -31,6 +34,5 @@ __all__ = [
|
||||
"ToolBitSlittingSaw",
|
||||
"ToolBitTap",
|
||||
"ToolBitThreadMill",
|
||||
"ToolBitBullnose",
|
||||
"ToolBitVBit",
|
||||
]
|
||||
|
||||
@@ -35,7 +35,7 @@ from PySide.QtCore import QT_TRANSLATE_NOOP
|
||||
from Path.Base.Generator import toolchange
|
||||
from ...assets import Asset
|
||||
from ...camassets import cam_assets
|
||||
from ...shape import ToolBitShape, ToolBitShapeEndmill, ToolBitShapeIcon
|
||||
from ...shape import ToolBitShape, ToolBitShapeCustom, ToolBitShapeIcon
|
||||
from ..docobject import DetachedDocumentObject
|
||||
from ..util import to_json, format_value
|
||||
|
||||
@@ -101,7 +101,15 @@ class ToolBit(Asset, ABC):
|
||||
if not shape_id:
|
||||
raise ValueError("ToolBit dictionary is missing 'shape' key")
|
||||
|
||||
shape_class = ToolBitShape.get_shape_class_from_id(shape_id, attrs.get("shape-type"))
|
||||
# Try to find the shape type. Default to Unknown if necessary.
|
||||
shape_type = attrs.get("shape-type")
|
||||
shape_class = ToolBitShape.get_shape_class_from_id(shape_id, shape_type)
|
||||
if not shape_class:
|
||||
Path.Log.debug(
|
||||
f"Failed to find usable shape for ID '{shape_id}'"
|
||||
f" (shape type {shape_type}). Falling back to 'Unknown'"
|
||||
)
|
||||
shape_class = ToolBitShapeCustom
|
||||
|
||||
# Create a ToolBitShape instance.
|
||||
if not shallow: # Shallow means: skip loading of child assets
|
||||
@@ -230,7 +238,7 @@ class ToolBit(Asset, ABC):
|
||||
)
|
||||
names = [c.name for c in ToolBitShape.__subclasses__()]
|
||||
self.obj.ShapeType = names
|
||||
self.obj.ShapeType = ToolBitShapeEndmill.name
|
||||
self.obj.ShapeType = ToolBitShapeCustom.name
|
||||
if not hasattr(self.obj, "BitBody"):
|
||||
self.obj.addProperty(
|
||||
"App::PropertyLink",
|
||||
|
||||
37
src/Mod/CAM/Path/Tool/toolbit/models/custom.py
Normal file
37
src/Mod/CAM/Path/Tool/toolbit/models/custom.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
|
||||
# * *
|
||||
# * This program is free software; you can redistribute it and/or modify *
|
||||
# * it under the terms of the GNU Lesser General Public License (LGPL) *
|
||||
# * as published by the Free Software Foundation; either version 2 of *
|
||||
# * the License, or (at your option) any later version. *
|
||||
# * for detail see the LICENCE text file. *
|
||||
# * *
|
||||
# * This program is distributed in the hope that it will be useful, *
|
||||
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
# * GNU Library General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Library General Public *
|
||||
# * License along with this program; if not, write to the Free Software *
|
||||
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
import FreeCAD
|
||||
import Path
|
||||
from ...shape import ToolBitShapeCustom
|
||||
from .base import ToolBit
|
||||
|
||||
|
||||
class ToolBitCustom(ToolBit):
|
||||
SHAPE_CLASS = ToolBitShapeCustom
|
||||
|
||||
def __init__(self, shape: ToolBitShapeCustom, id: str | None = None):
|
||||
Path.Log.track(f"ToolBitCustom __init__ called with shape: {shape}, id: {id}")
|
||||
super().__init__(shape, id=id)
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
return FreeCAD.Qt.translate("CAM", "Unknown custom toolbit type")
|
||||
@@ -26,7 +26,6 @@ import FreeCAD
|
||||
from ...assets import Asset, AssetUri, AssetSerializer
|
||||
from ...shape import ToolBitShape
|
||||
from ..models.base import ToolBit
|
||||
from Path.Base import Util as PathUtil
|
||||
|
||||
|
||||
class FCTBSerializer(AssetSerializer):
|
||||
|
||||
@@ -143,7 +143,7 @@ class ToolBitBrowserWidget(QtGui.QWidget):
|
||||
self._all_assets.sort(key=lambda x: x.label.lower())
|
||||
elif self._sort_key == "tool_no" and self._tool_no_factory:
|
||||
self._all_assets.sort(
|
||||
key=lambda x: (self._tool_no_factory(x) or 0) if self._tool_no_factory else 0
|
||||
key=lambda x: (int(self._tool_no_factory(x)) or 0) if self._tool_no_factory else 0
|
||||
)
|
||||
|
||||
def _trigger_fetch(self):
|
||||
|
||||
Reference in New Issue
Block a user