12 A Short Intro to Packaging Your Code in R and Python
12.1 Introduction
In the previous chapter, we learned how to prove that our code works through unit testing and how to manage our collaboration with Git. We now have functions, tests, and version control. But there is one more step we can take to truly professionalise our workflow: packaging.
You might think, “I’m a data scientist, not a software engineer. Isn’t this overkill?” The answer is a definitive no. Packaging your code, even for an internal analysis project, provides enormous benefits. Instead of copying and pasting your clean_data() function from project to project, you can simply import mypackage or library(mypackage) and use a single, trusted version. Sharing your work with a colleague becomes as simple as sending them a single command rather than emailing a zip file of scripts. Packaging also forces you into a standardised way of documenting your functions, provides a formal framework for running unit tests, and explicitly declares all of your dependencies.
This chapter will walk you through the process of creating a simple package in both R and Python. You will learn how to create, document, and test a basic R package using {devtools} and {usethis}, how to do the same for a modern Python package using uv and pytest, and how to install your own packages directly from GitHub so you can share your tools with colleagues and your future self. Finally, we will show how to integrate your packages into a T pipeline, turning them into a first-class part of your reproducible workflows. The goal is not to become an expert package developer, but to understand the structure and benefits so you can apply this powerful “packaging mindset” to all your future projects.
12.2 Part 1: Creating an R Package with {usethis} and {devtools}
The R community has developed an outstanding set of tools that make package development incredibly streamlined. The two essential packages are:
{devtools}: provides core development tools likeinstall(),test(), andcheck().{usethis}: a workflow package that automates all the boilerplate. It creates files, sets up infrastructure, and guides you through the process.
Let’s build a package called cleanR, which will contain a function to standardise column names. Create a folder called cleanR and cd into it.
12.2.1 Step 1: Project Setup
A T project is not only for pipelines: you can also use one purely as a reproducible development environment. Initialise a T project in this folder and declare the R packages you need for package development in tproject.toml:
[r-dependencies]
packages = ["devtools", "usethis", "roxygen2"]Run t update to generate the Nix files, then enter the development shell with nix develop. Let {usethis} create the package structure for you. From your R console, run:
usethis::create_package("~/Documents/projects/cleanR")This will create a new cleanR directory with all the necessary files and subdirectories. It will also open a new RStudio session for that project. The key components are:
R/: this is where your R source code files will live.DESCRIPTION: a metadata file describing your package, its author, licence, and dependencies.NAMESPACE: a file that declares which functions your package exports for users and which functions it imports from other packages. You should never edit this file by hand.{roxygen2}will manage it for you.
12.2.2 Step 2: Write and Document a Function
Let’s create our function. {usethis} helps with this too:
usethis::use_r("clean_names")This creates a new file R/clean_names.R and opens it for editing. Let’s add our function, including special comments for documentation. These #' comments are used by the {roxygen2} package to automatically generate the official documentation.
# In R/clean_names.R
#' Clean and Standardize Column Names
#'
#' This function takes a data frame and returns a new data frame with
#' cleaned-up column names (lowercase, with underscores instead of spaces
#' or periods).
#'
#' @param df A data frame.
#' @return A data frame with standardized column names.
#' @export
#' @examples
#' messy_df <- data.frame("First Name" = c("Ada", "Bob"), "Last.Name" = c("Lovelace", "Ross"))
#' clean_names(messy_df)
clean_names <- function(df) {
old_names <- names(df)
new_names <- tolower(old_names)
new_names <- gsub("[ .]", "_", new_names)
names(df) <- new_names
return(df)
}The key tags here are:
@param: describes a function argument.@return: describes what the function returns.@export: this is crucial. It tells R that you want this function to be available to users when they load your package withlibrary(cleanR).@examples: provides runnable examples that will appear in the help file.
Now, run the magic command to process these comments:
devtools::document()This updates the NAMESPACE file and creates the help file (man/clean_names.Rd). You can now see your function’s help page with ?clean_names.
12.2.3 Step 3: Add Unit Tests
A package without tests is a package waiting to break. {usethis} makes setting up tests trivial.
usethis::use_testthat() # Sets up the tests/testthat/ directory
usethis::use_test("clean_names") # Creates tests/testthat/test-clean_names.RNow, edit the test file to add your expectations.
# In tests/testthat/test-clean_names.R
test_that("clean_names works with spaces and periods", {
messy_df <- data.frame("First Name" = c("A"), "Last.Name" = c("B"))
cleaned_df <- clean_names(messy_df)
expected_names <- c("first_name", "last_name")
expect_equal(names(cleaned_df), expected_names)
})
test_that("clean_names handles already clean names", {
clean_df <- data.frame(a = 1, b = 2)
# The function should not change anything
expect_equal(names(clean_names(clean_df)), c("a", "b"))
})To run all the tests for your package, use:
devtools::test()12.2.4 Step 4: Check and Install
The final step before sharing is to run the official R CMD check, the gold standard for package quality. This command runs all tests, checks documentation, and looks for common problems.
devtools::check()If your package passes with 0 errors, 0 warnings, and 0 notes, you are in great shape. If your package raises NOTEs or WARNINGs during the check phase, you can most of the time safely ignore these, especially if the package is only intended for internal usage. However, I would recommend that you still take care of the WARNINGs at the very least.
As a next step, you could edit the DESCRIPTION file. This is where you will list yourself as the package author, list the dependencies of the package and so on. I won’t get into detail here, but learning how to edit the DESCRIPTION file is important for actual package development (especially listing dependencies is key).
To use your package within a project, the simplest way is to host it on GitHub or build a .tar.gz file and install it locally.
12.2.5 Step 5: Install from GitHub
If you can publicly host your package, hosting it on GitHub is a good way to easily share your code, and install the package in your projects without needing to publish it on CRAN.
Create a new, empty repository on GitHub (e.g.,
cleanR).In your local project, follow the instructions GitHub provides to link your local repository and push your code. This usually involves commands like:
git remote add origin git@github.com:yourusername/cleanR.git git branch -M main git push -u origin mainNow, anyone (including you on a different machine) can install your package with a single command (if you don’t use Nix):
# You might need to install {remotes} first # install.packages("remotes") remotes::install_github("yourusername/cleanR")
If you want to create an environment using Nix that includes this package, declare it in your tproject.toml as a Git reference, exactly as shown in Part 3, run t update, and re-enter the shell with nix develop.
Congratulations, you have created and shared a fully functional R package!
12.2.6 Step 5bis: Install it locally
If you can’t share your package on GitHub, the alternative is to build it locally using:
devtools::build()which will create a .tar.gz package. You can then install it with devtools::install_local() if you don’t use Nix. If you do, the simplest approach is to push the package to a (possibly private) Git repository and declare it in your tproject.toml as in Part 3.
12.3 Part 2: Creating a Minimal Python Package with uv
There are many different ways to build packages in Python, and what I propose here is just one way to do it. While we will use Nix to manage our overall environment, we still need to define the metadata and structure for our Python package. We will use uv, an extremely fast and modern tool, for one specific purpose: initialising our project’s configuration file. We will not use uv to manage a virtual environment, as Nix already handles that for us (unless you absolutely want to: however, you should then make sure that uv itself is being managed by Nix to ensure reproducibility).
Let’s build a Python package called pyclean, the equivalent of our R package.
12.3.1 Step 1: Project Setup with uv
First, set up a T project that provides a Python environment. In tproject.toml, declare the Python packages you need and uv as an additional tool:
[py-dependencies]
version = "python313"
packages = ["pytest", "pandas"]
[additional-tools]
packages = ["uv"]Run t update and enter the development shell with nix develop.
Then, create a directory for your new package and initialise it:
mkdir pyclean
cd pyclean
uv init --bareThe --bare flag is perfect for our Nix workflow. It creates only the essential pyproject.toml file without creating a virtual environment or extra directories. This leaves us with a clean slate.
Now, we must create the source and test directories manually. We’ll use the standard src layout:
mkdir -p src/pyclean
mkdir tests
touch src/pyclean/__init__.pyYour project structure should now look like this (check it using the tree command):
pyclean/
├── pyproject.toml
├── src/
│ └── pyclean/
│ └── __init__.py
└── tests/
12.3.2 Step 2: Write a Function and Declare Dependencies
Let’s create our clean_names function inside a new file, src/pyclean/formatters.py.
# In src/pyclean/formatters.py
import pandas as pd
def clean_names(df: pd.DataFrame) -> pd.DataFrame:
"""Clean and standardize column names of a DataFrame.
Args:
df: The input pandas DataFrame.
Returns:
A pandas DataFrame with standardized column names.
"""
new_df = df.copy()
new_cols = {col: col.lower().replace(" ", "_").replace(".", "_") for col in new_df.columns}
new_df = new_df.rename(columns=new_cols)
return new_dfTo make this function easily importable, we expose it in src/pyclean/__init__.py:
# In src/pyclean/__init__.py
from .formatters import clean_names
__all__ = ["clean_names"]Next, we must declare our dependencies by manually editing pyproject.toml. We need pandas for our function and pytest for our tests.
# In pyproject.toml
[project]
name = "pyclean"
version = "0.1.0"
description = "A simple package to clean data."
dependencies = [
"pandas>=2.0.0",
]
[project.optional-dependencies]
test = [
"pytest",
]
[tool.pytest.ini_options]
pythonpath = [
"src"
]The pythonpath = ["src"] line is very important. Without it, you’d first need to install your pyclean library in editable mode using pip before running the tests. By adding this block, simply running pytest from the command line will work.
12.3.3 Step 3: Add Unit Tests
Create a new test file, tests/test_formatters.py, and add your tests.
# In tests/test_formatters.py
import pandas as pd
from pyclean import clean_names
def test_clean_names_happy_path():
messy_df = pd.DataFrame({"First Name": ["Ada"], "Last.Name": ["Lovelace"]})
cleaned_df = clean_names(messy_df)
expected_cols = ["first_name", "last_name"]
assert list(cleaned_df.columns) == expected_cols
def test_clean_names_is_idempotent():
clean_df = pd.DataFrame({"first_name": ["a"], "last_name": ["b"]})
still_clean_df = clean_names(clean_df)
assert list(still_clean_df.columns) == list(clean_df.columns)Since your Nix environment provides all the tools, you can run tests directly from your terminal:
pytest12.3.4 Step 4: Build and Install
To package your code, you need a build tool. It turns out that uv bundles a build tool with it, so we only need to call uv build:
# In your terminal, from the root of the 'pyclean' project
uv buildThis creates a dist/ directory containing a source distribution (.tar.gz) and a compiled wheel (.whl). The wheel is the modern standard for distribution.
Outside of a Nix shell, to use your package during development, you can install it in “editable” mode. This creates a link to your source code, so any changes you make are immediately reflected without needing to reinstall.
# Install the package and its test dependencies
pip install -e .[test]But we are working from a Nix shell. Instead, we will edit the project’s flake.nix to update the PYTHONPATH environment variable, so our package can easily be found. Open flake.nix, find the definition of the development shell, and add a shellHook attribute:
shellHook = ''
export PYTHONPATH=$PWD/src:$PYTHONPATH
'';The shellHook runs every time the shell starts. Note that t update regenerates flake.nix, so if you re-run it you will need to re-apply the hook.
With this, dropping into the shell with nix develop, starting the Python interpreter and then typing import pyclean will work without any issues. You may need to adapt the path depending on where you’re developing the package.
12.3.5 Step 5: Install from GitHub
Sharing via GitHub is the most common way to distribute packages that aren’t on the official Python Package Index (PyPI):
- Create a new, empty repository on GitHub.
- Push your local project to the remote repository.
- Now, anyone can install your package directly from GitHub using
pip, which is smart enough to find and process yourpyproject.tomlfile:bash pip install git+https://github.com/yourusername/pyclean.git
For Nix environments, the cleanest approach is the one described in Part 3: declare pyclean as a direct Git reference in your project’s Python workspace, lock it with uv, and run t update. This process is naturally more involved than simply calling pip install, but it has the advantage of being entirely reproducible.
12.4 Part 3: Integrating Your Packages into a T Pipeline
Throughout this book, your T pipelines have declared their runtime dependencies in tproject.toml (R packages under [r-dependencies] and Python packages under [py-dependencies], Chapter 4) and you have built pipelines from those dependencies (Chapter 8). Here is the payoff of the packaging work you just did: you can now drop your own packages into that same mechanism and call them from any node.
12.4.1 Adding an R Package
For a package that is already on CRAN, list it in the packages array, exactly as in Chapter 4:
[r-dependencies]
packages = ["dplyr", "ggplot2"]For a package you host on GitHub, such as the cleanR package from Part 1, declare it as a named entry with a git URL and a full commit rev (see the T project development guide, section 3.3.3):
[r-dependencies]
packages = ["dplyr"]
cleanR = { git = "https://github.com/yourusername/cleanR", rev = "abc123def456" }The rev field must be the full 40-character commit hash of the version you want to use. After editing tproject.toml, run t update to regenerate flake.nix, then re-enter the shell with nix develop. The package is injected into every R node’s buildInputs, so library(cleanR) works inside any rn() node.
12.4.2 Adding a Python Package
For a package on PyPI, the default Nix resolver is the simplest option:
[py-dependencies]
packages = ["pandas", "scikit-learn"]If your package is only on GitHub, like pyclean from Part 2, and is not on PyPI, switch to the uv resolver (Chapter 4). This makes a pyproject.toml and a uv.lock file the source of truth for your Python dependencies:
[py-dependencies]
resolver = "uv"
workspace = "python"Create the workspace directory and declare pyclean as a direct Git reference:
mkdir python# python/pyproject.toml
[project]
name = "my_project_python_env"
version = "0.1.0"
requires-python = ">=3.13,<3.14"
dependencies = [
"pandas",
"pyclean @ git+https://github.com/yourusername/pyclean.git",
]Generate the lock file and regenerate the flake (run these from the Nix shell where uv is available):
uv lock --project python
t updateCommit python/pyproject.toml, python/uv.lock, and tproject.toml together. From then on, import pyclean works inside any pyn() node. To add or remove a dependency later, edit dependencies in python/pyproject.toml, re-run uv lock --project python and t update, and commit the updated files.
12.5 Conclusion: The Packaging Mindset in the Age of AI
You have now successfully created, tested, documented, and shared a basic package in both R and Python. While there is much more to learn about advanced package development, you have already mastered the most important part: the packaging mindset.
From now on, when you start a new analysis project, think of it as a small, internal package.
- Put your reusable logic into functions.
- Place those functions in the
R/ormypackage/source directory. - Document them.
- Write a few simple tests to prove they work.
- Manage dependencies formally in
DESCRIPTIONorpyproject.toml.
Adopting this structure will make your work more robust, easier to share, and fundamentally more reproducible. It is the bridge between writing one-off scripts and building reliable, professional data science tools.
This packaging mindset becomes even more powerful when you introduce a modern collaborator: the LLM. The structured, component-based nature of a package is the perfect way to interact with AI assistants.
A package provides a clear contract and a well-defined structure that LLMs thrive on. Instead of a vague prompt like, “Refactor my messy analysis script,” you can now make precise, targeted requests:
- “Here is my function
clean_names. Please write threepytestunit tests for it, including one for the happy path, one for an empty DataFrame, and one for names that are already clean.” - “Generate the roxygen2 documentation skeleton for this R function, including
@param,@return, and@examplestags.” - “I need a function in my
pyclean/utils.pymodule that calculates the Z-score for a pandas Series. Please generate the function and its docstring.”
This synergy is a two-way street. Not only does the structure help you write better prompts, but LLMs excel at generating the very boilerplate that makes packaging robust. Tedious tasks like writing standard documentation headers, creating skeleton unit test files, or even generating a first draft of a function based on a clear description become near-instantaneous.
This elevates your role from a writer of code to an architect and a reviewer. Your job is to design the components (the functions), prompt the LLM to generate the implementation, and then, most critically, use the testing framework you just built to rigorously verify that the AI-generated code is correct, efficient, and robust. You are the final authority, and the package structure gives you the tools to enforce quality control.
By combining the discipline of packaging with the power of LLMs, you lower the barrier to adopting best practices like comprehensive testing and documentation. This combination doesn’t just make you faster; it makes you a more reliable and professional data scientist, capable of producing tools that are truly reproducible and built to last.
While a full guide to package development is beyond the scope of this course, it is the natural next step in your journey as a data scientist who produces reliable tools. When you are ready to take that step, here are the definitive resources to guide you:
- For R: the “R Packages” (2e) book by Hadley Wickham and Jennifer Bryan is the essential, comprehensive guide. It covers everything from initial setup with
{usethis}to testing, documentation, and submission to CRAN. Read it online here.1 - For Python: the official Python Packaging User Guide2 is the place to start. For a more modern and streamlined approach that handles dependency management and publishing, many developers use tools like Poetry3 or Hatch4.
Treating your data analysis project like a small, internal software package, complete with functions and tests, is a powerful mindset that will elevate the quality and reliability of your work.