rpy-bridge 0.3.8__tar.gz → 0.4.0__tar.gz
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.
- {rpy_bridge-0.3.8/src/rpy_bridge.egg-info → rpy_bridge-0.4.0}/PKG-INFO +5 -3
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/README.md +4 -2
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/pyproject.toml +1 -1
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/src/rpy_bridge/rpy2_utils.py +218 -77
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0/src/rpy_bridge.egg-info}/PKG-INFO +5 -3
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/LICENSE +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/README.rst +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/setup.cfg +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/src/rpy_bridge/__init__.py +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/src/rpy_bridge/py.typed +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/src/rpy_bridge.egg-info/SOURCES.txt +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/src/rpy_bridge.egg-info/dependency_links.txt +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/src/rpy_bridge.egg-info/requires.txt +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/src/rpy_bridge.egg-info/top_level.txt +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/tests/test_package_call.py +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/tests/test_py2r.py +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/tests/test_roundtrip.py +0 -0
- {rpy_bridge-0.3.8 → rpy_bridge-0.4.0}/tests/test_wrapper.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rpy-bridge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python-to-R interoperability engine with environment management, type-safe conversions, data normalization, and safe R function execution.
|
|
5
5
|
Author-email: Victoria Cheung <victoriakcheung@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -114,14 +114,16 @@ It enables Python developers to call R functions, scripts, and packages safely w
|
|
|
114
114
|
|
|
115
115
|
**From PyPI:**
|
|
116
116
|
|
|
117
|
+
Install rpy-bridge with rpy2 for full R support
|
|
118
|
+
|
|
117
119
|
```bash
|
|
118
|
-
python3 -m pip install rpy-bridge
|
|
120
|
+
python3 -m pip install rpy-bridge rpy2
|
|
119
121
|
```
|
|
120
122
|
|
|
121
123
|
or using `uv`:
|
|
122
124
|
|
|
123
125
|
```bash
|
|
124
|
-
uv add rpy-bridge
|
|
126
|
+
uv add rpy-bridge rpy2
|
|
125
127
|
```
|
|
126
128
|
|
|
127
129
|
**During development (editable install):**
|
|
@@ -55,14 +55,16 @@ It enables Python developers to call R functions, scripts, and packages safely w
|
|
|
55
55
|
|
|
56
56
|
**From PyPI:**
|
|
57
57
|
|
|
58
|
+
Install rpy-bridge with rpy2 for full R support
|
|
59
|
+
|
|
58
60
|
```bash
|
|
59
|
-
python3 -m pip install rpy-bridge
|
|
61
|
+
python3 -m pip install rpy-bridge rpy2
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
or using `uv`:
|
|
63
65
|
|
|
64
66
|
```bash
|
|
65
|
-
uv add rpy-bridge
|
|
67
|
+
uv add rpy-bridge rpy2
|
|
66
68
|
```
|
|
67
69
|
|
|
68
70
|
**During development (editable install):**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "rpy-bridge"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Python-to-R interoperability engine with environment management, type-safe conversions, data normalization, and safe R function execution."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -146,18 +146,14 @@ def find_r_home() -> str | None:
|
|
|
146
146
|
|
|
147
147
|
|
|
148
148
|
# Determine if we're running in CI / testing
|
|
149
|
-
CI_TESTING = (
|
|
150
|
-
os.environ.get("GITHUB_ACTIONS") == "true" or os.environ.get("TESTING") == "1"
|
|
151
|
-
)
|
|
149
|
+
CI_TESTING = os.environ.get("GITHUB_ACTIONS") == "true" or os.environ.get("TESTING") == "1"
|
|
152
150
|
|
|
153
151
|
R_HOME = os.environ.get("R_HOME")
|
|
154
152
|
if not R_HOME:
|
|
155
153
|
R_HOME = find_r_home()
|
|
156
154
|
if not R_HOME:
|
|
157
155
|
if CI_TESTING:
|
|
158
|
-
logger.warning(
|
|
159
|
-
"R not found; skipping all R-dependent setup in CI/testing environment."
|
|
160
|
-
)
|
|
156
|
+
logger.warning("R not found; skipping all R-dependent setup in CI/testing environment.")
|
|
161
157
|
R_HOME = None # Explicitly None to signal "no R available"
|
|
162
158
|
else:
|
|
163
159
|
raise RuntimeError("R not found. Please install R or add it to PATH.")
|
|
@@ -174,7 +170,7 @@ if R_HOME:
|
|
|
174
170
|
lib_path = os.path.join(R_HOME, "lib")
|
|
175
171
|
if lib_path not in os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", ""):
|
|
176
172
|
os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = (
|
|
177
|
-
f"{lib_path}:{os.environ.get('DYLD_FALLBACK_LIBRARY_PATH','')}"
|
|
173
|
+
f"{lib_path}:{os.environ.get('DYLD_FALLBACK_LIBRARY_PATH', '')}"
|
|
178
174
|
)
|
|
179
175
|
|
|
180
176
|
elif sys.platform.startswith("linux"):
|
|
@@ -234,7 +230,7 @@ def _require_rpy2(raise_on_missing: bool = True) -> dict | None:
|
|
|
234
230
|
except ImportError as e:
|
|
235
231
|
if raise_on_missing:
|
|
236
232
|
raise RuntimeError(
|
|
237
|
-
"R support requires
|
|
233
|
+
"R support requires rpy2; install it in your Python env (e.g., pip install rpy2)"
|
|
238
234
|
) from e
|
|
239
235
|
return None
|
|
240
236
|
|
|
@@ -247,6 +243,46 @@ def _ensure_rpy2() -> dict:
|
|
|
247
243
|
return _RPY2
|
|
248
244
|
|
|
249
245
|
|
|
246
|
+
# ---------------------------------------------------------------------
|
|
247
|
+
# Project root discovery (for this.path / working dir)
|
|
248
|
+
# ---------------------------------------------------------------------
|
|
249
|
+
def _candidate_project_dirs(base: Path, depth: int = 3) -> list[Path]:
|
|
250
|
+
return [base] + list(base.parents)[:depth]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _has_root_marker(path: Path) -> bool:
|
|
254
|
+
if (path / ".git").exists():
|
|
255
|
+
return True
|
|
256
|
+
if any(path.glob("*.Rproj")):
|
|
257
|
+
return True
|
|
258
|
+
if (path / ".here").exists():
|
|
259
|
+
return True
|
|
260
|
+
if (path / "DESCRIPTION").exists():
|
|
261
|
+
return True
|
|
262
|
+
if (path / "renv.lock").exists():
|
|
263
|
+
return True
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _find_project_root(path_to_renv: Path | None, scripts: list[Path]) -> Path | None:
|
|
268
|
+
# Prefer roots discovered from script locations first; fall back to path_to_renv hints.
|
|
269
|
+
bases: list[Path] = []
|
|
270
|
+
if scripts:
|
|
271
|
+
bases.extend(_candidate_project_dirs(scripts[0].parent))
|
|
272
|
+
if path_to_renv is not None:
|
|
273
|
+
bases.extend(_candidate_project_dirs(path_to_renv))
|
|
274
|
+
|
|
275
|
+
seen = set()
|
|
276
|
+
for cand in bases:
|
|
277
|
+
c = cand.resolve()
|
|
278
|
+
if c in seen:
|
|
279
|
+
continue
|
|
280
|
+
seen.add(c)
|
|
281
|
+
if _has_root_marker(c):
|
|
282
|
+
return c
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
250
286
|
# ---------------------------------------------------------------------
|
|
251
287
|
# Activate renv
|
|
252
288
|
# ---------------------------------------------------------------------
|
|
@@ -254,19 +290,50 @@ def activate_renv(path_to_renv: Path) -> None:
|
|
|
254
290
|
r = _ensure_rpy2()
|
|
255
291
|
robjects = r["robjects"]
|
|
256
292
|
|
|
293
|
+
# Normalize and allow flexible layouts. Users may pass:
|
|
294
|
+
# - the project root (with renv.lock and renv/)
|
|
295
|
+
# - the renv directory itself
|
|
296
|
+
# - a script dir that sits beside or inside the project; we search upwards.
|
|
257
297
|
path_to_renv = path_to_renv.resolve()
|
|
258
|
-
if path_to_renv.name == "renv" and (path_to_renv / "activate.R").exists():
|
|
259
|
-
renv_dir = path_to_renv
|
|
260
|
-
project_dir = path_to_renv.parent
|
|
261
|
-
else:
|
|
262
|
-
renv_dir = path_to_renv / "renv"
|
|
263
|
-
project_dir = path_to_renv
|
|
264
298
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
299
|
+
def _candidates(base: Path) -> list[Path]:
|
|
300
|
+
# Search base, then parents up to 3 levels for renv assets
|
|
301
|
+
parents = [base] + list(base.parents)[:3]
|
|
302
|
+
return parents
|
|
303
|
+
|
|
304
|
+
project_dir = None
|
|
305
|
+
renv_dir = None
|
|
306
|
+
renv_activate = None
|
|
307
|
+
renv_lock = None
|
|
308
|
+
|
|
309
|
+
for cand in _candidates(path_to_renv):
|
|
310
|
+
# If the candidate *is* a renv dir with activate.R, treat its parent as project
|
|
311
|
+
cand_is_renv = cand.name == "renv" and (cand / "activate.R").exists()
|
|
312
|
+
if cand_is_renv:
|
|
313
|
+
rd = cand
|
|
314
|
+
pd = cand.parent
|
|
315
|
+
else:
|
|
316
|
+
rd = cand / "renv"
|
|
317
|
+
pd = cand
|
|
318
|
+
|
|
319
|
+
activate_path = rd / "activate.R"
|
|
320
|
+
lock_path = pd / "renv.lock"
|
|
321
|
+
if not lock_path.exists():
|
|
322
|
+
alt_lock = rd / "renv.lock"
|
|
323
|
+
if alt_lock.exists():
|
|
324
|
+
lock_path = alt_lock
|
|
325
|
+
|
|
326
|
+
if activate_path.exists() and lock_path.exists():
|
|
327
|
+
project_dir = pd
|
|
328
|
+
renv_dir = rd
|
|
329
|
+
renv_activate = activate_path
|
|
330
|
+
renv_lock = lock_path
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
if renv_dir is None or renv_activate is None or renv_lock is None:
|
|
334
|
+
raise FileNotFoundError(
|
|
335
|
+
f"[Error] renv environment incomplete: activate.R or renv.lock not found near {path_to_renv}"
|
|
336
|
+
)
|
|
270
337
|
|
|
271
338
|
renviron_file = project_dir / ".Renviron"
|
|
272
339
|
if renviron_file.is_file():
|
|
@@ -275,9 +342,22 @@ def activate_renv(path_to_renv: Path) -> None:
|
|
|
275
342
|
|
|
276
343
|
rprofile_file = project_dir / ".Rprofile"
|
|
277
344
|
if rprofile_file.is_file():
|
|
278
|
-
|
|
279
|
-
|
|
345
|
+
# Source .Rprofile from the project root so any relative paths (e.g. renv/activate.R)
|
|
346
|
+
# are resolved correctly even when the current R working directory is elsewhere.
|
|
347
|
+
try:
|
|
348
|
+
robjects.r(
|
|
349
|
+
f'old_wd <- getwd(); setwd("{project_dir.as_posix()}"); '
|
|
350
|
+
f"on.exit(setwd(old_wd), add = TRUE); "
|
|
351
|
+
f'source("{rprofile_file.as_posix()}")'
|
|
352
|
+
)
|
|
353
|
+
logger.info(f"[rpy-bridge] .Rprofile sourced: {rprofile_file}")
|
|
354
|
+
except Exception as e: # pragma: no cover - defensive fallback
|
|
355
|
+
logger.warning(
|
|
356
|
+
"[rpy-bridge] Failed to source .Rprofile; falling back to renv::activate(): %s",
|
|
357
|
+
e,
|
|
358
|
+
)
|
|
280
359
|
|
|
360
|
+
# If .Rprofile was absent or failed, ensure renv is loaded directly.
|
|
281
361
|
try:
|
|
282
362
|
robjects.r("suppressMessages(library(renv))")
|
|
283
363
|
except Exception:
|
|
@@ -287,6 +367,7 @@ def activate_renv(path_to_renv: Path) -> None:
|
|
|
287
367
|
)
|
|
288
368
|
robjects.r("library(renv)")
|
|
289
369
|
|
|
370
|
+
# Activate renv explicitly in case .Rprofile did not already do it (or failed).
|
|
290
371
|
robjects.r(f'renv::load("{project_dir.as_posix()}")')
|
|
291
372
|
logger.info(f"[rpy-bridge] renv environment loaded for project: {project_dir}")
|
|
292
373
|
|
|
@@ -356,9 +437,10 @@ class RFunctionCaller:
|
|
|
356
437
|
path_to_renv: str | Path | None = None,
|
|
357
438
|
scripts: str | Path | list[str | Path] | None = None,
|
|
358
439
|
packages: str | list[str] | None = None,
|
|
440
|
+
headless: bool = True,
|
|
441
|
+
skip_renv_if_no_r: bool = True,
|
|
359
442
|
**kwargs, # catch unexpected keywords
|
|
360
443
|
):
|
|
361
|
-
|
|
362
444
|
# Handle path_to_renv safely
|
|
363
445
|
if path_to_renv is not None:
|
|
364
446
|
if not isinstance(path_to_renv, Path):
|
|
@@ -380,9 +462,7 @@ class RFunctionCaller:
|
|
|
380
462
|
scripts = script_path_value
|
|
381
463
|
else:
|
|
382
464
|
# Both provided → prioritize scripts and ignore script_path
|
|
383
|
-
logger.warning(
|
|
384
|
-
"'script_path' ignored because 'scripts' argument is also provided."
|
|
385
|
-
)
|
|
465
|
+
logger.warning("'script_path' ignored because 'scripts' argument is also provided.")
|
|
386
466
|
|
|
387
467
|
self.scripts = _normalize_scripts(scripts)
|
|
388
468
|
|
|
@@ -399,6 +479,7 @@ class RFunctionCaller:
|
|
|
399
479
|
|
|
400
480
|
self.path_to_renv = path_to_renv.resolve() if path_to_renv else None
|
|
401
481
|
self._namespaces: dict[str, Any] = {}
|
|
482
|
+
self._namespace_roots: dict[str, Path] = {}
|
|
402
483
|
|
|
403
484
|
# Normalize scripts to a list
|
|
404
485
|
if scripts is None:
|
|
@@ -416,6 +497,10 @@ class RFunctionCaller:
|
|
|
416
497
|
else:
|
|
417
498
|
self.packages = packages
|
|
418
499
|
|
|
500
|
+
# Headless mode guards (avoid GUI probing in non-interactive runs)
|
|
501
|
+
self.headless = headless
|
|
502
|
+
self.skip_renv_if_no_r = skip_renv_if_no_r
|
|
503
|
+
|
|
419
504
|
# Lazy-loaded attributes
|
|
420
505
|
self._r = None
|
|
421
506
|
self.ro = None
|
|
@@ -434,6 +519,42 @@ class RFunctionCaller:
|
|
|
434
519
|
self._packages_loaded = False
|
|
435
520
|
self._scripts_loaded = [False] * len(self.scripts)
|
|
436
521
|
|
|
522
|
+
def _should_activate_renv(self) -> bool:
|
|
523
|
+
"""Determine if renv activation should run, honoring CI/override knobs."""
|
|
524
|
+
if not self.path_to_renv:
|
|
525
|
+
return False
|
|
526
|
+
|
|
527
|
+
# Explicit opt-out (e.g., CI jobs that only run pure-Python tests)
|
|
528
|
+
if os.environ.get("RPY_BRIDGE_SKIP_RENV") in {"1", "true", "TRUE"}:
|
|
529
|
+
logger.info("[rpy-bridge] Skipping renv activation: RPY_BRIDGE_SKIP_RENV set")
|
|
530
|
+
return False
|
|
531
|
+
|
|
532
|
+
# CI without R available: skip if allowed
|
|
533
|
+
if CI_TESTING and R_HOME is None and self.skip_renv_if_no_r:
|
|
534
|
+
logger.info("[rpy-bridge] Skipping renv activation in CI: R_HOME not detected")
|
|
535
|
+
return False
|
|
536
|
+
|
|
537
|
+
# Require R_HOME in non-CI runs
|
|
538
|
+
if R_HOME is None:
|
|
539
|
+
raise RuntimeError(
|
|
540
|
+
"R_HOME not detected; cannot activate renv. Install R or set R_HOME."
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
return True
|
|
544
|
+
|
|
545
|
+
def _ensure_headless_env(self) -> None:
|
|
546
|
+
"""Set defaults that prevent R GUI probing (e.g., this.path:::.gui_path)."""
|
|
547
|
+
if not self.headless:
|
|
548
|
+
return
|
|
549
|
+
defaults = {
|
|
550
|
+
"R_DEFAULT_DEVICE": "png",
|
|
551
|
+
"R_INTERACTIVE": "false",
|
|
552
|
+
"R_GUI_APP_VERSION": "0",
|
|
553
|
+
"RSTUDIO": "0",
|
|
554
|
+
}
|
|
555
|
+
for key, val in defaults.items():
|
|
556
|
+
os.environ.setdefault(key, val)
|
|
557
|
+
|
|
437
558
|
# -----------------------------------------------------------------
|
|
438
559
|
# Internal: lazy R loading
|
|
439
560
|
# -----------------------------------------------------------------
|
|
@@ -442,6 +563,9 @@ class RFunctionCaller:
|
|
|
442
563
|
Ensure R runtime is initialized and all configured R scripts
|
|
443
564
|
are sourced exactly once, in isolated environments.
|
|
444
565
|
"""
|
|
566
|
+
# Ensure headless-safe env before rpy2 initializes R
|
|
567
|
+
self._ensure_headless_env()
|
|
568
|
+
|
|
445
569
|
if self.robjects is None:
|
|
446
570
|
rpy2_dict = _ensure_rpy2()
|
|
447
571
|
self._RPY2 = rpy2_dict # cache in instance
|
|
@@ -457,8 +581,36 @@ class RFunctionCaller:
|
|
|
457
581
|
self.ListVector = rpy2_dict["ListVector"]
|
|
458
582
|
self.NamedList = rpy2_dict["NamedList"]
|
|
459
583
|
|
|
584
|
+
# Activate renv once if requested and allowed
|
|
585
|
+
if not self._renv_activated and self._should_activate_renv():
|
|
586
|
+
try:
|
|
587
|
+
activate_renv(self.path_to_renv)
|
|
588
|
+
self._renv_activated = True
|
|
589
|
+
logger.info(
|
|
590
|
+
f"[rpy-bridge.RFunctionCaller] renv activated for project: {self.path_to_renv}"
|
|
591
|
+
)
|
|
592
|
+
except Exception as e:
|
|
593
|
+
raise RuntimeError(f"Failed to activate renv at {self.path_to_renv}: {e}") from e
|
|
594
|
+
|
|
460
595
|
r = self.robjects.r
|
|
461
596
|
|
|
597
|
+
# Configure this.path to avoid GUI detection errors in embedded/headless R (e.g., rpy2)
|
|
598
|
+
try:
|
|
599
|
+
r('options(this.path.gui = "httpd")')
|
|
600
|
+
r("options(this.path.verbose = FALSE)")
|
|
601
|
+
# Patch this.path::.gui_path to avoid GUI detection errors in headless/rpy2 contexts.
|
|
602
|
+
r(
|
|
603
|
+
"""
|
|
604
|
+
if (requireNamespace("this.path", quietly = TRUE)) {
|
|
605
|
+
try({
|
|
606
|
+
assignInNamespace(".gui_path", function(...) "httpd", ns = "this.path")
|
|
607
|
+
}, silent = TRUE)
|
|
608
|
+
}
|
|
609
|
+
"""
|
|
610
|
+
)
|
|
611
|
+
except Exception:
|
|
612
|
+
pass
|
|
613
|
+
|
|
462
614
|
# Ensure required R package
|
|
463
615
|
self.ensure_r_package("withr")
|
|
464
616
|
|
|
@@ -494,21 +646,27 @@ class RFunctionCaller:
|
|
|
494
646
|
r("env <- new.env(parent=globalenv())")
|
|
495
647
|
r(f'script_path <- "{script_path.as_posix()}"')
|
|
496
648
|
|
|
649
|
+
# Determine a root for this script: prefer a discovered project root; else script dir.
|
|
650
|
+
script_root = _find_project_root(self.path_to_renv, [script_path])
|
|
651
|
+
# Prefer script-local roots; if none, fall back to script directory.
|
|
652
|
+
if script_root is None:
|
|
653
|
+
script_root = script_path.parent.resolve()
|
|
654
|
+
script_root_arg = f'"{script_root.as_posix()}"'
|
|
655
|
+
|
|
497
656
|
r(
|
|
498
|
-
"""
|
|
657
|
+
f"""
|
|
499
658
|
withr::with_dir(
|
|
500
|
-
|
|
501
|
-
sys.source(
|
|
659
|
+
{script_root_arg},
|
|
660
|
+
sys.source(script_path, envir=env, chdir = TRUE)
|
|
502
661
|
)
|
|
503
662
|
"""
|
|
504
663
|
)
|
|
505
664
|
|
|
506
665
|
env_obj = r("env")
|
|
507
666
|
self._namespaces[ns_name] = {
|
|
508
|
-
name: env_obj[name]
|
|
509
|
-
for name in env_obj.keys()
|
|
510
|
-
if callable(env_obj[name])
|
|
667
|
+
name: env_obj[name] for name in env_obj.keys() if callable(env_obj[name])
|
|
511
668
|
}
|
|
669
|
+
self._namespace_roots[ns_name] = script_root
|
|
512
670
|
|
|
513
671
|
logger.info(
|
|
514
672
|
f"[rpy-bridge.RFunctionCaller] Registered {len(self._namespaces[ns_name])} functions in namespace '{ns_name}'"
|
|
@@ -588,9 +746,7 @@ class RFunctionCaller:
|
|
|
588
746
|
logger.warning(f"Failed to list functions for package '{pkg}'")
|
|
589
747
|
return []
|
|
590
748
|
|
|
591
|
-
def list_all_functions(
|
|
592
|
-
self, include_packages: bool = False
|
|
593
|
-
) -> dict[str, list[str]]:
|
|
749
|
+
def list_all_functions(self, include_packages: bool = False) -> dict[str, list[str]]:
|
|
594
750
|
"""
|
|
595
751
|
Return all callable R functions grouped by script namespace and package.
|
|
596
752
|
"""
|
|
@@ -620,9 +776,7 @@ class RFunctionCaller:
|
|
|
620
776
|
|
|
621
777
|
return all_funcs
|
|
622
778
|
|
|
623
|
-
def print_function_tree(
|
|
624
|
-
self, include_packages: bool = False, max_display: int = 10
|
|
625
|
-
):
|
|
779
|
+
def print_function_tree(self, include_packages: bool = False, max_display: int = 10):
|
|
626
780
|
"""
|
|
627
781
|
Pretty-print available R functions grouped by namespace.
|
|
628
782
|
|
|
@@ -695,17 +849,11 @@ class RFunctionCaller:
|
|
|
695
849
|
|
|
696
850
|
types = set(type(x) for x in obj if not is_na(x))
|
|
697
851
|
if types <= {int, float}:
|
|
698
|
-
return FloatVector(
|
|
699
|
-
[robjects.NA_Real if is_na(x) else float(x) for x in obj]
|
|
700
|
-
)
|
|
852
|
+
return FloatVector([robjects.NA_Real if is_na(x) else float(x) for x in obj])
|
|
701
853
|
if types <= {bool}:
|
|
702
|
-
return BoolVector(
|
|
703
|
-
[robjects.NA_Logical if is_na(x) else x for x in obj]
|
|
704
|
-
)
|
|
854
|
+
return BoolVector([robjects.NA_Logical if is_na(x) else x for x in obj])
|
|
705
855
|
if types <= {str}:
|
|
706
|
-
return StrVector(
|
|
707
|
-
[robjects.NA_Character if is_na(x) else x for x in obj]
|
|
708
|
-
)
|
|
856
|
+
return StrVector([robjects.NA_Character if is_na(x) else x for x in obj])
|
|
709
857
|
return ListVector({str(i): self._py2r(v) for i, v in enumerate(obj)})
|
|
710
858
|
if isinstance(obj, dict):
|
|
711
859
|
return ListVector({k: self._py2r(v) for k, v in obj.items()})
|
|
@@ -758,9 +906,7 @@ class RFunctionCaller:
|
|
|
758
906
|
r(f'suppressMessages(library("{pkg}", character.only=TRUE))')
|
|
759
907
|
except Exception:
|
|
760
908
|
logger.info(f"[rpy-bridge.RFunctionCaller] Package '{pkg}' not found.")
|
|
761
|
-
logger.warning(
|
|
762
|
-
f"[rpy-bridge.RFunctionCaller] Installing missing R package: {pkg}"
|
|
763
|
-
)
|
|
909
|
+
logger.warning(f"[rpy-bridge.RFunctionCaller] Installing missing R package: {pkg}")
|
|
764
910
|
r(f'install.packages("{pkg}", repos="https://cloud.r-project.org")')
|
|
765
911
|
r(f'suppressMessages(library("{pkg}", character.only=TRUE))')
|
|
766
912
|
|
|
@@ -812,6 +958,7 @@ class RFunctionCaller:
|
|
|
812
958
|
if fname in ns_env:
|
|
813
959
|
func = ns_env[fname]
|
|
814
960
|
source_info = f"script namespace '{ns_name}'"
|
|
961
|
+
namespace_root = self._namespace_roots.get(ns_name)
|
|
815
962
|
else:
|
|
816
963
|
raise ValueError(
|
|
817
964
|
f"Function '{fname}' not found in R script namespace '{ns_name}'"
|
|
@@ -821,15 +968,14 @@ class RFunctionCaller:
|
|
|
821
968
|
func = self.robjects.r(f"{ns_name}::{fname}")
|
|
822
969
|
source_info = f"R package '{ns_name}'"
|
|
823
970
|
except Exception as e:
|
|
824
|
-
raise RuntimeError(
|
|
825
|
-
f"Failed to resolve R function '{func_name}': {e}"
|
|
826
|
-
) from e
|
|
971
|
+
raise RuntimeError(f"Failed to resolve R function '{func_name}': {e}") from e
|
|
827
972
|
|
|
828
973
|
else:
|
|
829
974
|
for ns_name, ns_env in self._namespaces.items():
|
|
830
975
|
if func_name in ns_env:
|
|
831
976
|
func = ns_env[func_name]
|
|
832
977
|
source_info = f"script namespace '{ns_name}'"
|
|
978
|
+
namespace_root = self._namespace_roots.get(ns_name)
|
|
833
979
|
break
|
|
834
980
|
|
|
835
981
|
if func is None:
|
|
@@ -852,7 +998,18 @@ class RFunctionCaller:
|
|
|
852
998
|
r_kwargs = {k: self._py2r(v) for k, v in kwargs.items()}
|
|
853
999
|
|
|
854
1000
|
try:
|
|
855
|
-
|
|
1001
|
+
if source_info and source_info.startswith("script namespace") and namespace_root:
|
|
1002
|
+
r = self.robjects.r
|
|
1003
|
+
try:
|
|
1004
|
+
r(f'old_wd <- getwd(); setwd("{namespace_root.as_posix()}")')
|
|
1005
|
+
result = func(*r_args, **r_kwargs)
|
|
1006
|
+
finally:
|
|
1007
|
+
try:
|
|
1008
|
+
r("setwd(old_wd)")
|
|
1009
|
+
except Exception:
|
|
1010
|
+
pass
|
|
1011
|
+
else:
|
|
1012
|
+
result = func(*r_args, **r_kwargs)
|
|
856
1013
|
except Exception as e:
|
|
857
1014
|
raise RuntimeError(
|
|
858
1015
|
f"Error calling R function '{func_name}' from {source_info}: {e}"
|
|
@@ -927,9 +1084,7 @@ def fix_r_dataframe_types(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
927
1084
|
values = series.dropna()
|
|
928
1085
|
if not values.empty and values.between(10000, 40000).all():
|
|
929
1086
|
try:
|
|
930
|
-
df[col] = pd.to_datetime("1970-01-01") + pd.to_timedelta(
|
|
931
|
-
series, unit="D"
|
|
932
|
-
)
|
|
1087
|
+
df[col] = pd.to_datetime("1970-01-01") + pd.to_timedelta(series, unit="D")
|
|
933
1088
|
except Exception:
|
|
934
1089
|
pass
|
|
935
1090
|
if pd.api.types.is_datetime64tz_dtype(series):
|
|
@@ -975,20 +1130,14 @@ def clean_r_missing(obj, caller: RFunctionCaller):
|
|
|
975
1130
|
# ---------------------------------------------------------------------
|
|
976
1131
|
# DataFrame comparison utilities
|
|
977
1132
|
# ---------------------------------------------------------------------
|
|
978
|
-
def normalize_dtypes(
|
|
979
|
-
df1: pd.DataFrame, df2: pd.DataFrame
|
|
980
|
-
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
1133
|
+
def normalize_dtypes(df1: pd.DataFrame, df2: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
981
1134
|
for col in df1.columns.intersection(df2.columns):
|
|
982
1135
|
df1[col] = df1[col].replace("", pd.NA)
|
|
983
1136
|
df2[col] = df2[col].replace("", pd.NA)
|
|
984
1137
|
s1, s2 = df1[col], df2[col]
|
|
985
1138
|
dtype1, dtype2 = s1.dtype, s2.dtype
|
|
986
|
-
if (
|
|
987
|
-
pd.api.types.
|
|
988
|
-
and pd.api.types.is_object_dtype(dtype2)
|
|
989
|
-
) or (
|
|
990
|
-
pd.api.types.is_object_dtype(dtype1)
|
|
991
|
-
and pd.api.types.is_numeric_dtype(dtype2)
|
|
1139
|
+
if (pd.api.types.is_numeric_dtype(dtype1) and pd.api.types.is_object_dtype(dtype2)) or (
|
|
1140
|
+
pd.api.types.is_object_dtype(dtype1) and pd.api.types.is_numeric_dtype(dtype2)
|
|
992
1141
|
):
|
|
993
1142
|
try:
|
|
994
1143
|
df1[col] = pd.to_numeric(s1, errors="coerce")
|
|
@@ -996,9 +1145,7 @@ def normalize_dtypes(
|
|
|
996
1145
|
continue
|
|
997
1146
|
except Exception:
|
|
998
1147
|
pass
|
|
999
|
-
if pd.api.types.is_numeric_dtype(dtype1) and pd.api.types.is_numeric_dtype(
|
|
1000
|
-
dtype2
|
|
1001
|
-
):
|
|
1148
|
+
if pd.api.types.is_numeric_dtype(dtype1) and pd.api.types.is_numeric_dtype(dtype2):
|
|
1002
1149
|
df1[col] = df1[col].astype("float64")
|
|
1003
1150
|
df2[col] = df2[col].astype("float64")
|
|
1004
1151
|
continue
|
|
@@ -1008,9 +1155,7 @@ def normalize_dtypes(
|
|
|
1008
1155
|
return df1, df2
|
|
1009
1156
|
|
|
1010
1157
|
|
|
1011
|
-
def align_numeric_dtypes(
|
|
1012
|
-
df1: pd.DataFrame, df2: pd.DataFrame
|
|
1013
|
-
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
1158
|
+
def align_numeric_dtypes(df1: pd.DataFrame, df2: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
1014
1159
|
for col in df1.columns.intersection(df2.columns):
|
|
1015
1160
|
s1, s2 = df1[col].replace("", pd.NA), df2[col].replace("", pd.NA)
|
|
1016
1161
|
try:
|
|
@@ -1026,9 +1171,7 @@ def align_numeric_dtypes(
|
|
|
1026
1171
|
return df1, df2
|
|
1027
1172
|
|
|
1028
1173
|
|
|
1029
|
-
def compare_r_py_dataframes(
|
|
1030
|
-
df1: pd.DataFrame, df2: pd.DataFrame, float_tol: float = 1e-8
|
|
1031
|
-
) -> dict:
|
|
1174
|
+
def compare_r_py_dataframes(df1: pd.DataFrame, df2: pd.DataFrame, float_tol: float = 1e-8) -> dict:
|
|
1032
1175
|
results: dict[str, Any] = {
|
|
1033
1176
|
"shape_mismatch": False,
|
|
1034
1177
|
"columns_mismatch": False,
|
|
@@ -1055,9 +1198,7 @@ def compare_r_py_dataframes(
|
|
|
1055
1198
|
df1_aligned, df2_aligned = df1.loc[:, common_cols], df2.loc[:, common_cols]
|
|
1056
1199
|
for col in common_cols:
|
|
1057
1200
|
col_py, col_r = df1_aligned[col], df2_aligned[col]
|
|
1058
|
-
if pd.api.types.is_numeric_dtype(col_py) and pd.api.types.is_numeric_dtype(
|
|
1059
|
-
col_r
|
|
1060
|
-
):
|
|
1201
|
+
if pd.api.types.is_numeric_dtype(col_py) and pd.api.types.is_numeric_dtype(col_r):
|
|
1061
1202
|
col_py, col_r = col_py.align(col_r)
|
|
1062
1203
|
close = np.isclose(
|
|
1063
1204
|
col_py.fillna(np.nan),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rpy-bridge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python-to-R interoperability engine with environment management, type-safe conversions, data normalization, and safe R function execution.
|
|
5
5
|
Author-email: Victoria Cheung <victoriakcheung@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -114,14 +114,16 @@ It enables Python developers to call R functions, scripts, and packages safely w
|
|
|
114
114
|
|
|
115
115
|
**From PyPI:**
|
|
116
116
|
|
|
117
|
+
Install rpy-bridge with rpy2 for full R support
|
|
118
|
+
|
|
117
119
|
```bash
|
|
118
|
-
python3 -m pip install rpy-bridge
|
|
120
|
+
python3 -m pip install rpy-bridge rpy2
|
|
119
121
|
```
|
|
120
122
|
|
|
121
123
|
or using `uv`:
|
|
122
124
|
|
|
123
125
|
```bash
|
|
124
|
-
uv add rpy-bridge
|
|
126
|
+
uv add rpy-bridge rpy2
|
|
125
127
|
```
|
|
126
128
|
|
|
127
129
|
**During development (editable install):**
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|