2525from contextlib import contextmanager
2626from datetime import datetime , timedelta , timezone
2727from pathlib import Path
28- from typing import Dict
28+ from typing import Dict , Tuple
2929from urllib .parse import unquote_plus
3030
3131import httpx
@@ -58,7 +58,9 @@ class StructurizrClient:
5858 agent (str): A string identifying the agent (e.g. 'structurizr-java/1.2.0').
5959 workspace_archive_location (pathlib.Path): A directory for archiving downloaded
6060 workspaces, or None to suppress archiving.
61-
61+ ignore_free_plan_locking_errors (bool): When True (the default), attempts to
62+ lock a workspace on a free plan licence will succeed even though free plans
63+ don't allow locking.
6264 """
6365
6466 def __init__ (self , * , settings : StructurizrClientSettings , ** kwargs ):
@@ -82,6 +84,7 @@ def __init__(self, *, settings: StructurizrClientSettings, **kwargs):
8284 self .agent = settings .agent
8385 self .workspace_archive_location = settings .workspace_archive_location
8486 self .merge_from_remote = True
87+ self .ignore_free_plan_locking_errors = True
8588 self ._workspace_url = f"/workspace/{ self .workspace_id } "
8689 self ._lock_url = f"{ self ._workspace_url } /lock"
8790 self ._params = {
@@ -134,15 +137,16 @@ def __exit__(self, exc_type, exc_val, exc_tb):
134137 @contextmanager
135138 def lock (self ):
136139 """Provide a context manager for locking and unlocking a workspace."""
137- is_successful = self .lock_workspace ()
138- if not is_successful :
139- raise StructurizrClientException (
140- f"Failed to lock the Structurizr workspace { self .workspace_id } ."
141- )
140+ locked , paid_plan = self ._lock_workspace ()
141+ if not locked :
142+ if paid_plan or not self .ignore_free_plan_locking_errors :
143+ raise StructurizrClientException (
144+ f"Failed to lock the Structurizr workspace { self .workspace_id } ."
145+ )
142146 try :
143147 yield self
144148 finally :
145- is_successful = self .unlock_workspace ()
149+ is_successful = self .unlock_workspace () if locked else True
146150 self ._client .close ()
147151 if not is_successful :
148152 raise StructurizrClientException (
@@ -224,13 +228,13 @@ def put_workspace(self, workspace: Workspace) -> None:
224228 f"Error message: { body .get ('message' , '' )} "
225229 )
226230
227- def lock_workspace (self ) -> bool :
231+ def _lock_workspace (self ) -> Tuple [ bool , bool ] :
228232 """
229233 Lock the Structurizr workspace.
230234
231235 Returns:
232- bool: `True` if the workspace could be locked, `False` otherwise.
233-
236+ bool: `True` if lock succeeeded
237+ bool: `True` if on paid plan
234238 """
235239 request = self ._client .build_request ("PUT" , self ._lock_url , params = self ._params )
236240 request .headers .update (self ._add_headers (request ))
@@ -239,10 +243,26 @@ def lock_workspace(self) -> bool:
239243 logger .debug ("%r" , response .json ())
240244 response = APIResponse .parse_raw (response .text )
241245 if not response .success :
242- logger .error (
246+ logger .warning (
243247 f"Failed to lock workspace { self .workspace_id } . { response .message } "
244248 )
245- return response .success
249+ return response .success , self ._paid_plan (response )
250+
251+ def lock_workspace (self ) -> bool :
252+ """Lock the Structurizr workspace.
253+
254+ Returns:
255+ bool: `True` if the workspace could be locked, `False` otherwise.
256+
257+ Note that free plan Structurizr licences do not support locking. By
258+ default this failure will be ignored, however if you do want such locks
259+ to fail then set ignore_free_plan_locking_errors to False.
260+ """
261+ success , paid_plan = self ._lock_workspace ()
262+ if not success and not paid_plan and self .ignore_free_plan_locking_errors :
263+ logger .debug ("Ignoring lock failure on free plan" )
264+ success = True
265+ return success
246266
247267 def unlock_workspace (self ) -> bool :
248268 """
@@ -259,11 +279,15 @@ def unlock_workspace(self) -> bool:
259279 response = self ._client .send (request )
260280 response .raise_for_status ()
261281 response = APIResponse .parse_raw (response .text )
282+ success = response .success
262283 if not response .success :
263- logger .error (
284+ logger .warning (
264285 f"Failed to unlock workspace { self .workspace_id } . { response .message } "
265286 )
266- return response .success
287+ if self .ignore_free_plan_locking_errors and not self ._paid_plan (response ):
288+ logger .debug ("Ignoring unlock failure on free plan" )
289+ success = True
290+ return success
267291
268292 def _add_headers (
269293 self ,
@@ -359,3 +383,7 @@ def _message_digest(
359383 ) -> str :
360384 """Assemble the complete message digest."""
361385 return f"{ http_verb } \n { uri_path } \n { definition_md5 } \n { content_type } \n { nonce } \n "
386+
387+ @staticmethod
388+ def _paid_plan (response : APIResponse ) -> bool :
389+ return "free plan" not in response .message .lower ()
0 commit comments