rpy-bridge 0.3.9__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.9/src/rpy_bridge.egg-info → rpy_bridge-0.4.0}/PKG-INFO +1 -1
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/pyproject.toml +1 -1
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/src/rpy_bridge/rpy2_utils.py +189 -20
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0/src/rpy_bridge.egg-info}/PKG-INFO +1 -1
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/LICENSE +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/README.md +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/README.rst +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/setup.cfg +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/src/rpy_bridge/__init__.py +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/src/rpy_bridge/py.typed +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/src/rpy_bridge.egg-info/SOURCES.txt +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/src/rpy_bridge.egg-info/dependency_links.txt +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/src/rpy_bridge.egg-info/requires.txt +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/src/rpy_bridge.egg-info/top_level.txt +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/tests/test_package_call.py +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/tests/test_py2r.py +0 -0
- {rpy_bridge-0.3.9 → rpy_bridge-0.4.0}/tests/test_roundtrip.py +0 -0
- {rpy_bridge-0.3.9 → 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
|
|
@@ -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" }
|
|
@@ -230,7 +230,7 @@ def _require_rpy2(raise_on_missing: bool = True) -> dict | None:
|
|
|
230
230
|
except ImportError as e:
|
|
231
231
|
if raise_on_missing:
|
|
232
232
|
raise RuntimeError(
|
|
233
|
-
"R support requires
|
|
233
|
+
"R support requires rpy2; install it in your Python env (e.g., pip install rpy2)"
|
|
234
234
|
) from e
|
|
235
235
|
return None
|
|
236
236
|
|
|
@@ -243,6 +243,46 @@ def _ensure_rpy2() -> dict:
|
|
|
243
243
|
return _RPY2
|
|
244
244
|
|
|
245
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
|
+
|
|
246
286
|
# ---------------------------------------------------------------------
|
|
247
287
|
# Activate renv
|
|
248
288
|
# ---------------------------------------------------------------------
|
|
@@ -250,19 +290,50 @@ def activate_renv(path_to_renv: Path) -> None:
|
|
|
250
290
|
r = _ensure_rpy2()
|
|
251
291
|
robjects = r["robjects"]
|
|
252
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.
|
|
253
297
|
path_to_renv = path_to_renv.resolve()
|
|
254
|
-
if path_to_renv.name == "renv" and (path_to_renv / "activate.R").exists():
|
|
255
|
-
renv_dir = path_to_renv
|
|
256
|
-
project_dir = path_to_renv.parent
|
|
257
|
-
else:
|
|
258
|
-
renv_dir = path_to_renv / "renv"
|
|
259
|
-
project_dir = path_to_renv
|
|
260
298
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
)
|
|
266
337
|
|
|
267
338
|
renviron_file = project_dir / ".Renviron"
|
|
268
339
|
if renviron_file.is_file():
|
|
@@ -271,9 +342,22 @@ def activate_renv(path_to_renv: Path) -> None:
|
|
|
271
342
|
|
|
272
343
|
rprofile_file = project_dir / ".Rprofile"
|
|
273
344
|
if rprofile_file.is_file():
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
)
|
|
276
359
|
|
|
360
|
+
# If .Rprofile was absent or failed, ensure renv is loaded directly.
|
|
277
361
|
try:
|
|
278
362
|
robjects.r("suppressMessages(library(renv))")
|
|
279
363
|
except Exception:
|
|
@@ -283,6 +367,7 @@ def activate_renv(path_to_renv: Path) -> None:
|
|
|
283
367
|
)
|
|
284
368
|
robjects.r("library(renv)")
|
|
285
369
|
|
|
370
|
+
# Activate renv explicitly in case .Rprofile did not already do it (or failed).
|
|
286
371
|
robjects.r(f'renv::load("{project_dir.as_posix()}")')
|
|
287
372
|
logger.info(f"[rpy-bridge] renv environment loaded for project: {project_dir}")
|
|
288
373
|
|
|
@@ -352,6 +437,8 @@ class RFunctionCaller:
|
|
|
352
437
|
path_to_renv: str | Path | None = None,
|
|
353
438
|
scripts: str | Path | list[str | Path] | None = None,
|
|
354
439
|
packages: str | list[str] | None = None,
|
|
440
|
+
headless: bool = True,
|
|
441
|
+
skip_renv_if_no_r: bool = True,
|
|
355
442
|
**kwargs, # catch unexpected keywords
|
|
356
443
|
):
|
|
357
444
|
# Handle path_to_renv safely
|
|
@@ -392,6 +479,7 @@ class RFunctionCaller:
|
|
|
392
479
|
|
|
393
480
|
self.path_to_renv = path_to_renv.resolve() if path_to_renv else None
|
|
394
481
|
self._namespaces: dict[str, Any] = {}
|
|
482
|
+
self._namespace_roots: dict[str, Path] = {}
|
|
395
483
|
|
|
396
484
|
# Normalize scripts to a list
|
|
397
485
|
if scripts is None:
|
|
@@ -409,6 +497,10 @@ class RFunctionCaller:
|
|
|
409
497
|
else:
|
|
410
498
|
self.packages = packages
|
|
411
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
|
+
|
|
412
504
|
# Lazy-loaded attributes
|
|
413
505
|
self._r = None
|
|
414
506
|
self.ro = None
|
|
@@ -427,6 +519,42 @@ class RFunctionCaller:
|
|
|
427
519
|
self._packages_loaded = False
|
|
428
520
|
self._scripts_loaded = [False] * len(self.scripts)
|
|
429
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
|
+
|
|
430
558
|
# -----------------------------------------------------------------
|
|
431
559
|
# Internal: lazy R loading
|
|
432
560
|
# -----------------------------------------------------------------
|
|
@@ -435,6 +563,9 @@ class RFunctionCaller:
|
|
|
435
563
|
Ensure R runtime is initialized and all configured R scripts
|
|
436
564
|
are sourced exactly once, in isolated environments.
|
|
437
565
|
"""
|
|
566
|
+
# Ensure headless-safe env before rpy2 initializes R
|
|
567
|
+
self._ensure_headless_env()
|
|
568
|
+
|
|
438
569
|
if self.robjects is None:
|
|
439
570
|
rpy2_dict = _ensure_rpy2()
|
|
440
571
|
self._RPY2 = rpy2_dict # cache in instance
|
|
@@ -450,8 +581,8 @@ class RFunctionCaller:
|
|
|
450
581
|
self.ListVector = rpy2_dict["ListVector"]
|
|
451
582
|
self.NamedList = rpy2_dict["NamedList"]
|
|
452
583
|
|
|
453
|
-
# Activate renv once if requested
|
|
454
|
-
if self.
|
|
584
|
+
# Activate renv once if requested and allowed
|
|
585
|
+
if not self._renv_activated and self._should_activate_renv():
|
|
455
586
|
try:
|
|
456
587
|
activate_renv(self.path_to_renv)
|
|
457
588
|
self._renv_activated = True
|
|
@@ -463,6 +594,23 @@ class RFunctionCaller:
|
|
|
463
594
|
|
|
464
595
|
r = self.robjects.r
|
|
465
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
|
+
|
|
466
614
|
# Ensure required R package
|
|
467
615
|
self.ensure_r_package("withr")
|
|
468
616
|
|
|
@@ -498,11 +646,18 @@ class RFunctionCaller:
|
|
|
498
646
|
r("env <- new.env(parent=globalenv())")
|
|
499
647
|
r(f'script_path <- "{script_path.as_posix()}"')
|
|
500
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
|
+
|
|
501
656
|
r(
|
|
502
|
-
"""
|
|
657
|
+
f"""
|
|
503
658
|
withr::with_dir(
|
|
504
|
-
|
|
505
|
-
sys.source(
|
|
659
|
+
{script_root_arg},
|
|
660
|
+
sys.source(script_path, envir=env, chdir = TRUE)
|
|
506
661
|
)
|
|
507
662
|
"""
|
|
508
663
|
)
|
|
@@ -511,6 +666,7 @@ class RFunctionCaller:
|
|
|
511
666
|
self._namespaces[ns_name] = {
|
|
512
667
|
name: env_obj[name] for name in env_obj.keys() if callable(env_obj[name])
|
|
513
668
|
}
|
|
669
|
+
self._namespace_roots[ns_name] = script_root
|
|
514
670
|
|
|
515
671
|
logger.info(
|
|
516
672
|
f"[rpy-bridge.RFunctionCaller] Registered {len(self._namespaces[ns_name])} functions in namespace '{ns_name}'"
|
|
@@ -802,6 +958,7 @@ class RFunctionCaller:
|
|
|
802
958
|
if fname in ns_env:
|
|
803
959
|
func = ns_env[fname]
|
|
804
960
|
source_info = f"script namespace '{ns_name}'"
|
|
961
|
+
namespace_root = self._namespace_roots.get(ns_name)
|
|
805
962
|
else:
|
|
806
963
|
raise ValueError(
|
|
807
964
|
f"Function '{fname}' not found in R script namespace '{ns_name}'"
|
|
@@ -818,6 +975,7 @@ class RFunctionCaller:
|
|
|
818
975
|
if func_name in ns_env:
|
|
819
976
|
func = ns_env[func_name]
|
|
820
977
|
source_info = f"script namespace '{ns_name}'"
|
|
978
|
+
namespace_root = self._namespace_roots.get(ns_name)
|
|
821
979
|
break
|
|
822
980
|
|
|
823
981
|
if func is None:
|
|
@@ -840,7 +998,18 @@ class RFunctionCaller:
|
|
|
840
998
|
r_kwargs = {k: self._py2r(v) for k, v in kwargs.items()}
|
|
841
999
|
|
|
842
1000
|
try:
|
|
843
|
-
|
|
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)
|
|
844
1013
|
except Exception as e:
|
|
845
1014
|
raise RuntimeError(
|
|
846
1015
|
f"Error calling R function '{func_name}' from {source_info}: {e}"
|
|
@@ -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
|
|
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
|
|
File without changes
|