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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rpy-bridge
3
- Version: 0.3.8
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.8"
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 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)"
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
- renv_activate = renv_dir / "activate.R"
266
- renv_lock = project_dir / "renv.lock"
267
-
268
- if not renv_activate.exists() or not renv_lock.exists():
269
- 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
+ )
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
- robjects.r(f'source("{rprofile_file.as_posix()}")')
279
- 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
+ )
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
- dirname(script_path),
501
- sys.source(basename(script_path), envir=env)
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
- 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)
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.is_numeric_dtype(dtype1)
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.8
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