openalex-local 0.3.0__py3-none-any.whl → 0.3.1__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 (37) hide show
  1. openalex_local/__init__.py +28 -7
  2. openalex_local/_cache/__init__.py +45 -0
  3. openalex_local/_cache/core.py +298 -0
  4. openalex_local/_cache/export.py +100 -0
  5. openalex_local/_cache/models.py +17 -0
  6. openalex_local/_cache/utils.py +85 -0
  7. openalex_local/_cli/__init__.py +9 -0
  8. openalex_local/_cli/cli.py +409 -0
  9. openalex_local/_cli/cli_cache.py +220 -0
  10. openalex_local/_cli/mcp.py +210 -0
  11. openalex_local/_cli/mcp_server.py +235 -0
  12. openalex_local/_core/__init__.py +42 -0
  13. openalex_local/{api.py → _core/api.py} +137 -19
  14. openalex_local/_core/config.py +120 -0
  15. openalex_local/{db.py → _core/db.py} +53 -0
  16. openalex_local/_core/export.py +252 -0
  17. openalex_local/{models.py → _core/models.py} +201 -0
  18. openalex_local/_remote/__init__.py +34 -0
  19. openalex_local/_remote/base.py +256 -0
  20. openalex_local/_server/__init__.py +117 -0
  21. openalex_local/_server/routes.py +175 -0
  22. openalex_local/aio.py +259 -0
  23. openalex_local/cache.py +31 -0
  24. openalex_local/cli.py +4 -205
  25. openalex_local/jobs.py +169 -0
  26. openalex_local/remote.py +8 -0
  27. openalex_local/server.py +8 -0
  28. openalex_local-0.3.1.dist-info/METADATA +288 -0
  29. openalex_local-0.3.1.dist-info/RECORD +34 -0
  30. openalex_local-0.3.1.dist-info/entry_points.txt +2 -0
  31. openalex_local/config.py +0 -182
  32. openalex_local-0.3.0.dist-info/METADATA +0 -152
  33. openalex_local-0.3.0.dist-info/RECORD +0 -13
  34. openalex_local-0.3.0.dist-info/entry_points.txt +0 -2
  35. /openalex_local/{fts.py → _core/fts.py} +0 -0
  36. {openalex_local-0.3.0.dist-info → openalex_local-0.3.1.dist-info}/WHEEL +0 -0
  37. {openalex_local-0.3.0.dist-info → openalex_local-0.3.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ # Timestamp: 2026-01-29
3
+ """Configuration for openalex_local."""
4
+
5
+ import os as _os
6
+ from pathlib import Path as _Path
7
+ from typing import Optional as _Optional
8
+
9
+ # Default database locations (checked in order)
10
+ DEFAULT_DB_PATHS = [
11
+ _Path("/home/ywatanabe/proj/openalex-local/data/openalex.db"),
12
+ _Path("/home/ywatanabe/proj/openalex_local/data/openalex.db"),
13
+ _Path("/mnt/nas_ug/openalex_local/data/openalex.db"),
14
+ _Path.home() / ".openalex_local" / "openalex.db",
15
+ _Path.cwd() / "data" / "openalex.db",
16
+ ]
17
+
18
+
19
+ def get_db_path() -> _Path:
20
+ """Get database path from environment or auto-detect."""
21
+ env_path = _os.environ.get("OPENALEX_LOCAL_DB")
22
+ if env_path:
23
+ path = _Path(env_path)
24
+ if path.exists():
25
+ return path
26
+ raise FileNotFoundError(f"OPENALEX_LOCAL_DB path not found: {env_path}")
27
+
28
+ for path in DEFAULT_DB_PATHS:
29
+ if path.exists():
30
+ return path
31
+
32
+ raise FileNotFoundError(
33
+ "OpenAlex database not found. Set OPENALEX_LOCAL_DB environment variable."
34
+ )
35
+
36
+
37
+ DEFAULT_PORT = 31292
38
+ DEFAULT_HOST = "0.0.0.0"
39
+
40
+
41
+ class Config:
42
+ """Configuration container."""
43
+
44
+ _db_path: _Optional[_Path] = None
45
+ _api_url: _Optional[str] = None
46
+ _mode: str = "auto" # "auto", "db", or "http"
47
+
48
+ @classmethod
49
+ def get_db_path(cls) -> _Path:
50
+ if cls._db_path is None:
51
+ cls._db_path = get_db_path()
52
+ return cls._db_path
53
+
54
+ @classmethod
55
+ def set_db_path(cls, path: str) -> None:
56
+ p = _Path(path)
57
+ if not p.exists():
58
+ raise FileNotFoundError(f"Database not found: {path}")
59
+ cls._db_path = p
60
+ cls._mode = "db"
61
+
62
+ @classmethod
63
+ def get_api_url(cls) -> str:
64
+ if cls._api_url:
65
+ return cls._api_url
66
+ return _os.environ.get(
67
+ "OPENALEX_LOCAL_API_URL", f"http://localhost:{DEFAULT_PORT}"
68
+ )
69
+
70
+ @classmethod
71
+ def set_api_url(cls, url: str) -> None:
72
+ cls._api_url = url.rstrip("/")
73
+ cls._mode = "http"
74
+
75
+ @classmethod
76
+ def set_mode(cls, mode: str) -> None:
77
+ """Set mode explicitly: 'db', 'http', or 'auto'."""
78
+ if mode not in ("auto", "db", "http"):
79
+ raise ValueError(f"Invalid mode: {mode}. Use 'auto', 'db', or 'http'")
80
+ cls._mode = mode
81
+
82
+ @classmethod
83
+ def get_mode(cls) -> str:
84
+ """
85
+ Get current mode.
86
+
87
+ Returns:
88
+ "db" if using direct database access
89
+ "http" if using HTTP API
90
+ """
91
+ if cls._mode == "auto":
92
+ # Check environment variable for explicit mode
93
+ env_mode = _os.environ.get("OPENALEX_LOCAL_MODE", "").lower()
94
+ if env_mode in ("http", "remote", "api"):
95
+ return "http"
96
+ if env_mode in ("db", "local"):
97
+ return "db"
98
+
99
+ # Check if API URL is set explicitly
100
+ if cls._api_url or _os.environ.get("OPENALEX_LOCAL_API_URL"):
101
+ return "http"
102
+
103
+ # Check if local database exists
104
+ try:
105
+ get_db_path()
106
+ return "db"
107
+ except FileNotFoundError:
108
+ # No local DB, try http
109
+ return "http"
110
+
111
+ return cls._mode
112
+
113
+ @classmethod
114
+ def reset(cls) -> None:
115
+ cls._db_path = None
116
+ cls._api_url = None
117
+ cls._mode = "auto"
118
+
119
+
120
+ # EOF
@@ -120,6 +120,59 @@ class Database:
120
120
 
121
121
  return result
122
122
 
123
+ def get_source_metrics(self, issn: str) -> Optional[Dict[str, Any]]:
124
+ """
125
+ Get source/journal metrics by ISSN.
126
+
127
+ Args:
128
+ issn: Journal ISSN
129
+
130
+ Returns:
131
+ Dictionary with impact_factor, h_index, cited_by_count or None
132
+ """
133
+ if not issn:
134
+ return None
135
+
136
+ # Try lookup via issn_lookup table first (fast)
137
+ row = self.fetchone(
138
+ """
139
+ SELECT s.two_year_mean_citedness as impact_factor,
140
+ s.h_index as source_h_index,
141
+ s.cited_by_count as source_cited_by_count,
142
+ s.display_name as source_name
143
+ FROM issn_lookup l
144
+ JOIN sources s ON l.source_id = s.id
145
+ WHERE l.issn = ?
146
+ """,
147
+ (issn,),
148
+ )
149
+ if row:
150
+ return dict(row)
151
+
152
+ # Fallback: search in sources.issns JSON field
153
+ row = self.fetchone(
154
+ """
155
+ SELECT two_year_mean_citedness as impact_factor,
156
+ h_index as source_h_index,
157
+ cited_by_count as source_cited_by_count,
158
+ display_name as source_name
159
+ FROM sources
160
+ WHERE issn_l = ? OR issns LIKE ?
161
+ """,
162
+ (issn, f'%"{issn}"%'),
163
+ )
164
+ if row:
165
+ return dict(row)
166
+
167
+ return None
168
+
169
+ def has_sources_table(self) -> bool:
170
+ """Check if sources table exists."""
171
+ row = self.fetchone(
172
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='sources'"
173
+ )
174
+ return row is not None
175
+
123
176
 
124
177
  # Singleton connection for convenience functions
125
178
  _db: Optional[Database] = None
@@ -0,0 +1,252 @@
1
+ """Export functionality for Work and SearchResult objects.
2
+
3
+ Supports multiple output formats:
4
+ - text: Human-readable formatted text
5
+ - json: JSON format with all fields
6
+ - bibtex: BibTeX bibliography format
7
+ """
8
+
9
+ import json as _json
10
+ from pathlib import Path as _Path
11
+ from typing import TYPE_CHECKING, List, Optional, Union
12
+
13
+ if TYPE_CHECKING:
14
+ from .models import SearchResult, Work
15
+
16
+ __all__ = [
17
+ "save",
18
+ "export_text",
19
+ "export_json",
20
+ "export_bibtex",
21
+ "SUPPORTED_FORMATS",
22
+ ]
23
+
24
+ SUPPORTED_FORMATS = ["text", "json", "bibtex"]
25
+
26
+
27
+ def work_to_text(work: "Work", include_abstract: bool = False) -> str:
28
+ """Convert a Work to human-readable text format.
29
+
30
+ Args:
31
+ work: Work object to convert
32
+ include_abstract: Whether to include abstract
33
+
34
+ Returns:
35
+ Formatted text string
36
+ """
37
+ lines = []
38
+
39
+ # Title
40
+ title = work.title or "Untitled"
41
+ year = f"({work.year})" if work.year else ""
42
+ lines.append(f"{title} {year}".strip())
43
+
44
+ # Authors
45
+ if work.authors:
46
+ authors_str = ", ".join(work.authors[:5])
47
+ if len(work.authors) > 5:
48
+ authors_str += f" et al. ({len(work.authors)} authors)"
49
+ lines.append(f"Authors: {authors_str}")
50
+
51
+ # Journal and identifiers
52
+ if work.source:
53
+ source_line = f"Journal: {work.source}"
54
+ if work.volume:
55
+ source_line += f", {work.volume}"
56
+ if work.issue:
57
+ source_line += f"({work.issue})"
58
+ if work.pages:
59
+ source_line += f", {work.pages}"
60
+ lines.append(source_line)
61
+
62
+ if work.doi:
63
+ lines.append(f"DOI: {work.doi}")
64
+
65
+ lines.append(f"OpenAlex ID: {work.openalex_id}")
66
+
67
+ # Citation count
68
+ if work.cited_by_count is not None:
69
+ lines.append(f"Citations: {work.cited_by_count}")
70
+
71
+ # Open access
72
+ if work.is_oa:
73
+ lines.append(f"Open Access: {work.oa_url or 'Yes'}")
74
+
75
+ # Abstract
76
+ if include_abstract and work.abstract:
77
+ lines.append(f"Abstract: {work.abstract}")
78
+
79
+ return "\n".join(lines)
80
+
81
+
82
+ def export_text(
83
+ works: List["Work"],
84
+ include_abstract: bool = False,
85
+ query: Optional[str] = None,
86
+ total: Optional[int] = None,
87
+ elapsed_ms: Optional[float] = None,
88
+ ) -> str:
89
+ """Export works to text format.
90
+
91
+ Args:
92
+ works: List of Work objects
93
+ include_abstract: Whether to include abstracts
94
+ query: Original search query (for header)
95
+ total: Total number of matches
96
+ elapsed_ms: Search time in milliseconds
97
+
98
+ Returns:
99
+ Formatted text string
100
+ """
101
+ lines = []
102
+
103
+ # Header
104
+ if query is not None:
105
+ lines.append(f"Search: {query}")
106
+ if total is not None:
107
+ lines.append(f"Found: {total:,} matches")
108
+ if elapsed_ms is not None:
109
+ lines.append(f"Time: {elapsed_ms:.1f}ms")
110
+ lines.append("")
111
+ lines.append("=" * 60)
112
+ lines.append("")
113
+
114
+ # Works
115
+ for i, work in enumerate(works, 1):
116
+ lines.append(f"[{i}]")
117
+ lines.append(work_to_text(work, include_abstract=include_abstract))
118
+ lines.append("")
119
+ lines.append("-" * 40)
120
+ lines.append("")
121
+
122
+ return "\n".join(lines)
123
+
124
+
125
+ def export_json(
126
+ works: List["Work"],
127
+ query: Optional[str] = None,
128
+ total: Optional[int] = None,
129
+ elapsed_ms: Optional[float] = None,
130
+ indent: int = 2,
131
+ ) -> str:
132
+ """Export works to JSON format.
133
+
134
+ Args:
135
+ works: List of Work objects
136
+ query: Original search query
137
+ total: Total number of matches
138
+ elapsed_ms: Search time in milliseconds
139
+ indent: JSON indentation
140
+
141
+ Returns:
142
+ JSON string
143
+ """
144
+ data = {
145
+ "works": [w.to_dict() for w in works],
146
+ }
147
+
148
+ if query is not None:
149
+ data["query"] = query
150
+ if total is not None:
151
+ data["total"] = total
152
+ if elapsed_ms is not None:
153
+ data["elapsed_ms"] = elapsed_ms
154
+
155
+ return _json.dumps(data, indent=indent, ensure_ascii=False)
156
+
157
+
158
+ def export_bibtex(works: List["Work"]) -> str:
159
+ """Export works to BibTeX format.
160
+
161
+ Args:
162
+ works: List of Work objects
163
+
164
+ Returns:
165
+ BibTeX string with all entries
166
+ """
167
+ entries = [w.citation("bibtex") for w in works]
168
+ return "\n\n".join(entries)
169
+
170
+
171
+ def save(
172
+ data: Union["Work", "SearchResult", List["Work"]],
173
+ path: Union[str, _Path],
174
+ format: str = "json",
175
+ include_abstract: bool = True,
176
+ ) -> str:
177
+ """Save Work(s) or SearchResult to a file.
178
+
179
+ Args:
180
+ data: Work, SearchResult, or list of Works to save
181
+ path: Output file path
182
+ format: Output format ("text", "json", "bibtex")
183
+ include_abstract: Include abstracts in text format
184
+
185
+ Returns:
186
+ Path to saved file
187
+
188
+ Raises:
189
+ ValueError: If format is not supported
190
+
191
+ Examples:
192
+ >>> from openalex_local import search, save
193
+ >>> results = search("machine learning", limit=10)
194
+ >>> save(results, "results.json")
195
+ >>> save(results, "results.bib", format="bibtex")
196
+ >>> save(results, "results.txt", format="text")
197
+ """
198
+ from .models import SearchResult, Work
199
+
200
+ if format not in SUPPORTED_FORMATS:
201
+ raise ValueError(
202
+ f"Unsupported format: {format}. "
203
+ f"Supported formats: {', '.join(SUPPORTED_FORMATS)}"
204
+ )
205
+
206
+ path = _Path(path)
207
+
208
+ # Extract works and metadata
209
+ if isinstance(data, Work):
210
+ works = [data]
211
+ query = None
212
+ total = None
213
+ elapsed_ms = None
214
+ elif isinstance(data, SearchResult):
215
+ works = data.works
216
+ query = data.query
217
+ total = data.total
218
+ elapsed_ms = data.elapsed_ms
219
+ elif isinstance(data, list):
220
+ works = data
221
+ query = None
222
+ total = len(data)
223
+ elapsed_ms = None
224
+ else:
225
+ raise TypeError(f"Unsupported data type: {type(data)}")
226
+
227
+ # Generate content
228
+ if format == "text":
229
+ content = export_text(
230
+ works,
231
+ include_abstract=include_abstract,
232
+ query=query,
233
+ total=total,
234
+ elapsed_ms=elapsed_ms,
235
+ )
236
+ elif format == "json":
237
+ content = export_json(
238
+ works,
239
+ query=query,
240
+ total=total,
241
+ elapsed_ms=elapsed_ms,
242
+ )
243
+ elif format == "bibtex":
244
+ content = export_bibtex(works)
245
+ else:
246
+ raise ValueError(f"Unsupported format: {format}")
247
+
248
+ # Write to file
249
+ path.parent.mkdir(parents=True, exist_ok=True)
250
+ path.write_text(content, encoding="utf-8")
251
+
252
+ return str(path)
@@ -50,6 +50,10 @@ class Work:
50
50
  referenced_works: List[str] = field(default_factory=list)
51
51
  is_oa: bool = False
52
52
  oa_url: Optional[str] = None
53
+ # Source/journal metrics (from sources table)
54
+ impact_factor: Optional[float] = None # 2yr_mean_citedness
55
+ source_h_index: Optional[int] = None
56
+ source_cited_by_count: Optional[int] = None
53
57
 
54
58
  @classmethod
55
59
  def from_openalex(cls, data: dict) -> "Work":
@@ -177,6 +181,9 @@ class Work:
177
181
  referenced_works=data.get("referenced_works", []),
178
182
  is_oa=bool(data.get("is_oa", False)),
179
183
  oa_url=data.get("oa_url"),
184
+ impact_factor=data.get("impact_factor"),
185
+ source_h_index=data.get("source_h_index"),
186
+ source_cited_by_count=data.get("source_cited_by_count"),
180
187
  )
181
188
 
182
189
  def to_dict(self) -> dict:
@@ -201,8 +208,179 @@ class Work:
201
208
  "referenced_works": self.referenced_works,
202
209
  "is_oa": self.is_oa,
203
210
  "oa_url": self.oa_url,
211
+ "impact_factor": self.impact_factor,
212
+ "source_h_index": self.source_h_index,
213
+ "source_cited_by_count": self.source_cited_by_count,
204
214
  }
205
215
 
216
+ def citation(self, style: str = "apa") -> str:
217
+ """
218
+ Format work as a citation string.
219
+
220
+ Args:
221
+ style: Citation style - "apa" (default) or "bibtex"
222
+
223
+ Returns:
224
+ Formatted citation string
225
+
226
+ Example:
227
+ >>> work.citation() # APA format
228
+ 'Piwowar, H., & Priem, J. (2018). The state of OA. PeerJ.'
229
+ >>> work.citation("bibtex") # BibTeX format
230
+ '@article{W2741809807, title={The state of OA}, ...}'
231
+ """
232
+ if style.lower() == "bibtex":
233
+ return self._citation_bibtex()
234
+ return self._citation_apa()
235
+
236
+ def _citation_apa(self) -> str:
237
+ """Format as APA citation."""
238
+ parts = []
239
+
240
+ # Authors
241
+ if self.authors:
242
+ if len(self.authors) == 1:
243
+ parts.append(self._format_author_apa(self.authors[0]))
244
+ elif len(self.authors) == 2:
245
+ parts.append(
246
+ f"{self._format_author_apa(self.authors[0])} & "
247
+ f"{self._format_author_apa(self.authors[1])}"
248
+ )
249
+ else:
250
+ formatted = [self._format_author_apa(a) for a in self.authors[:19]]
251
+ if len(self.authors) > 20:
252
+ formatted = (
253
+ formatted[:19]
254
+ + ["..."]
255
+ + [self._format_author_apa(self.authors[-1])]
256
+ )
257
+ parts.append(", ".join(formatted[:-1]) + ", & " + formatted[-1])
258
+
259
+ # Year
260
+ if self.year:
261
+ parts.append(f"({self.year})")
262
+
263
+ # Title
264
+ if self.title:
265
+ parts.append(f"{self.title}.")
266
+
267
+ # Source (journal)
268
+ if self.source:
269
+ source_part = f"*{self.source}*"
270
+ if self.volume:
271
+ source_part += f", *{self.volume}*"
272
+ if self.issue:
273
+ source_part += f"({self.issue})"
274
+ if self.pages:
275
+ source_part += f", {self.pages}"
276
+ source_part += "."
277
+ parts.append(source_part)
278
+
279
+ # DOI
280
+ if self.doi:
281
+ parts.append(f"https://doi.org/{self.doi}")
282
+
283
+ return " ".join(parts)
284
+
285
+ def _format_author_apa(self, name: str) -> str:
286
+ """Format author name for APA (Last, F. M.)."""
287
+ parts = name.split()
288
+ if len(parts) == 1:
289
+ return parts[0]
290
+ last = parts[-1]
291
+ initials = " ".join(f"{p[0]}." for p in parts[:-1] if p)
292
+ return f"{last}, {initials}"
293
+
294
+ def _citation_bibtex(self) -> str:
295
+ """Format as BibTeX entry."""
296
+ # Determine entry type
297
+ entry_type = "article"
298
+ if self.type:
299
+ type_map = {
300
+ "book": "book",
301
+ "book-chapter": "incollection",
302
+ "proceedings": "inproceedings",
303
+ "proceedings-article": "inproceedings",
304
+ "dissertation": "phdthesis",
305
+ "report": "techreport",
306
+ }
307
+ entry_type = type_map.get(self.type, "article")
308
+
309
+ # Use OpenAlex ID as citation key
310
+ key = self.openalex_id or "unknown"
311
+
312
+ lines = [f"@{entry_type}{{{key},"]
313
+
314
+ if self.title:
315
+ lines.append(f" title = {{{self.title}}},")
316
+
317
+ if self.authors:
318
+ author_str = " and ".join(self.authors)
319
+ lines.append(f" author = {{{author_str}}},")
320
+
321
+ if self.year:
322
+ lines.append(f" year = {{{self.year}}},")
323
+
324
+ if self.source:
325
+ if entry_type == "article":
326
+ lines.append(f" journal = {{{self.source}}},")
327
+ elif entry_type in ("incollection", "inproceedings"):
328
+ lines.append(f" booktitle = {{{self.source}}},")
329
+
330
+ if self.volume:
331
+ lines.append(f" volume = {{{self.volume}}},")
332
+
333
+ if self.issue:
334
+ lines.append(f" number = {{{self.issue}}},")
335
+
336
+ if self.pages:
337
+ lines.append(f" pages = {{{self.pages}}},")
338
+
339
+ if self.publisher:
340
+ lines.append(f" publisher = {{{self.publisher}}},")
341
+
342
+ if self.doi:
343
+ lines.append(f" doi = {{{self.doi}}},")
344
+
345
+ if self.oa_url:
346
+ lines.append(f" url = {{{self.oa_url}}},")
347
+
348
+ lines.append("}")
349
+
350
+ return "\n".join(lines)
351
+
352
+ def to_text(self, include_abstract: bool = False) -> str:
353
+ """Format as human-readable text.
354
+
355
+ Args:
356
+ include_abstract: Include abstract in output
357
+
358
+ Returns:
359
+ Formatted text string
360
+ """
361
+ from .export import work_to_text
362
+
363
+ return work_to_text(self, include_abstract=include_abstract)
364
+
365
+ def save(self, path: str, format: str = "json") -> str:
366
+ """Save work to file.
367
+
368
+ Args:
369
+ path: Output file path
370
+ format: Output format ("text", "json", "bibtex")
371
+
372
+ Returns:
373
+ Path to saved file
374
+
375
+ Examples:
376
+ >>> work = get("W2741809807")
377
+ >>> work.save("paper.json")
378
+ >>> work.save("paper.bib", format="bibtex")
379
+ """
380
+ from .export import save
381
+
382
+ return save(self, path, format=format)
383
+
206
384
 
207
385
  @dataclass
208
386
  class SearchResult:
@@ -229,3 +407,26 @@ class SearchResult:
229
407
 
230
408
  def __getitem__(self, idx):
231
409
  return self.works[idx]
410
+
411
+ def save(
412
+ self, path: str, format: str = "json", include_abstract: bool = True
413
+ ) -> str:
414
+ """Save search results to file.
415
+
416
+ Args:
417
+ path: Output file path
418
+ format: Output format ("text", "json", "bibtex")
419
+ include_abstract: Include abstracts in text format
420
+
421
+ Returns:
422
+ Path to saved file
423
+
424
+ Examples:
425
+ >>> results = search("machine learning", limit=10)
426
+ >>> results.save("results.json")
427
+ >>> results.save("results.bib", format="bibtex")
428
+ >>> results.save("results.txt", format="text")
429
+ """
430
+ from .export import save
431
+
432
+ return save(self, path, format=format, include_abstract=include_abstract)
@@ -0,0 +1,34 @@
1
+ """Remote API client for openalex_local.
2
+
3
+ Connects to an OpenAlex Local API server instead of direct database access.
4
+ Use this when the database is on a remote server accessible via HTTP.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ from .base import RemoteClient, DEFAULT_API_URL
10
+
11
+ # Module-level client singleton
12
+ _client: Optional[RemoteClient] = None
13
+
14
+
15
+ def get_client(base_url: str = DEFAULT_API_URL) -> RemoteClient:
16
+ """Get or create singleton remote client."""
17
+ global _client
18
+ if _client is None or _client.base_url != base_url:
19
+ _client = RemoteClient(base_url)
20
+ return _client
21
+
22
+
23
+ def reset_client() -> None:
24
+ """Reset singleton client."""
25
+ global _client
26
+ _client = None
27
+
28
+
29
+ __all__ = [
30
+ "RemoteClient",
31
+ "DEFAULT_API_URL",
32
+ "get_client",
33
+ "reset_client",
34
+ ]