---
title: "Importing Data Files"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{importing-data}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)
```

A crucial first step in any data analysis pipeline is importing data. The
`{rixpress}` package provides a flexible set of functions, `rxp_r_file`,
`rxp_py_file`, and `rxp_jl_file`, to handle various data import scenarios in a
reproducible way. This vignette will guide you through the common use cases.

For more examples, check out the [rixpress_demos repository](https://github.com/b-rodrigues/rixpress_demos/).

## Importing a single local file

The most straightforward case is reading a single data file from your local
project directory. You need to provide a `name` for the resulting R object, the
`path` to the file, and a `read_function` to process it.

```{r, eval = FALSE}
library(rixpress)

list(
  rxp_r_file(
    name = mtcars,
    path = 'data/mtcars.csv',
    read_function = \(x) (read.csv(file = x, sep = "|"))
  ),
...
```

In this example, `rxp_r_file` creates a derivation that:

1. Copies `data/mtcars.csv` into a sandboxed build environment.
2. Executes the provided anonymous function, `\(x) (read.csv(file = x, sep =
   "|"))`, where `x` is the path to the copied file inside the sandbox.
3. Saves the resulting data frame as an object named `mtcars` for subsequent steps in the pipeline.

## Importing a single file from the internet

You can also directly import a file from a URL. Simply provide the URL as the
`path`. `{rixpress}` handles the download and ensures reproducibility by caching
the file using its cryptographic hash.

```{r, eval = FALSE}
library(rixpress)

list(
  rxp_r_file(
    name = mtcars,
    path = 'https://raw.githubusercontent.com/b-rodrigues/rixpress_demos/refs/heads/master/basic_r/data/mtcars.csv',
    read_function = \(x) (read.csv(file = x, sep = "|"))
  ),
...
```

Behind the scenes, `{rixpress}` uses Nix to fetch the file, ensuring that the
exact same version of the file is used every time the pipeline is run. This is
the only time the build sandbox can access a remote file: it's because the file
actually gets downloaded by Nix ahead of time. If you need to access data in
real-time from an API, then you'll need to download the data yourself
outside of `{rixpress}` pipeline, and then import it in the pipeline using
`rxp_r_file()`.

## Importing many files from a directory

Often, you need to import and combine multiple files from a single directory. To
do this, set the `path` argument to the directory's path. Your `read_function`
will then receive the path to this directory inside the build environment and
must contain the logic to handle all the files within.

Here is an example in R that reads all files in the `data` directory:

```{r, eval = FALSE}
library(rixpress)

list(
  rxp_r_file(
    name = mtcars_r,
    path = 'data',
    read_function = \(x) {
      (readr::read_delim(list.files(x, full.names = TRUE), delim = '|'))
    }
  )
) |>
  rxp_populate(project_path = ".")
```

And here's a similar example using Python, which calls a user-defined function
`read_many_csvs` from an external script:

```{r, eval = FALSE}
library(rixpress)

list(
  rxp_py_file(
    name = mtcars_py,
    path = 'data',
    read_function = "read_many_csvs",
    user_functions = "functions.py"
  )
) |>
  rxp_populate(project_path = ".")
```

Here is what the Python function looks like:

```py
import polars
from pathlib import Path

def read_many_csvs(dir_path):
    folder = Path(dir_path)
    csv_files = folder.glob("*.csv")
    return polars.concat([polars.read_csv(f) for f in csv_files])
```

In both cases, the entire `data` directory is copied into the build sandbox, and
the `read_function` is responsible for listing the files and reading them.

## Importing files with dependencies (e.g., Shapefiles)

Some file formats, like the ESRI Shapefile, consist of multiple "sidecar" files
(e.g., `.shp`, `.shx`, `.dbf`) that must be present together for the data to be
read correctly. Even though you might only point the read function to the `.shp`
file, the other component files need to be in the same directory.

`{rixpress}` handles this by allowing you to specify a directory as the `path`.
This ensures all necessary files are copied into the build environment. However,
you must then provide the *full path to the main file inside the build
environment* within your `read_function`.

In a `{rixpress}` pipeline, local files and directories specified in `path` are
copied into a sub-directory called `input_folder`. Therefore, the path to your
data inside the Nix sandbox will be `input_folder/YOUR_PATH`.

The following example shows how to read a shapefile using Python and
`geopandas`:

```{r, eval = FALSE}
library(rixpress)

list(
  rxp_py_file(
    name = gdf,
    # We provide the directory 'data' to ensure all shapefile components are copied.
    path = 'data',
    # The read_function must use the hardcoded path within the build environment.
    read_function = "lambda x: geopandas.read_file('input_folder/data/oceans.shp', driver='ESRI Shapefile')"
  ),

  rxp_py(
    name = sa,
    expr = "gdf.loc[gdf['Oceans'] == 'South Atlantic Ocean']['geometry'].loc[0]"
  )
) |>
  rxp_populate(project_path = ".")
```

Here's what happens:

1. The `path = 'data'` argument tells `{rixpress}` to copy the entire `data`
   directory into the sandbox.
2. Inside the sandbox, the shapefile is located at
   `input_folder/data/oceans.shp`.
3. The `read_function` is a lambda function that explicitly calls
   `geopandas.read_file` with this hardcoded path, allowing it to find the
   `.shp` file and its necessary sidecar files.

A perhaps cleaner alternative is to write a function that takes the path to the
data folder as an input, and then have this function look in that folder for the
shapefile, and pass its path to `geopandas.read_file`. For example

```py
def read_shp(path_folder):
    # Look for files ending with .shp in the given folder
    candidates = glob.glob(os.path.join(path_folder, "*.shp"))
    if not candidates:
        raise FileNotFoundError(f"No .shp file found in {path_folder}")

    shapefile = candidates[0]
    return gpd.read_file(shapefile, driver="ESRI Shapefile")
```

We can then rewrite the derivation like so:

```r
rxp_py_file(
    name = gdf,
    path = 'data',
    read_function = "read_shp",
    user_functions = "functions.py"
  ),
```

(assuming our function is defined in a script called `functions.py`).

Because our Python function also uses `glob` and `os`, we need to import these
functions using `add_import()`. We can add this just after calling
`rxp_populate()`:


```r
  rxp_populate(
    project_path = ".",
    py_imports = c(geopandas = "import geopandas as gpd")
  )

# This is needed for the function defined in functions.py
add_import("import os", "default.nix")
add_import("import glob", "default.nix")
```

## Conclusion

The `rxp_*_file` functions in `{rixpress}` offer a powerful and consistent
interface for ingesting data into your reproducible pipelines, whether your data
lives locally, on the web, as a single file, or as a collection of files. By
understanding how to specify the `path` and tailor the `read_function`, you can
handle a wide variety of data import tasks.
