python-alfresco-api 1.1.5__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,7 +36,7 @@ from .clients.conversion_utils import (
36
36
  # Import raw_clients to ensure they get packaged
37
37
  from . import raw_clients
38
38
 
39
- __version__ = "1.1.1"
39
+ __version__ = "1.2.0"
40
40
  __all__ = [
41
41
  # Factory & utilities
42
42
  "ClientFactory",
@@ -7,6 +7,7 @@ Handles ticket-based authentication with automatic renewal.
7
7
 
8
8
  import asyncio
9
9
  import base64
10
+ import json
10
11
  import os
11
12
  from typing import Optional, Dict, Any, Union
12
13
  from datetime import datetime, timedelta
@@ -276,31 +277,77 @@ class TicketAuthUtil:
276
277
  Handles both Basic auth and ticket-based authentication.
277
278
  """
278
279
 
279
- def __init__(self, username: str, password: str, base_url: str = ""):
280
+ def __init__(self, username: str, password: str, base_url: str = "",
281
+ verify_ssl: Union[bool, str] = True, timeout: Optional[int] = None):
280
282
  """
281
283
  Initialize ticket auth utility.
282
-
284
+
283
285
  Args:
284
286
  username: Alfresco username
285
- password: Alfresco password
286
- base_url: Base URL for Alfresco (optional, for future ticket API calls)
287
+ password: Alfresco password
288
+ base_url: Base URL for Alfresco (required to self-fetch a ticket)
289
+ verify_ssl: SSL verification - True, False, or path to certificate bundle
290
+ timeout: Request timeout in seconds (None = use system defaults)
287
291
  """
288
292
  self.username = username
289
293
  self.password = password
290
294
  self.base_url = base_url.rstrip('/')
295
+ self.verify_ssl = verify_ssl
296
+ self.timeout = timeout
291
297
  self.ticket = None
298
+ self.ticket_expires = None
292
299
  self._authenticated = False
293
-
300
+
301
+ def authenticate_sync(self) -> bool:
302
+ """Synchronously fetch an Alfresco login ticket from username/password."""
303
+ if not self.base_url:
304
+ return False
305
+ try:
306
+ auth_url = f"{self.base_url}/alfresco/api/-default-/public/authentication/versions/1/tickets"
307
+ client_kwargs = {"verify": self.verify_ssl}
308
+ if self.timeout is not None:
309
+ client_kwargs["timeout"] = self.timeout
310
+ with httpx.Client(**client_kwargs) as client:
311
+ response = client.post(auth_url, json={"userId": self.username, "password": self.password})
312
+ if response.status_code == 201:
313
+ self.ticket = response.json()["entry"]["id"]
314
+ self.ticket_expires = datetime.now() + timedelta(hours=1)
315
+ self._authenticated = True
316
+ return True
317
+ print(f"Alfresco ticket authentication failed: {response.status_code} {response.text}")
318
+ except Exception as e:
319
+ print(f"Alfresco ticket authentication failed: {e}")
320
+ self._authenticated = False
321
+ return False
322
+
323
+ def ensure_authenticated_sync(self) -> bool:
324
+ """Ensure a valid ticket, fetching one if needed."""
325
+ if self.is_authenticated():
326
+ return True
327
+ return self.authenticate_sync()
328
+
294
329
  def get_basic_auth_header(self):
295
330
  """Get basic auth header for initial authentication."""
296
331
  auth_string = f"{self.username}:{self.password}"
297
332
  auth_b64 = base64.b64encode(auth_string.encode()).decode()
298
333
  return f'Basic {auth_b64}'
299
-
334
+
300
335
  def get_auth_token(self):
301
- """Get just the base64 auth token (without 'Basic ' prefix) for AuthenticatedClient."""
302
- auth_string = f"{self.username}:{self.password}"
303
- return base64.b64encode(auth_string.encode()).decode()
336
+ """Get the base64 token (no 'Basic ' prefix) for AuthenticatedClient.
337
+
338
+ Alfresco accepts a login ticket as `Authorization: Basic base64(<ticket>)`. We lazily
339
+ fetch a ticket (when base_url is set) and return base64(ticket); if that fails we fall
340
+ back to base64(username:password) so the client still authenticates.
341
+ """
342
+ if not self.is_authenticated() and self.base_url:
343
+ try:
344
+ self.ensure_authenticated_sync()
345
+ except Exception as e:
346
+ print(f"Alfresco ticket acquisition failed: {e}")
347
+ if self.is_authenticated() and self.ticket:
348
+ return base64.b64encode(self.ticket.encode()).decode()
349
+ # Fallback: basic credentials
350
+ return base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
304
351
 
305
352
  def get_ticket_header(self):
306
353
  """Get ticket auth header if we have a ticket."""
@@ -332,8 +379,13 @@ class TicketAuthUtil:
332
379
  self._authenticated = True
333
380
 
334
381
  def is_authenticated(self):
335
- """Check if we have a valid ticket."""
336
- return self._authenticated and self.ticket is not None
382
+ """Check if we have a valid (unexpired) ticket."""
383
+ if not self._authenticated or not self.ticket:
384
+ return False
385
+ if self.ticket_expires and datetime.now() >= self.ticket_expires:
386
+ self._authenticated = False
387
+ return False
388
+ return True
337
389
 
338
390
  class OAuth2AuthUtil:
339
391
  """
@@ -416,7 +468,18 @@ class OAuth2AuthUtil:
416
468
 
417
469
  # For environment variable loading
418
470
  self._load_oauth_env_config(load_env, env_file)
419
-
471
+
472
+ # If an access token was supplied (constructor or env), mark it usable so the
473
+ # synchronous client-build path can use it immediately. Previously a provided token
474
+ # was ignored because _authenticated stayed False.
475
+ if self.access_token:
476
+ self._authenticated = True
477
+ # Derive expiry from the token's own JWT `exp` claim when no expires_in was given,
478
+ # so a *provided* (pasted) token that has since expired is detected as stale and
479
+ # refreshed via refresh_token — instead of being used blindly and 401ing.
480
+ if self.token_expires is None:
481
+ self.token_expires = self._expiry_from_jwt(self.access_token)
482
+
420
483
  def _load_oauth_env_config(self, load_env: bool, env_file: Optional[str]):
421
484
  """Load OAuth2-specific configuration from environment."""
422
485
  if not load_env:
@@ -569,26 +632,143 @@ class OAuth2AuthUtil:
569
632
 
570
633
  self._authenticated = True
571
634
  return True
572
-
635
+
573
636
  return False
574
-
637
+
638
+ @staticmethod
639
+ def _expiry_from_jwt(token: str) -> Optional[datetime]:
640
+ """Best-effort read of a JWT access token's `exp` claim as a local naive datetime.
641
+
642
+ Lets a provided access token (which carries no expires_in) still be expiry-checked so
643
+ it can be refreshed when stale. Returns None for opaque (non-JWT) tokens or on any error.
644
+ """
645
+ try:
646
+ parts = token.split(".")
647
+ if len(parts) != 3:
648
+ return None
649
+ payload_b64 = parts[1]
650
+ payload_b64 += "=" * (-len(payload_b64) % 4) # restore base64 padding
651
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64))
652
+ exp = payload.get("exp")
653
+ return datetime.fromtimestamp(exp) if exp else None
654
+ except Exception:
655
+ return None
656
+
657
+ def _apply_token_response(self, token_data: Dict[str, Any]) -> None:
658
+ """Apply an OAuth2 token-endpoint response to this util's token state."""
659
+ self.access_token = token_data.get("access_token")
660
+ expires_in = token_data.get("expires_in")
661
+ if expires_in:
662
+ self.token_expires = datetime.now() + timedelta(seconds=expires_in)
663
+ elif self.access_token:
664
+ self.token_expires = self._expiry_from_jwt(self.access_token)
665
+ if "refresh_token" in token_data:
666
+ self.refresh_token = token_data["refresh_token"]
667
+ self._authenticated = bool(self.access_token)
668
+
669
+ # --- Synchronous token acquisition ---------------------------------------
670
+ # The sub-clients call get_auth_token() synchronously at client-build time, so the async
671
+ # authenticate()/ensure_authenticated() above never fire on their own. These sync variants
672
+ # let get_auth_token() lazily acquire/refresh a token so OAuth2 works end-to-end without the
673
+ # caller having to pre-await authenticate().
674
+
675
+ def _client_credentials_flow_sync(self) -> bool:
676
+ """Synchronous OAuth2 client-credentials flow."""
677
+ if not self.client_id or not self.client_secret or not self.token_endpoint:
678
+ raise ValueError(
679
+ "Client credentials flow requires client_id, client_secret, and token_endpoint"
680
+ )
681
+ data = {
682
+ "grant_type": "client_credentials",
683
+ "client_id": self.client_id,
684
+ "client_secret": self.client_secret,
685
+ }
686
+ if self.scope:
687
+ data["scope"] = self.scope
688
+ client_kwargs = {"verify": self.verify_ssl}
689
+ if self.timeout is not None:
690
+ client_kwargs["timeout"] = self.timeout
691
+ with httpx.Client(**client_kwargs) as client:
692
+ response = client.post(
693
+ self.token_endpoint, data=data,
694
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
695
+ )
696
+ if response.status_code == 200:
697
+ self._apply_token_response(response.json())
698
+ return True
699
+ return False
700
+
701
+ def _refresh_token_flow_sync(self) -> bool:
702
+ """Synchronous refresh-token flow."""
703
+ if not self.refresh_token or not self.token_endpoint:
704
+ return False
705
+ data = {
706
+ "grant_type": "refresh_token",
707
+ "refresh_token": self.refresh_token,
708
+ "client_id": self.client_id,
709
+ }
710
+ if self.client_secret:
711
+ data["client_secret"] = self.client_secret
712
+ client_kwargs = {"verify": self.verify_ssl}
713
+ if self.timeout is not None:
714
+ client_kwargs["timeout"] = self.timeout
715
+ with httpx.Client(**client_kwargs) as client:
716
+ response = client.post(
717
+ self.token_endpoint, data=data,
718
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
719
+ )
720
+ if response.status_code == 200:
721
+ self._apply_token_response(response.json())
722
+ return True
723
+ return False
724
+
725
+ def authenticate_sync(self) -> bool:
726
+ """Synchronous authenticate() — dispatches by grant type."""
727
+ try:
728
+ if self.grant_type == "client_credentials":
729
+ return self._client_credentials_flow_sync()
730
+ elif self.grant_type == "refresh_token" and self.refresh_token:
731
+ return self._refresh_token_flow_sync()
732
+ elif self.grant_type == "authorization_code":
733
+ # Requires prior user interaction; usable only if an access_token was supplied.
734
+ return self.is_authenticated()
735
+ else:
736
+ raise ValueError(f"Unsupported grant type: {self.grant_type}")
737
+ except Exception as e:
738
+ print(f"OAuth2 authentication failed: {e}")
739
+ self._authenticated = False
740
+ return False
741
+
742
+ def ensure_authenticated_sync(self) -> bool:
743
+ """Synchronous ensure_authenticated() — refresh if possible, else authenticate."""
744
+ if self.is_authenticated():
745
+ return True
746
+ if self.refresh_token:
747
+ if self._refresh_token_flow_sync():
748
+ return True
749
+ return self.authenticate_sync()
750
+
575
751
  def is_authenticated(self) -> bool:
576
752
  """Check if currently authenticated with valid access token."""
577
753
  if not self._authenticated or not self.access_token:
578
754
  return False
579
755
 
580
- # Check token expiration
581
- if self.token_expires and datetime.now() >= self.token_expires:
756
+ # Check token expiration (30s skew so we refresh just before it actually expires)
757
+ if self.token_expires and datetime.now() >= (self.token_expires - timedelta(seconds=30)):
582
758
  self._authenticated = False
583
759
  return False
584
-
760
+
585
761
  return True
586
762
 
587
763
  def get_auth_headers(self) -> Dict[str, str]:
588
- """Get authentication headers for API requests."""
764
+ """Get authentication headers for API requests (lazily acquiring a token if needed)."""
765
+ if not self.is_authenticated():
766
+ try:
767
+ self.ensure_authenticated_sync()
768
+ except Exception as e:
769
+ print(f"OAuth2 token acquisition failed: {e}")
589
770
  if not self.is_authenticated():
590
771
  return {}
591
-
592
772
  return {
593
773
  "Authorization": f"Bearer {self.access_token}"
594
774
  }
@@ -600,10 +780,18 @@ class OAuth2AuthUtil:
600
780
  return ""
601
781
 
602
782
  def get_auth_token(self):
603
- """Get just the access token (without 'Bearer ' prefix) for AuthenticatedClient."""
604
- if self.is_authenticated():
605
- return self.access_token
606
- return ""
783
+ """Get the access token (no 'Bearer ' prefix) for AuthenticatedClient.
784
+
785
+ Lazily acquires/refreshes the token synchronously when needed, so the sub-clients'
786
+ synchronous build path produces a real Bearer header. Previously this returned "" unless
787
+ the caller had manually pre-awaited authenticate(), which nothing in the build path did.
788
+ """
789
+ if not self.is_authenticated():
790
+ try:
791
+ self.ensure_authenticated_sync()
792
+ except Exception as e:
793
+ print(f"OAuth2 token acquisition failed: {e}")
794
+ return self.access_token if self.is_authenticated() else ""
607
795
 
608
796
  def get_auth_prefix(self):
609
797
  """Get authentication prefix for headers. OAuth2AuthUtil uses Bearer tokens."""
@@ -303,5 +303,5 @@ class ActionsClient:
303
303
 
304
304
  def __repr__(self) -> str:
305
305
  """String representation for debugging."""
306
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
306
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
307
307
  return f"AlfrescoActionsClient(base_url='{base_url}')"
@@ -124,5 +124,5 @@ class ActivitiesClient:
124
124
 
125
125
  def __repr__(self) -> str:
126
126
  """String representation for debugging."""
127
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
127
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
128
128
  return f"AlfrescoActivitiesClient(base_url='{base_url}')"
@@ -303,5 +303,5 @@ class CommentsClient:
303
303
 
304
304
  def __repr__(self) -> str:
305
305
  """String representation for debugging."""
306
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
306
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
307
307
  return f"AlfrescoCommentsClient(base_url='{base_url}')"
@@ -458,5 +458,5 @@ class ContentClient:
458
458
 
459
459
  def __repr__(self) -> str:
460
460
  """String representation for debugging."""
461
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
461
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
462
462
  return f"AlfrescoContentClient(base_url='{base_url}')"
@@ -173,5 +173,5 @@ class DownloadsClient:
173
173
 
174
174
  def __repr__(self) -> str:
175
175
  """String representation for debugging."""
176
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
176
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
177
177
  return f"DownloadsClient(base_url='{base_url}')"
@@ -85,5 +85,5 @@ class ProbesClient:
85
85
 
86
86
  def __repr__(self) -> str:
87
87
  """String representation for debugging."""
88
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
88
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
89
89
  return f"ProbesClient(base_url='{base_url}')"
@@ -260,5 +260,5 @@ class QueriesClient:
260
260
 
261
261
  def __repr__(self) -> str:
262
262
  """String representation for debugging."""
263
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
263
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
264
264
  return f"QueriesClient(base_url='{base_url}')"
@@ -238,5 +238,5 @@ class RatingsClient:
238
238
 
239
239
  def __repr__(self) -> str:
240
240
  """String representation for debugging."""
241
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
241
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
242
242
  return f"RatingsClient(base_url='{base_url}')"
@@ -153,5 +153,5 @@ class RenditionsClient:
153
153
 
154
154
  def __repr__(self) -> str:
155
155
  """String representation for debugging."""
156
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
156
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
157
157
  return f"RenditionsClient(base_url='{base_url}')"
@@ -31,8 +31,8 @@ class VersionsClient:
31
31
  return self.parent_client.raw_client
32
32
 
33
33
  def _get_core_client(self):
34
- """Get the core client from the factory."""
35
- return self._client_factory.create_core_client()
34
+ """Get the parent core client."""
35
+ return self.parent_client
36
36
 
37
37
  # ==================== SYNC METHODS ====================
38
38
 
@@ -110,9 +110,8 @@ class VersionsClient:
110
110
  CheckoutResponse: Response with checkout details
111
111
  """
112
112
  from ....utils import version_utils
113
-
114
- # Get core client from the same factory
115
- core_client = self._client_factory.create_core_client()
113
+
114
+ core_client = self._get_core_client()
116
115
 
117
116
  # Use utility function for implementation
118
117
  result = version_utils.checkout_document(core_client, node_id)
@@ -140,9 +139,8 @@ class VersionsClient:
140
139
  CheckoutResponse: Response with unlock details
141
140
  """
142
141
  from ....utils import version_utils
143
-
144
- # Get core client from the same factory
145
- core_client = self._client_factory.create_core_client()
142
+
143
+ core_client = self._get_core_client()
146
144
 
147
145
  # Use utility function for implementation
148
146
  result = version_utils.cancel_checkout(core_client, node_id)
@@ -178,9 +176,8 @@ class VersionsClient:
178
176
  CheckinResponse: Response with new version details
179
177
  """
180
178
  from ....utils import version_utils
181
-
182
- # Get core client from the same factory
183
- core_client = self._client_factory.create_core_client()
179
+
180
+ core_client = self._get_core_client()
184
181
 
185
182
  # Use utility function for implementation
186
183
  result = version_utils.checkin_document(
@@ -256,46 +253,23 @@ class VersionsClient:
256
253
  except Exception as e:
257
254
  raise ValueError(f"Failed to enable versioning on node {node_id}: {str(e)}")
258
255
 
259
- async def checkout_async(self, node_id: str) -> CheckoutResponse:
256
+ async def checkout_async(self, node_id: str, **kwargs) -> CheckoutResponse:
260
257
  """Async version of checkout."""
261
- # For now, return a mock response structure
262
- return CheckoutResponse(
263
- node_id=node_id,
264
- locked=True,
265
- working_copy_id=f"{node_id}-working-copy",
266
- locked_by="current_user",
267
- locked_at=datetime.now()
268
- )
258
+ return self.checkout(node_id, **kwargs)
269
259
 
270
- async def cancel_checkout_async(self, node_id: str) -> CheckoutResponse:
260
+ async def cancel_checkout_async(self, node_id: str, **kwargs) -> CheckoutResponse:
271
261
  """Async version of cancel_checkout."""
272
- # For now, return a mock response structure
273
- return CheckoutResponse(
274
- node_id=node_id,
275
- locked=False,
276
- working_copy_id=None,
277
- locked_by=None,
278
- locked_at=None
279
- )
262
+ return self.cancel_checkout(node_id, **kwargs)
280
263
 
281
264
  async def checkin_async(
282
265
  self,
283
266
  node_id: str,
284
267
  comment: Optional[str] = None,
285
- major_version: bool = False
268
+ major_version: bool = False,
269
+ **kwargs
286
270
  ) -> CheckinResponse:
287
271
  """Async version of checkin."""
288
- # For now, return a mock response structure
289
- version_number = "2.0" if major_version else "1.1"
290
-
291
- return CheckinResponse(
292
- node_id=node_id,
293
- version_number=version_number,
294
- comment=comment or "No comment",
295
- major_version=major_version,
296
- created_at=datetime.now(),
297
- created_by="current_user"
298
- )
272
+ return self.checkin(node_id, comment=comment, major_version=major_version, **kwargs)
299
273
 
300
274
  # ==================== DETAILED METHODS - Moved from AlfrescoCoreClient ====================
301
275
 
@@ -399,5 +373,5 @@ class VersionsClient:
399
373
 
400
374
  def __repr__(self) -> str:
401
375
  """String representation for debugging."""
402
- base_url = getattr(self._client_factory, 'base_url', 'unknown')
376
+ base_url = getattr(self.parent_client._client_factory, 'base_url', 'unknown')
403
377
  return f"AlfrescoVersionsClient(base_url='{base_url}')"
@@ -1,8 +1,9 @@
1
1
  """
2
2
  Alfresco Event System
3
3
 
4
- Unified event client supporting both Community Edition (ActiveMQ) and Enterprise Edition (Event Gateway).
5
- Provides automatic detection and graceful fallback capabilities.
4
+ Event client for Alfresco's ActiveMQ messaging (repo events on the STOMP topic
5
+ /topic/alfresco.repo.event2). Detects ActiveMQ availability and registers handlers; consumers
6
+ subscribe to the STOMP topic directly (see AlfrescoEventClient for details).
6
7
  """
7
8
 
8
9
  from .event_client import AlfrescoEventClient