Skip to content

Commit f5bbdd6

Browse files
rvirani1claude
andcommitted
fix: make Workspace importable in slim mode
Move all heavy imports in workspace.py (Project, folderparser, PIL, active_learning_utils, image_utils, model_processor, two_stage_utils) to lazy imports inside the methods that use them. Workspace now imports cleanly without PIL/opencv, so vision events, search, folders, and workflows all work in slim installs. Update test mock paths to match the new import locations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3488f7e commit f5bbdd6

5 files changed

Lines changed: 44 additions & 52 deletions

File tree

roboflow/__init__.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,14 @@
1010

1111
from roboflow.adapters import rfapi
1212
from roboflow.config import API_URL, APP_URL, DEMO_KEYS, load_roboflow_api_key
13+
from roboflow.core.workspace import Workspace
1314
from roboflow.util.general import write_line
1415

1516
try:
1617
from roboflow.core.project import Project
17-
from roboflow.core.workspace import Workspace
1818
from roboflow.models import CLIPModel, GazeModel # noqa: F401
1919
except ImportError:
2020
Project = None # type: ignore[assignment,misc]
21-
Workspace = None # type: ignore[assignment,misc]
2221
CLIPModel = None # type: ignore[assignment,misc]
2322
GazeModel = None # type: ignore[assignment,misc]
2423

@@ -233,11 +232,6 @@ def auth(self):
233232
return self
234233

235234
def workspace(self, the_workspace=None):
236-
if Workspace is None:
237-
raise ImportError(
238-
"Workspace requires additional dependencies. Install the full package: pip install roboflow"
239-
)
240-
241235
sys.stdout.write("\r" + "loading Roboflow workspace...")
242236
sys.stdout.write("\n")
243237
sys.stdout.flush()

roboflow/core/workspace.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,7 @@
1414

1515
from roboflow.adapters import rfapi, vision_events_api
1616
from roboflow.adapters.rfapi import AnnotationSaveError, ImageUploadError, RoboflowError
17-
from roboflow.config import API_URL, APP_URL, CLIP_FEATURIZE_URL, DEMO_KEYS
18-
from roboflow.core.project import Project
19-
from roboflow.util import folderparser
20-
from roboflow.util.active_learning_utils import check_box_size, clip_encode, count_comparisons
21-
from roboflow.util.general import extract_zip as _extract_zip
22-
from roboflow.util.image_utils import load_labelmap
23-
from roboflow.util.model_processor import process
24-
from roboflow.util.two_stage_utils import ocr_infer
25-
from roboflow.util.versions import normalize_yolo_model_type
17+
from roboflow.config import API_URL, APP_URL, DEMO_KEYS
2618

2719

2820
class Workspace:
@@ -63,6 +55,8 @@ def projects(self):
6355
Returns:
6456
List of Project objects.
6557
"""
58+
from roboflow.core.project import Project
59+
6660
projects_array = []
6761
for a_project in self.project_list:
6862
proj = Project(self.__api_key, a_project, self.model_format)
@@ -82,6 +76,8 @@ def project(self, project_id):
8276
Returns:
8377
Project Object
8478
"""
79+
from roboflow.core.project import Project
80+
8581
sys.stdout.write("\r" + "loading Roboflow project...")
8682
sys.stdout.write("\n")
8783
sys.stdout.flush()
@@ -112,6 +108,8 @@ def create_project(self, project_name, project_type, project_license, annotation
112108
Returns:
113109
Project Object
114110
""" # noqa: E501 // docs
111+
from roboflow.core.project import Project
112+
115113
data = {
116114
"name": project_name,
117115
"type": project_type,
@@ -142,6 +140,9 @@ def clip_compare(self, dir: str = "", image_ext: str = ".png", target_image: str
142140
dict: a key:value mapping of image_name:comparison_score_to_target
143141
""" # noqa: E501 // docs
144142

143+
from roboflow.config import CLIP_FEATURIZE_URL
144+
from roboflow.util.active_learning_utils import clip_encode
145+
145146
# list to store comparison results in
146147
comparisons = []
147148
# grab all images in a given directory with ext type
@@ -248,6 +249,8 @@ def two_stage_ocr(
248249
""" # noqa: E501 // docs
249250
from PIL import Image
250251

252+
from roboflow.util.two_stage_utils import ocr_infer
253+
251254
results = []
252255

253256
# create PIL image for cropping
@@ -310,6 +313,9 @@ def upload_dataset(
310313
num_retries (int, optional): number of times to retry uploading an image if the upload fails. Defaults to 0.
311314
is_prediction (bool, optional): whether the annotations provided in the dataset are predictions and not ground truth. Defaults to False.
312315
""" # noqa: E501 // docs
316+
from roboflow.util import folderparser
317+
from roboflow.util.image_utils import load_labelmap
318+
313319
if dataset_format != "NOT_USED":
314320
print("Warning: parameter 'dataset_format' is deprecated and will be removed in a future release")
315321
project, created = self._get_or_create_project(
@@ -463,6 +469,9 @@ def active_learning(
463469
use_localhost: (bool) = determines if local http format used or remote endpoint
464470
local_server: (str) = local http address for inference server, use_localhost must be True for this to be used
465471
""" # noqa: E501 // docs
472+
from roboflow.config import CLIP_FEATURIZE_URL
473+
from roboflow.util.active_learning_utils import check_box_size, clip_encode, count_comparisons
474+
466475
if inference_endpoint is None:
467476
inference_endpoint = []
468477
if conditionals is None:
@@ -611,6 +620,9 @@ def deploy_model(
611620
filename (str, optional): The name of the weights file. Defaults to "weights/best.pt".
612621
"""
613622

623+
from roboflow.util.model_processor import process
624+
from roboflow.util.versions import normalize_yolo_model_type
625+
614626
if not project_ids:
615627
raise ValueError("At least one project ID must be provided")
616628

@@ -805,6 +817,8 @@ def search_export(
805817
ValueError: If both *dataset* and *annotation_group* are provided.
806818
RoboflowError: On API errors or export timeout.
807819
"""
820+
from roboflow.util.general import extract_zip as _extract_zip
821+
808822
if dataset is not None and annotation_group is not None:
809823
raise ValueError("dataset and annotation_group are mutually exclusive; provide only one")
810824

tests/test_project.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,15 @@ def _setup_upload_dataset_mocks(
6161

6262
# Create the mock objects
6363
mocks = {
64-
"parser": patch("roboflow.core.workspace.folderparser.parsefolder", return_value=test_dataset),
65-
"upload": patch("roboflow.core.workspace.Project.upload_image", side_effect=upload_image_side_effect)
64+
"parser": patch("roboflow.util.folderparser.parsefolder", return_value=test_dataset),
65+
"upload": patch("roboflow.core.project.Project.upload_image", side_effect=upload_image_side_effect)
6666
if upload_image_side_effect
67-
else patch("roboflow.core.workspace.Project.upload_image", return_value=image_return),
67+
else patch("roboflow.core.project.Project.upload_image", return_value=image_return),
6868
"save_annotation": patch(
69-
"roboflow.core.workspace.Project.save_annotation", side_effect=save_annotation_side_effect
69+
"roboflow.core.project.Project.save_annotation", side_effect=save_annotation_side_effect
7070
)
7171
if save_annotation_side_effect
72-
else patch("roboflow.core.workspace.Project.save_annotation", return_value=annotation_return),
72+
else patch("roboflow.core.project.Project.save_annotation", return_value=annotation_return),
7373
"get_project": patch(
7474
"roboflow.core.workspace.Workspace._get_or_create_project", return_value=(self.project, project_created)
7575
),
@@ -348,7 +348,7 @@ def test_project_upload_dataset(self):
348348
"extra_mocks": [
349349
(
350350
"load_labelmap",
351-
"roboflow.core.workspace.load_labelmap",
351+
"roboflow.util.image_utils.load_labelmap",
352352
{"return_value": {"old_label": "new_label"}},
353353
)
354354
],
@@ -650,13 +650,13 @@ def capture_annotation_calls(annotation_path, **kwargs):
650650
return ({"success": True}, 0.1, 0)
651651

652652
mocks = {
653-
"parser": patch("roboflow.core.workspace.folderparser.parsefolder", return_value=parsed_dataset),
653+
"parser": patch("roboflow.util.folderparser.parsefolder", return_value=parsed_dataset),
654654
"upload": patch(
655-
"roboflow.core.workspace.Project.upload_image",
655+
"roboflow.core.project.Project.upload_image",
656656
return_value=({"id": "test-id", "success": True}, 0.1, 0),
657657
),
658658
"save_annotation": patch(
659-
"roboflow.core.workspace.Project.save_annotation", side_effect=capture_annotation_calls
659+
"roboflow.core.project.Project.save_annotation", side_effect=capture_annotation_calls
660660
),
661661
"get_project": patch(
662662
"roboflow.core.workspace.Workspace._get_or_create_project", return_value=(self.project, False)
@@ -737,13 +737,13 @@ def capture_annotation_calls(annotation_path, **kwargs):
737737
return ({"success": True}, 0.1, 0)
738738

739739
mocks = {
740-
"parser": patch("roboflow.core.workspace.folderparser.parsefolder", return_value=parsed_dataset),
740+
"parser": patch("roboflow.util.folderparser.parsefolder", return_value=parsed_dataset),
741741
"upload": patch(
742-
"roboflow.core.workspace.Project.upload_image",
742+
"roboflow.core.project.Project.upload_image",
743743
return_value=({"id": "test-id", "success": True}, 0.1, 0),
744744
),
745745
"save_annotation": patch(
746-
"roboflow.core.workspace.Project.save_annotation", side_effect=capture_annotation_calls
746+
"roboflow.core.project.Project.save_annotation", side_effect=capture_annotation_calls
747747
),
748748
"get_project": patch(
749749
"roboflow.core.workspace.Workspace._get_or_create_project", return_value=(self.project, False)

tests/test_slim_compat.py

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,31 +51,18 @@ class TestSlimGracefulDegradation(unittest.TestCase):
5151
In a full install they verify the guard exists but doesn't fire.
5252
"""
5353

54-
def test_workspace_and_project_attributes_exist(self):
55-
"""Workspace and Project are either real classes or None sentinels."""
54+
def test_workspace_always_available(self):
55+
"""Workspace imports cleanly even in slim mode."""
5656
import roboflow
5757

58-
# In full install these are classes; in slim they're None
59-
ws = roboflow.Workspace
60-
proj = roboflow.Project
61-
self.assertTrue(ws is None or callable(ws))
62-
self.assertTrue(proj is None or callable(proj))
58+
self.assertIsNotNone(roboflow.Workspace)
59+
self.assertTrue(callable(roboflow.Workspace))
6360

64-
def test_roboflow_workspace_guard(self):
65-
"""If Workspace is None (slim), calling workspace() raises ImportError."""
61+
def test_project_guarded(self):
62+
"""Project is either a real class (full) or None (slim)."""
6663
import roboflow
6764

68-
if roboflow.Workspace is not None:
69-
self.skipTest("Full install, Workspace is available")
70-
71-
rf = roboflow.Roboflow.__new__(roboflow.Roboflow)
72-
rf.api_key = "test"
73-
rf.current_workspace = "test"
74-
rf.model_format = "yolov8"
75-
76-
with self.assertRaises(ImportError) as ctx:
77-
rf.workspace()
78-
self.assertIn("pip install roboflow", str(ctx.exception))
65+
self.assertTrue(roboflow.Project is None or callable(roboflow.Project))
7966

8067
def test_roboflow_project_guard(self):
8168
"""If Project is None (slim), calling project() raises ImportError."""

tests/test_vision_events.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ class TestVisionEvents(unittest.TestCase):
1818
WORKSPACE = "test-ws"
1919

2020
def _make_workspace(self):
21-
try:
22-
from roboflow.core.workspace import Workspace
23-
except ImportError:
24-
self.skipTest("Workspace requires PIL (not available in slim install)")
21+
from roboflow.core.workspace import Workspace
2522

2623
info = {
2724
"workspace": {

0 commit comments

Comments
 (0)