rpy-bridge 0.3.4__tar.gz → 0.3.5__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.4
3
+ Version: 0.3.5
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
@@ -150,12 +150,12 @@ uv sync
150
150
  from pathlib import Path
151
151
  from rpy_bridge import RFunctionCaller
152
152
 
153
- caller = RFunctionCaller(
153
+ rfc = RFunctionCaller(
154
154
  path_to_renv=Path("/path/to/project"),
155
- script_path=Path("/path/to/script.R"),
155
+ script=Path("/path/to/script.R"),
156
156
  )
157
157
 
158
- summary_df = caller.call("summarize_cohort", cohort_df)
158
+ summary_df = rfc.call("summarize_cohort", cohort_df)
159
159
  ```
160
160
 
161
161
  ---
@@ -91,12 +91,12 @@ uv sync
91
91
  from pathlib import Path
92
92
  from rpy_bridge import RFunctionCaller
93
93
 
94
- caller = RFunctionCaller(
94
+ rfc = RFunctionCaller(
95
95
  path_to_renv=Path("/path/to/project"),
96
- script_path=Path("/path/to/script.R"),
96
+ script=Path("/path/to/script.R"),
97
97
  )
98
98
 
99
- summary_df = caller.call("summarize_cohort", cohort_df)
99
+ summary_df = rfc.call("summarize_cohort", cohort_df)
100
100
  ```
101
101
 
102
102
  ---
@@ -9,5 +9,5 @@ Usage example (local script):
9
9
 
10
10
  # Use a local script path (clone or download remote scripts yourself)
11
11
  script_path = "/path/to/cloned/repo/scripts/my_script.R"
12
- caller = RFunctionCaller(path_to_renv=None, script_path=script_path)
12
+ caller = RFunctionCaller(path_to_renv=None, script=script_path)
13
13
  result = caller.call("my_func")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rpy-bridge"
3
- version = "0.3.4"
3
+ version = "0.3.5"
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" }
@@ -29,7 +29,7 @@ import subprocess
29
29
  import sys
30
30
  import warnings
31
31
  from pathlib import Path
32
- from typing import TYPE_CHECKING, Any, Union
32
+ from typing import TYPE_CHECKING, Any, Iterable, Union
33
33
 
34
34
  import numpy as np
35
35
  import pandas as pd
@@ -58,6 +58,47 @@ except ImportError:
58
58
  logger = logging.getLogger("rpy-bridge")
59
59
 
60
60
 
61
+ # --- Remove default handler to override global default ---
62
+ logger.remove()
63
+
64
+ # --- Add a "sink" for RFunctionCaller logs ---
65
+ _rfc_logger = logger.bind(tag="[RFunctionCaller]")
66
+ _rfc_logger.add(
67
+ sys.stderr,
68
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", # Only show message
69
+ level="INFO",
70
+ )
71
+
72
+
73
+ def _log_r_call(func_name: str, source_info: str):
74
+ """
75
+ Log an R function call, showing only '[RFunctionCaller] Called ...'
76
+ """
77
+ _rfc_logger.opt(depth=1, record=False).info(
78
+ "[rpy-bridge.RFunctionCaller] Called R function '{}' from {}",
79
+ func_name,
80
+ source_info,
81
+ )
82
+
83
+
84
+ # ---------------------------------------------------------------------
85
+ # Path resolution
86
+ # ---------------------------------------------------------------------
87
+ def _normalize_scripts(
88
+ scripts: Union[str, Path, Iterable[Union[str, Path]], None],
89
+ ) -> list[Path]:
90
+ if scripts is None:
91
+ return []
92
+ if isinstance(scripts, (str, Path)):
93
+ return [Path(scripts).resolve()]
94
+ try:
95
+ return [Path(s).resolve() for s in scripts]
96
+ except TypeError:
97
+ raise TypeError(
98
+ f"Invalid type for 'scripts': {type(scripts)}. Must be str, Path, or list/iterable thereof."
99
+ )
100
+
101
+
61
102
  # ---------------------------------------------------------------------
62
103
  # R detection and rpy2 installation
63
104
  # ---------------------------------------------------------------------
@@ -104,31 +145,49 @@ def find_r_home() -> str | None:
104
145
  return None
105
146
 
106
147
 
107
- if "R_HOME" not in os.environ:
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
+ )
152
+
153
+ R_HOME = os.environ.get("R_HOME")
154
+ if not R_HOME:
108
155
  R_HOME = find_r_home()
109
156
  if not R_HOME:
110
- raise RuntimeError("R not found. Please install R or add it to PATH.")
111
- os.environ["R_HOME"] = R_HOME
112
- else:
113
- R_HOME = os.environ["R_HOME"]
114
-
115
- logger.info(f"R_HOME = {R_HOME}")
116
- os.environ["R_HOME"] = R_HOME
117
- ensure_rpy2_available()
118
-
119
- # macOS dynamic library path
120
- if sys.platform == "darwin":
121
- lib_path = os.path.join(R_HOME, "lib")
122
- if lib_path not in os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", ""):
123
- os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = (
124
- f"{lib_path}:{os.environ.get('DYLD_FALLBACK_LIBRARY_PATH','')}"
125
- )
157
+ if CI_TESTING:
158
+ logger.warning(
159
+ "R not found; skipping all R-dependent setup in CI/testing environment."
160
+ )
161
+ R_HOME = None # Explicitly None to signal "no R available"
162
+ else:
163
+ raise RuntimeError("R not found. Please install R or add it to PATH.")
164
+ else:
165
+ os.environ["R_HOME"] = R_HOME
166
+
167
+ logger.info(
168
+ f"[rpy-bridge] R_HOME = {R_HOME if R_HOME else 'not detected; R-dependent code skipped'}"
169
+ )
170
+
171
+ # Only configure platform-specific library paths if R is available
172
+ if R_HOME:
173
+ if sys.platform == "darwin":
174
+ lib_path = os.path.join(R_HOME, "lib")
175
+ if lib_path not in os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", ""):
176
+ os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = (
177
+ f"{lib_path}:{os.environ.get('DYLD_FALLBACK_LIBRARY_PATH','')}"
178
+ )
179
+
180
+ elif sys.platform.startswith("linux"):
181
+ lib_path = os.path.join(R_HOME, "lib")
182
+ ld_path = os.environ.get("LD_LIBRARY_PATH", "")
183
+ if lib_path not in ld_path.split(":"):
184
+ os.environ["LD_LIBRARY_PATH"] = f"{lib_path}:{ld_path}"
126
185
 
127
- elif sys.platform.startswith("linux"):
128
- lib_path = os.path.join(R_HOME, "lib")
129
- ld_path = os.environ.get("LD_LIBRARY_PATH", "")
130
- if lib_path not in ld_path.split(":"):
131
- os.environ["LD_LIBRARY_PATH"] = f"{lib_path}:{ld_path}"
186
+ elif sys.platform.startswith("win"):
187
+ bin_path = os.path.join(R_HOME, "bin", "x64")
188
+ path_env = os.environ.get("PATH", "")
189
+ if bin_path not in path_env.split(os.pathsep):
190
+ os.environ["PATH"] = f"{bin_path}{os.pathsep}{path_env}"
132
191
 
133
192
 
134
193
  # ---------------------------------------------------------------------
@@ -212,24 +271,24 @@ def activate_renv(path_to_renv: Path) -> None:
212
271
  renviron_file = project_dir / ".Renviron"
213
272
  if renviron_file.is_file():
214
273
  os.environ["R_ENVIRON_USER"] = str(renviron_file)
215
- logger.info(f"R_ENVIRON_USER set to: {renviron_file}")
274
+ logger.info(f"[rpy-bridge] R_ENVIRON_USER set to: {renviron_file}")
216
275
 
217
276
  rprofile_file = project_dir / ".Rprofile"
218
277
  if rprofile_file.is_file():
219
278
  robjects.r(f'source("{rprofile_file.as_posix()}")')
220
- logger.info(f".Rprofile sourced: {rprofile_file}")
279
+ logger.info(f"[rpy-bridge] .Rprofile sourced: {rprofile_file}")
221
280
 
222
281
  try:
223
282
  robjects.r("suppressMessages(library(renv))")
224
283
  except Exception:
225
- logger.info("Installing renv package in project library...")
284
+ logger.info("[rpy-bridge] Installing renv package in project library...")
226
285
  robjects.r(
227
286
  f'install.packages("renv", repos="https://cloud.r-project.org", lib="{renv_dir / "library"}")'
228
287
  )
229
288
  robjects.r("library(renv)")
230
289
 
231
290
  robjects.r(f'renv::load("{project_dir.as_posix()}")')
232
- logger.info(f"renv environment loaded for project: {project_dir}")
291
+ logger.info(f"[rpy-bridge] renv environment loaded for project: {project_dir}")
233
292
 
234
293
 
235
294
  # ---------------------------------------------------------------------
@@ -248,35 +307,54 @@ class NamespaceWrapper:
248
307
  return self._env[func_name]
249
308
  raise AttributeError(f"Function '{func_name}' not found in R namespace")
250
309
 
310
+ def list_functions(self):
311
+ """
312
+ Return a list of callable functions in this namespace.
313
+ """
314
+ return [k for k, v in self._env.items() if callable(v)]
315
+
251
316
 
252
317
  # ---------------------------------------------------------------------
253
318
  # RFunctionCaller
254
319
  # ---------------------------------------------------------------------
255
320
  class RFunctionCaller:
256
321
  """
257
- Utility to load and call R functions from scripts, lazily loading rpy2 and activating renv.
258
-
259
- Supports:
260
- - Single or multiple R scripts
261
- - R script directories (sources all `.R` files inside)
262
- - Base R functions
263
- - Functions in loaded packages
264
- - Automatic conversion of Python types to R objects
265
-
266
- Args:
267
- scripts:
268
- Path or list of Paths.
269
- Each path may be:
270
- - an R script (.R file)
271
- - a directory containing R scripts (all *.R files are sourced)
272
- - scripts in subdirectories are not automatically sourced
322
+ Primary interface for calling R functions from Python.
323
+
324
+ ``RFunctionCaller`` loads one or more R scripts into isolated namespaces
325
+ and provides a unified ``call()`` method for executing:
326
+
327
+ * Functions defined in sourced R scripts
328
+ * Base R functions (e.g. ``sum``, ``mean``)
329
+ * Functions from installed R packages (via ``package::function``)
330
+
331
+ In most workflows, users only need to interact with this class.
273
332
 
333
+ Parameters
334
+ ----------
335
+ path_to_renv : Path or None, optional
336
+ Path to an R project that uses ``renv``. This may be either the project
337
+ root or the ``renv/`` directory itself. If provided, the renv
338
+ environment is activated before any scripts are sourced.
339
+
340
+ scripts : str, Path, list[str | Path], or None, optional
341
+ One or more ``.R`` files or directories containing ``.R`` files.
342
+ Each script is sourced into its own namespace.
343
+
344
+ packages : str or list[str], optional
345
+ R packages to load (and install if missing) before calling functions.
346
+
347
+ Notes
348
+ -----
349
+ * Python objects are automatically converted to R objects.
350
+ * R return values are converted back to Python equivalents.
351
+ * Missing values (``None``, ``pd.NA``) are mapped to R ``NA``.
274
352
  """
275
353
 
276
354
  def __init__(
277
355
  self,
278
356
  path_to_renv: Path | None = None,
279
- scripts: Path | list[Path] | None = None,
357
+ scripts: str | Path | list[str | Path] | None = None,
280
358
  packages: str | list[str] | None = None,
281
359
  **kwargs, # catch unexpected keywords
282
360
  ):
@@ -297,6 +375,13 @@ class RFunctionCaller:
297
375
  "'script_path' ignored because 'scripts' argument is also provided."
298
376
  )
299
377
 
378
+ self.scripts = _normalize_scripts(scripts)
379
+
380
+ # --- Check all scripts exist immediately ---
381
+ for script_path in self.scripts:
382
+ if not script_path.exists():
383
+ raise FileNotFoundError(f"R script path not found: {script_path}")
384
+
300
385
  # Raise error if other unexpected kwargs remain
301
386
  if kwargs:
302
387
  raise TypeError(
@@ -391,8 +476,10 @@ class RFunctionCaller:
391
476
 
392
477
  for script_path in r_files:
393
478
  ns_name = script_path.stem
394
- logger.info(
395
- f"Loading R script '{script_path.name}' as namespace '{ns_name}'"
479
+ logger.opt(depth=2).info(
480
+ "[rpy-bridge.RFunctionCaller] Loading R script '{}' as namespace '{}'",
481
+ script_path.name,
482
+ ns_name,
396
483
  )
397
484
 
398
485
  r("env <- new.env(parent=globalenv())")
@@ -415,7 +502,7 @@ class RFunctionCaller:
415
502
  }
416
503
 
417
504
  logger.info(
418
- f"Registered {len(self._namespaces[ns_name])} functions in namespace '{ns_name}'"
505
+ f"[rpy-bridge.RFunctionCaller] Registered {len(self._namespaces[ns_name])} functions in namespace '{ns_name}'"
419
506
  )
420
507
 
421
508
  self._scripts_loaded[idx] = True
@@ -454,6 +541,105 @@ class RFunctionCaller:
454
541
 
455
542
  return x
456
543
 
544
+ def list_namespaces(self) -> list[str]:
545
+ """
546
+ Return the names of all loaded script namespaces.
547
+
548
+ Returns
549
+ -------
550
+ list[str]
551
+ Names of sourced R script namespaces.
552
+ """
553
+ self._ensure_r_loaded()
554
+ return list(self._namespaces.keys())
555
+
556
+ def list_namespace_functions(self, namespace: str) -> list[str]:
557
+ """
558
+ Return all callable functions in a specific namespace.
559
+ """
560
+ self._ensure_r_loaded()
561
+ if namespace not in self._namespaces:
562
+ raise ValueError(f"Namespace '{namespace}' not found")
563
+ return [k for k, v in self._namespaces[namespace].items() if callable(v)]
564
+
565
+ def _get_package_functions(self, pkg: str) -> list[str]:
566
+ """
567
+ Return a list of callable functions from a loaded R package.
568
+ """
569
+ r = self.robjects.r
570
+ try:
571
+ all_objs = list(r[f'ls("package:{pkg}")'])
572
+ funcs = [
573
+ name
574
+ for name in all_objs
575
+ if r(f'is.function(get("{name}", envir=asNamespace("{pkg}")))')[0]
576
+ ]
577
+ return funcs
578
+ except Exception:
579
+ logger.warning(f"Failed to list functions for package '{pkg}'")
580
+ return []
581
+
582
+ def list_all_functions(
583
+ self, include_packages: bool = False
584
+ ) -> dict[str, list[str]]:
585
+ """
586
+ Return all callable R functions grouped by script namespace and package.
587
+ """
588
+ self._ensure_r_loaded()
589
+ all_funcs = {}
590
+
591
+ # --- Script namespaces ---
592
+ for ns_name, ns_env in self._namespaces.items():
593
+ funcs = [name for name, val in ns_env.items() if callable(val)]
594
+ all_funcs[ns_name] = funcs
595
+
596
+ # --- Loaded R packages ---
597
+ if include_packages:
598
+ r = self.robjects.r
599
+ try:
600
+ pkgs = r("loadedNamespaces()")
601
+ for pkg in pkgs:
602
+ funcs = self._get_package_functions(pkg)
603
+ if not funcs:
604
+ # Add a placeholder note
605
+ funcs = [
606
+ "[See official documentation for functions, datasets, and objects]"
607
+ ]
608
+ all_funcs[pkg] = funcs
609
+ except Exception:
610
+ pass
611
+
612
+ return all_funcs
613
+
614
+ def print_function_tree(
615
+ self, include_packages: bool = False, max_display: int = 10
616
+ ):
617
+ """
618
+ Pretty-print available R functions grouped by namespace.
619
+
620
+ Parameters
621
+ ----------
622
+ include_packages : bool, default False
623
+ Whether to include functions from loaded R packages.
624
+
625
+ max_display : int, default 10
626
+ Maximum number of functions displayed per namespace.
627
+
628
+ Notes
629
+ -----
630
+ This method is intended for interactive exploration and debugging.
631
+ """
632
+ all_funcs = self.list_all_functions(include_packages=include_packages)
633
+
634
+ for ns_name, funcs in all_funcs.items():
635
+ if not funcs:
636
+ continue
637
+ print(f"{ns_name}/")
638
+ for f in sorted(funcs)[:max_display]:
639
+ print(f" {f}")
640
+ if len(funcs) > max_display:
641
+ print(" ...")
642
+
457
643
  # -----------------------------------------------------------------
458
644
  # Python -> R conversion
459
645
  # -----------------------------------------------------------------
@@ -562,8 +748,10 @@ class RFunctionCaller:
562
748
  try:
563
749
  r(f'suppressMessages(library("{pkg}", character.only=TRUE))')
564
750
  except Exception:
565
- logger.info(f"Package '{pkg}' not found.")
566
- logger.warning(f"Installing missing R package: {pkg}")
751
+ logger.info(f"[rpy-bridge.RFunctionCaller] Package '{pkg}' not found.")
752
+ logger.warning(
753
+ f"[rpy-bridge.RFunctionCaller] Installing missing R package: {pkg}"
754
+ )
567
755
  r(f'install.packages("{pkg}", repos="https://cloud.r-project.org")')
568
756
  r(f'suppressMessages(library("{pkg}", character.only=TRUE))')
569
757
 
@@ -571,6 +759,38 @@ class RFunctionCaller:
571
759
  # Public: call an R function
572
760
  # -----------------------------------------------------------------
573
761
  def call(self, func_name: str, *args, **kwargs):
762
+ """
763
+ Call an R function.
764
+
765
+ The function may be defined in:
766
+ * a sourced R script
767
+ * an installed R package (using ``package::function`` syntax)
768
+ * base R
769
+
770
+ Parameters
771
+ ----------
772
+ func_name : str
773
+ Name of the R function to call. Package functions should be specified
774
+ as ``package::function``.
775
+
776
+ *args
777
+ Positional arguments passed to the R function.
778
+
779
+ **kwargs
780
+ Named arguments passed to the R function.
781
+
782
+ Returns
783
+ -------
784
+ object
785
+ The result of the R function, converted to a Python object.
786
+
787
+ Examples
788
+ --------
789
+ >>> rfc.call("sum", [1, 2, 3])
790
+ >>> rfc.call("dplyr::n_distinct", [1, 2, 2, 3])
791
+ >>> rfc.call("add_and_scale", 2, 3, scale=10)
792
+ """
793
+
574
794
  self._ensure_r_loaded()
575
795
 
576
796
  func = None
@@ -629,7 +849,8 @@ class RFunctionCaller:
629
849
  f"Error calling R function '{func_name}' from {source_info}: {e}"
630
850
  ) from e
631
851
 
632
- logger.info(f"Called R function '{func_name}' from {source_info}")
852
+ _log_r_call(func_name, source_info)
853
+
633
854
  return self._r2py(result)
634
855
 
635
856
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rpy-bridge
3
- Version: 0.3.4
3
+ Version: 0.3.5
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
@@ -150,12 +150,12 @@ uv sync
150
150
  from pathlib import Path
151
151
  from rpy_bridge import RFunctionCaller
152
152
 
153
- caller = RFunctionCaller(
153
+ rfc = RFunctionCaller(
154
154
  path_to_renv=Path("/path/to/project"),
155
- script_path=Path("/path/to/script.R"),
155
+ script=Path("/path/to/script.R"),
156
156
  )
157
157
 
158
- summary_df = caller.call("summarize_cohort", cohort_df)
158
+ summary_df = rfc.call("summarize_cohort", cohort_df)
159
159
  ```
160
160
 
161
161
  ---
@@ -0,0 +1,11 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from rpy_bridge import RFunctionCaller
6
+
7
+
8
+ def test_missing_script_raises():
9
+ # If script does not exist, the constructor should raise FileNotFoundError
10
+ with pytest.raises(FileNotFoundError):
11
+ RFunctionCaller(path_to_renv=None, scripts=Path("/does/not/exist.R"))
@@ -1,11 +0,0 @@
1
- from pathlib import Path
2
-
3
- import pytest
4
-
5
- from rpy_bridge import RFunctionCaller
6
-
7
-
8
- def test_missing_script_raises():
9
- # If script_path does not exist, the constructor should raise FileNotFoundError
10
- with pytest.raises(FileNotFoundError):
11
- RFunctionCaller(path_to_renv=None, script_path=Path("/does/not/exist.R"))
File without changes
File without changes