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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rpy-bridge
3
- Version: 0.3.9
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.9"
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 optional dependency `rpy2`. Install with: pip install rpy-bridge[r]"
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
- renv_activate = renv_dir / "activate.R"
262
- renv_lock = project_dir / "renv.lock"
263
-
264
- if not renv_activate.exists() or not renv_lock.exists():
265
- raise FileNotFoundError(f"[Error] renv environment incomplete: {path_to_renv}")
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
- robjects.r(f'source("{rprofile_file.as_posix()}")')
275
- logger.info(f"[rpy-bridge] .Rprofile sourced: {rprofile_file}")
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.path_to_renv and not self._renv_activated:
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
- dirname(script_path),
505
- sys.source(basename(script_path), envir=env)
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
- result = func(*r_args, **r_kwargs)
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.9
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