docspan 0.1.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.
Files changed (65) hide show
  1. docspan/__init__.py +3 -0
  2. docspan/__main__.py +0 -0
  3. docspan/backends/__init__.py +19 -0
  4. docspan/backends/base.py +85 -0
  5. docspan/backends/confluence/__init__.py +0 -0
  6. docspan/backends/confluence/adf/__init__.py +14 -0
  7. docspan/backends/confluence/adf/comparator.py +427 -0
  8. docspan/backends/confluence/adf/converter.py +119 -0
  9. docspan/backends/confluence/adf/converters.py +1449 -0
  10. docspan/backends/confluence/adf/interfaces.py +191 -0
  11. docspan/backends/confluence/adf/nodes.py +2085 -0
  12. docspan/backends/confluence/adf/parser.py +400 -0
  13. docspan/backends/confluence/adf/validators.py +161 -0
  14. docspan/backends/confluence/adf/visitors.py +495 -0
  15. docspan/backends/confluence/backend.py +227 -0
  16. docspan/backends/confluence/client.py +44 -0
  17. docspan/backends/confluence/config/__init__.py +21 -0
  18. docspan/backends/confluence/config/loader.py +107 -0
  19. docspan/backends/confluence/config/models.py +167 -0
  20. docspan/backends/confluence/config/validation.py +297 -0
  21. docspan/backends/confluence/markdown/__init__.py +22 -0
  22. docspan/backends/confluence/markdown/ast.py +819 -0
  23. docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
  24. docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
  25. docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
  26. docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
  27. docspan/backends/confluence/markdown/inline_parser.py +495 -0
  28. docspan/backends/confluence/markdown/parser.py +1006 -0
  29. docspan/backends/confluence/models/__init__.py +18 -0
  30. docspan/backends/confluence/models/markdown_file.py +402 -0
  31. docspan/backends/confluence/models/page.py +212 -0
  32. docspan/backends/confluence/models/path_utils.py +34 -0
  33. docspan/backends/confluence/models/results.py +28 -0
  34. docspan/backends/confluence/models/sync_status.py +382 -0
  35. docspan/backends/confluence/services/__init__.py +0 -0
  36. docspan/backends/confluence/services/confluence/__init__.py +40 -0
  37. docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
  38. docspan/backends/confluence/services/confluence/base_client.py +420 -0
  39. docspan/backends/confluence/services/confluence/client.py +376 -0
  40. docspan/backends/confluence/services/confluence/comment_client.py +682 -0
  41. docspan/backends/confluence/services/confluence/crawler.py +587 -0
  42. docspan/backends/confluence/services/confluence/label_client.py +130 -0
  43. docspan/backends/confluence/services/confluence/page_client.py +1288 -0
  44. docspan/backends/confluence/services/confluence/space_client.py +179 -0
  45. docspan/backends/confluence/services/confluence/url_parser.py +106 -0
  46. docspan/backends/google_docs/__init__.py +0 -0
  47. docspan/backends/google_docs/auth.py +143 -0
  48. docspan/backends/google_docs/backend.py +140 -0
  49. docspan/backends/google_docs/client.py +665 -0
  50. docspan/backends/google_docs/converter.py +471 -0
  51. docspan/backends/google_docs/docs_request_builder.py +232 -0
  52. docspan/backends/google_docs/docs_structure_parser.py +120 -0
  53. docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
  54. docspan/cli/__init__.py +0 -0
  55. docspan/cli/main.py +408 -0
  56. docspan/config.py +62 -0
  57. docspan/core/__init__.py +49 -0
  58. docspan/core/merge.py +30 -0
  59. docspan/core/orchestrator.py +332 -0
  60. docspan/core/paths.py +8 -0
  61. docspan/core/state.py +53 -0
  62. docspan-0.1.0.dist-info/METADATA +273 -0
  63. docspan-0.1.0.dist-info/RECORD +65 -0
  64. docspan-0.1.0.dist-info/WHEEL +4 -0
  65. docspan-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,179 @@
1
+ """
2
+ Client for managing Confluence spaces.
3
+
4
+ This module provides specialized functionality for working with Confluence spaces,
5
+ including retrieving space information and content.
6
+ """
7
+
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from docspan.backends.confluence.config.models import ConfluenceConfig
11
+ from docspan.backends.confluence.services.confluence.base_client import BaseConfluenceClient
12
+
13
+
14
+ class SpaceClient(BaseConfluenceClient):
15
+ """
16
+ Client for managing Confluence spaces.
17
+
18
+ Provides methods for retrieving space information and content.
19
+ """
20
+
21
+ def __init__(self, config: ConfluenceConfig):
22
+ """
23
+ Initialize the space client.
24
+
25
+ Args:
26
+ config: Confluence configuration
27
+ """
28
+ super().__init__(config)
29
+
30
+ def get_space(self, space_key: str) -> Dict[str, Any]:
31
+ """
32
+ Get information about a space.
33
+
34
+ Args:
35
+ space_key: Space key
36
+
37
+ Returns:
38
+ Space data
39
+ """
40
+ endpoint = f"space/{space_key}"
41
+ params = {"expand": "description,homepage"}
42
+
43
+ return self._make_request(
44
+ method="GET",
45
+ endpoint=endpoint,
46
+ params=params
47
+ )
48
+
49
+ def get_spaces(
50
+ self,
51
+ start: int = 0,
52
+ limit: int = 25,
53
+ type: Optional[str] = None,
54
+ status: Optional[str] = None,
55
+ expand: Optional[str] = None
56
+ ) -> List[Dict[str, Any]]:
57
+ """
58
+ Get all spaces or filtered spaces.
59
+
60
+ Args:
61
+ start: Start index for pagination
62
+ limit: Maximum number of results to return
63
+ type: Space type filter (global, personal)
64
+ status: Space status filter (current, archived)
65
+ expand: Additional properties to expand in the response
66
+
67
+ Returns:
68
+ List of space data
69
+ """
70
+ endpoint = "space"
71
+ params = {
72
+ "start": start,
73
+ "limit": limit
74
+ }
75
+
76
+ if type:
77
+ params["type"] = type
78
+
79
+ if status:
80
+ params["status"] = status
81
+
82
+ if expand:
83
+ params["expand"] = expand
84
+
85
+ response = self._make_request(
86
+ method="GET",
87
+ endpoint=endpoint,
88
+ params=params
89
+ )
90
+
91
+ # Return the results
92
+ if isinstance(response, dict):
93
+ return response.get("results", [])
94
+
95
+ return []
96
+
97
+ def get_space_content(
98
+ self,
99
+ space_key: str,
100
+ content_type: str = "page",
101
+ start: int = 0,
102
+ limit: int = 25,
103
+ expand: Optional[str] = None
104
+ ) -> List[Dict[str, Any]]:
105
+ """
106
+ Get content in a space.
107
+
108
+ Args:
109
+ space_key: Space key
110
+ content_type: Content type (page, blogpost, comment)
111
+ start: Start index for pagination
112
+ limit: Maximum number of results to return
113
+ expand: Additional properties to expand in the response
114
+
115
+ Returns:
116
+ List of content data
117
+ """
118
+ endpoint = f"space/{space_key}/content/{content_type}"
119
+ params = {
120
+ "start": start,
121
+ "limit": limit
122
+ }
123
+
124
+ if expand:
125
+ params["expand"] = expand
126
+
127
+ response = self._make_request(
128
+ method="GET",
129
+ endpoint=endpoint,
130
+ params=params
131
+ )
132
+
133
+ # Return the results
134
+ if isinstance(response, dict):
135
+ return response.get("results", [])
136
+
137
+ return []
138
+
139
+ def search_space_content(
140
+ self,
141
+ space_key: str,
142
+ query: str,
143
+ start: int = 0,
144
+ limit: int = 25
145
+ ) -> List[Dict[str, Any]]:
146
+ """
147
+ Search for content in a space.
148
+
149
+ Args:
150
+ space_key: Space key
151
+ query: Search query
152
+ start: Start index for pagination
153
+ limit: Maximum number of results to return
154
+
155
+ Returns:
156
+ List of content data matching the search query
157
+ """
158
+ # Use CQL (Confluence Query Language)
159
+ cql = f"space = {space_key} AND text ~ \"{query}\""
160
+
161
+ endpoint = "content/search"
162
+ params = {
163
+ "cql": cql,
164
+ "start": start,
165
+ "limit": limit,
166
+ "expand": "space,version"
167
+ }
168
+
169
+ response = self._make_request(
170
+ method="GET",
171
+ endpoint=endpoint,
172
+ params=params
173
+ )
174
+
175
+ # Return the results
176
+ if isinstance(response, dict):
177
+ return response.get("results", [])
178
+
179
+ return []
@@ -0,0 +1,106 @@
1
+ """
2
+ Utilities for parsing Confluence URLs.
3
+
4
+ This module provides functions to extract page IDs and space keys from Confluence URLs.
5
+ """
6
+
7
+ import re
8
+ from typing import Optional, Tuple
9
+
10
+
11
+ class ConfluenceUrlParser:
12
+ """Parser for Confluence URLs."""
13
+
14
+ # Pattern for Confluence page URLs:
15
+ # https://{domain}/wiki/spaces/{space_key}/pages/{page_id}/{title}
16
+ PAGE_URL_PATTERN = re.compile(
17
+ r'/wiki/spaces/(?P<space_key>[^/]+)/pages/(?P<page_id>\d+)(?:/|$)'
18
+ )
19
+
20
+ @classmethod
21
+ def parse_page_url(cls, url: str) -> Tuple[Optional[str], Optional[str]]:
22
+ """
23
+ Parse a Confluence page URL to extract page ID and space key.
24
+
25
+ Args:
26
+ url: Confluence page URL
27
+
28
+ Returns:
29
+ Tuple of (page_id, space_key), or (None, None) if parsing fails
30
+
31
+ Examples:
32
+ >>> parser = ConfluenceUrlParser()
33
+ >>> parser.parse_page_url('https://example.atlassian.net/wiki/spaces/MYSPACE/pages/123456/Page+Title')
34
+ ('123456', 'MYSPACE')
35
+ >>> parser.parse_page_url('https://example.atlassian.net/wiki/spaces/~630044b443e43992b9a3e6f2/pages/973308410/Reliability+Manifesto')
36
+ ('973308410', '~630044b443e43992b9a3e6f2')
37
+ """
38
+ match = cls.PAGE_URL_PATTERN.search(url)
39
+ if match:
40
+ return match.group('page_id'), match.group('space_key')
41
+ return None, None
42
+
43
+ @classmethod
44
+ def extract_page_id(cls, url_or_id: str) -> Optional[str]:
45
+ """
46
+ Extract page ID from a URL or return the ID if already provided.
47
+
48
+ Args:
49
+ url_or_id: Either a Confluence URL or a page ID
50
+
51
+ Returns:
52
+ Page ID or None if not found
53
+
54
+ Examples:
55
+ >>> parser = ConfluenceUrlParser()
56
+ >>> parser.extract_page_id('123456')
57
+ '123456'
58
+ >>> parser.extract_page_id('https://example.atlassian.net/wiki/spaces/MYSPACE/pages/123456/Title')
59
+ '123456'
60
+ """
61
+ # If it's already just a number, return it
62
+ if url_or_id.isdigit():
63
+ return url_or_id
64
+
65
+ # Try to parse as URL
66
+ page_id, _ = cls.parse_page_url(url_or_id)
67
+ return page_id
68
+
69
+ @classmethod
70
+ def extract_space_key(cls, url: str) -> Optional[str]:
71
+ """
72
+ Extract space key from a Confluence URL.
73
+
74
+ Args:
75
+ url: Confluence page URL
76
+
77
+ Returns:
78
+ Space key or None if not found
79
+
80
+ Examples:
81
+ >>> parser = ConfluenceUrlParser()
82
+ >>> parser.extract_space_key('https://example.atlassian.net/wiki/spaces/MYSPACE/pages/123456/Title')
83
+ 'MYSPACE'
84
+ """
85
+ _, space_key = cls.parse_page_url(url)
86
+ return space_key
87
+
88
+ @classmethod
89
+ def is_confluence_url(cls, text: str) -> bool:
90
+ """
91
+ Check if the text looks like a Confluence URL.
92
+
93
+ Args:
94
+ text: Text to check
95
+
96
+ Returns:
97
+ True if it looks like a Confluence URL
98
+
99
+ Examples:
100
+ >>> parser = ConfluenceUrlParser()
101
+ >>> parser.is_confluence_url('https://example.atlassian.net/wiki/spaces/MYSPACE/pages/123456')
102
+ True
103
+ >>> parser.is_confluence_url('123456')
104
+ False
105
+ """
106
+ return cls.PAGE_URL_PATTERN.search(text) is not None
File without changes
@@ -0,0 +1,143 @@
1
+ """
2
+ Google Drive Authentication Module
3
+
4
+ Handles authentication for two separate Google accounts:
5
+ - Account A: Google Docs source
6
+ - Account B: Obsidian vault storage
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+
13
+ from google.auth.transport.requests import Request
14
+ from google.oauth2 import service_account
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Google Drive API scopes
19
+ PULL_SCOPES = [
20
+ "https://www.googleapis.com/auth/documents.readonly",
21
+ "https://www.googleapis.com/auth/drive.readonly",
22
+ "https://www.googleapis.com/auth/spreadsheets.readonly",
23
+ ]
24
+
25
+ PUSH_SCOPES = [
26
+ "https://www.googleapis.com/auth/documents",
27
+ "https://www.googleapis.com/auth/drive",
28
+ "https://www.googleapis.com/auth/spreadsheets.readonly",
29
+ ]
30
+
31
+ # Default to read-write scopes so push works out of the box.
32
+ # Note: existing tokens created with documents.readonly are insufficient for push.
33
+ DEFAULT_SCOPES = PUSH_SCOPES
34
+
35
+ # Legacy alias — kept so any code that references SCOPES still works.
36
+ SCOPES = DEFAULT_SCOPES
37
+
38
+
39
+ class GoogleAuthenticator:
40
+ """Handles Google API authentication"""
41
+
42
+ def __init__(self, credentials_json=None, credentials_path=None):
43
+ """
44
+ Initialize authenticator with service account credentials
45
+
46
+ Args:
47
+ credentials_json: JSON string of service account credentials
48
+ credentials_path: Path to service account JSON file
49
+ """
50
+ self.credentials = None
51
+
52
+ if credentials_json:
53
+ # Load from JSON string (for Railway env vars)
54
+ creds_dict = json.loads(credentials_json)
55
+ self.credentials = service_account.Credentials.from_service_account_info(
56
+ creds_dict, scopes=SCOPES
57
+ )
58
+ logger.info("Loaded credentials from JSON string")
59
+ elif credentials_path:
60
+ # Load from file (for local development)
61
+ self.credentials = service_account.Credentials.from_service_account_file(
62
+ credentials_path, scopes=SCOPES
63
+ )
64
+ logger.info(f"Loaded credentials from file: {credentials_path}")
65
+ else:
66
+ raise ValueError("Either credentials_json or credentials_path must be provided")
67
+
68
+ def get_credentials(self):
69
+ """
70
+ Get valid credentials
71
+
72
+ Returns:
73
+ google.auth.credentials.Credentials
74
+ """
75
+ if not self.credentials:
76
+ raise ValueError("Credentials not initialized")
77
+
78
+ # Refresh if needed
79
+ if not self.credentials.valid:
80
+ logger.info("Refreshing credentials")
81
+ self.credentials.refresh(Request())
82
+
83
+ return self.credentials
84
+
85
+
86
+ class DualAccountAuth:
87
+ """Manages authentication for both Google accounts"""
88
+
89
+ def __init__(self):
90
+ """Initialize dual account authentication from environment variables"""
91
+ self.account_a_auth = None
92
+ self.account_b_auth = None
93
+ self._load_from_env()
94
+
95
+ def _load_from_env(self):
96
+ """Load credentials from environment variables"""
97
+ # Account A (Google Docs)
98
+ account_a_json = os.getenv('ACCOUNT_A_CREDENTIALS')
99
+ account_a_path = os.getenv('ACCOUNT_A_CREDENTIALS_PATH')
100
+
101
+ if account_a_json:
102
+ self.account_a_auth = GoogleAuthenticator(credentials_json=account_a_json)
103
+ elif account_a_path:
104
+ self.account_a_auth = GoogleAuthenticator(credentials_path=account_a_path)
105
+ else:
106
+ logger.warning("Account A credentials not found in environment")
107
+
108
+ # Account B (Obsidian Vault)
109
+ account_b_json = os.getenv('ACCOUNT_B_CREDENTIALS')
110
+ account_b_path = os.getenv('ACCOUNT_B_CREDENTIALS_PATH')
111
+
112
+ if account_b_json:
113
+ self.account_b_auth = GoogleAuthenticator(credentials_json=account_b_json)
114
+ elif account_b_path:
115
+ self.account_b_auth = GoogleAuthenticator(credentials_path=account_b_path)
116
+ else:
117
+ logger.warning("Account B credentials not found in environment")
118
+
119
+ def get_account_a_credentials(self):
120
+ """
121
+ Get credentials for Account A (Google Docs)
122
+
123
+ Returns:
124
+ google.auth.credentials.Credentials
125
+ """
126
+ if not self.account_a_auth:
127
+ raise ValueError("Account A not authenticated. Check ACCOUNT_A_CREDENTIALS env var")
128
+ return self.account_a_auth.get_credentials()
129
+
130
+ def get_account_b_credentials(self):
131
+ """
132
+ Get credentials for Account B (Obsidian Vault)
133
+
134
+ Returns:
135
+ google.auth.credentials.Credentials
136
+ """
137
+ if not self.account_b_auth:
138
+ raise ValueError("Account B not authenticated. Check ACCOUNT_B_CREDENTIALS env var")
139
+ return self.account_b_auth.get_credentials()
140
+
141
+ def is_authenticated(self) -> bool:
142
+ """Return True if the primary Google Docs account (account A) is configured."""
143
+ return self.account_a_auth is not None
@@ -0,0 +1,140 @@
1
+ """Google Docs backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import pathlib
7
+ from typing import TYPE_CHECKING
8
+
9
+ from docspan.backends.base import Backend, PullResult, PushResult
10
+ from docspan.backends.google_docs.auth import DualAccountAuth, GoogleAuthenticator
11
+ from docspan.backends.google_docs.client import GoogleDocsClient
12
+ from docspan.backends.google_docs.converter import DocumentConverter
13
+ from docspan.backends.google_docs.docs_request_builder import DocsRequestBuilder
14
+ from docspan.backends.google_docs.docs_structure_parser import DocsStructureParser
15
+ from docspan.backends.google_docs.markdown_to_paragraph_parser import MarkdownToParagraphParser
16
+
17
+ if TYPE_CHECKING:
18
+ from docspan.config import GoogleDocsConfig, MarkgateConfig
19
+
20
+
21
+ class GoogleDocsBackend(Backend):
22
+ name = "google_docs"
23
+
24
+ def __init__(self, config: "GoogleDocsConfig") -> None:
25
+ self.config = config
26
+ self._client: GoogleDocsClient | None = None
27
+
28
+ @classmethod
29
+ def from_config(cls, markgate_config: "MarkgateConfig") -> "GoogleDocsBackend":
30
+ from docspan.config import GoogleDocsConfig
31
+ return cls(markgate_config.backends.google_docs or GoogleDocsConfig())
32
+
33
+ def _ensure_client(self) -> None:
34
+ if self._client is not None:
35
+ return
36
+ if self.config.credentials_path:
37
+ auth = GoogleAuthenticator(credentials_path=self.config.credentials_path)
38
+ self._client = GoogleDocsClient(auth.get_credentials())
39
+ else:
40
+ dual = DualAccountAuth()
41
+ if not dual.is_authenticated():
42
+ raise RuntimeError(
43
+ "Google Docs credentials not found. "
44
+ "Set credentials_path in markgate.yaml or ACCOUNT_A_CREDENTIALS_PATH env var. "
45
+ "Run: docspan auth setup google_docs"
46
+ )
47
+ self._client = GoogleDocsClient(dual.get_account_a_credentials())
48
+
49
+ def push(self, local_path: str, doc_id: str, **kwargs: object) -> PushResult:
50
+ """Convert local markdown to Google Docs format using structural diff and batch update."""
51
+ self._ensure_client()
52
+ assert self._client is not None
53
+ try:
54
+ content = pathlib.Path(local_path).read_text()
55
+
56
+ target_nodes = MarkdownToParagraphParser().parse(content)
57
+ doc = self._client.get_document(doc_id)
58
+ current_nodes = DocsStructureParser().parse(doc)
59
+
60
+ if "tabs" in doc and doc["tabs"]:
61
+ body_content = doc["tabs"][0].get("documentTab", doc).get("body", {}).get("content", [])
62
+ else:
63
+ body_content = doc.get("body", {}).get("content", [])
64
+ doc_end_index = body_content[-1].get("endIndex", 1) if body_content else 1
65
+
66
+ requests = DocsRequestBuilder().build(current_nodes, target_nodes, doc_end_index)
67
+ if not requests:
68
+ return PushResult(status="skipped", doc_id=doc_id, message="No changes detected")
69
+
70
+ self._client.batch_update(doc_id, requests)
71
+ url = f"https://docs.google.com/document/d/{doc_id}/edit"
72
+ return PushResult(status="ok", doc_id=doc_id, url=url)
73
+ except Exception as exc:
74
+ return PushResult(status="error", doc_id=doc_id, message=str(exc))
75
+
76
+ def pull(self, doc_id: str, local_path: str, **kwargs: object) -> PullResult:
77
+ """Export Google Doc as HTML, convert to markdown, write locally."""
78
+ self._ensure_client()
79
+ assert self._client is not None
80
+ try:
81
+ html_content = self._client.get_doc_content(doc_id)
82
+ markdown_content = DocumentConverter().html_to_markdown(html_content)
83
+ pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True)
84
+ pathlib.Path(local_path).write_text(markdown_content)
85
+ return PullResult(status="ok", doc_id=doc_id, local_path=local_path)
86
+ except Exception as exc:
87
+ return PullResult(status="error", doc_id=doc_id, local_path=local_path, message=str(exc))
88
+
89
+ def get_remote_version(self, doc_id: str) -> str:
90
+ """Return the revisionId of the Google Doc (opaque, non-empty string)."""
91
+ self._ensure_client()
92
+ assert self._client is not None
93
+ doc = self._client.get_document(doc_id)
94
+ return doc["revisionId"]
95
+
96
+ def auth_setup(self) -> None:
97
+ """Print setup instructions for Google Docs service account credentials."""
98
+ has_path = self.config.credentials_path or os.getenv("ACCOUNT_A_CREDENTIALS_PATH")
99
+ has_json = os.getenv("ACCOUNT_A_CREDENTIALS")
100
+
101
+ if has_path or has_json:
102
+ print("Google Docs credentials are already configured.")
103
+ try:
104
+ self._ensure_client()
105
+ print("✓ Connection verified successfully.")
106
+ except Exception as exc:
107
+ print(f"✗ Connection test failed: {exc}")
108
+ return
109
+
110
+ print("\nGoogle Docs Auth Setup")
111
+ print("=" * 40)
112
+ print("docspan uses Google service account credentials for Google Docs access.")
113
+ print("\nSetup steps:")
114
+ print(" 1. Create a service account at:")
115
+ print(" https://console.cloud.google.com/iam-admin/serviceaccounts")
116
+ print(" 2. Enable Google Docs API and Google Drive API in your project")
117
+ print(" 3. Download the service account JSON key file")
118
+ print(" 4. Share your Google Docs with the service account email")
119
+ print("\nConfigure credentials via one of:")
120
+ print(" Option A — YAML config:")
121
+ print(" backends:")
122
+ print(" google_docs:")
123
+ print(" credentials_path: /path/to/service-account.json")
124
+ print(" Option B — environment variable (path):")
125
+ print(" export ACCOUNT_A_CREDENTIALS_PATH=/path/to/service-account.json")
126
+ print(" Option C — environment variable (inline JSON):")
127
+ print(" export ACCOUNT_A_CREDENTIALS='{ ... service account JSON ... }'")
128
+
129
+ def validate_config(self) -> None:
130
+ has_credentials = (
131
+ self.config.credentials_path
132
+ or os.getenv("ACCOUNT_A_CREDENTIALS_PATH")
133
+ or os.getenv("ACCOUNT_A_CREDENTIALS")
134
+ )
135
+ if not has_credentials:
136
+ raise ValueError(
137
+ "Missing Google Docs credentials. "
138
+ "Set credentials_path in markgate.yaml or ACCOUNT_A_CREDENTIALS_PATH env var. "
139
+ "Run: docspan auth setup google_docs"
140
+ )