11 - Developing Julia packages
Understanding modules
The main objective of modules is to provide separate namespaces for global names in Julia.
The basic structure of a module is:
Module names are customary starting with a capital letter and the module content is usually not indented. Modules can be entered in the REPL as normal Julia code or in a script that is imported with include("file.jl")
.
There is no connection between a given file and a given module as in other languages, so the logical structure of a program can be decoupled from its actual division in files. For example, one file could contain multiple modules.
A more common way to use modules is by loading a package that will consists, at least, of a module with the same name of the package.
All modules are children of the module Main
, the default module for global objects in Julia, and each module defines its own set of global names.
Note: using
and import
, when they are followed with either Main.x
or .x
, look for a module already loaded and bring it and its exported objects into scope (for import
only those explicitly specified). Otherwise, they do a completely different job: they expects a package, and the package system lookups the correct version of the module x
embedded inside package x
, it loads it, and it bring it and its exported objects into scope (again, for import x
only those explicitly specified).
Modules can include submodules, although there exists a large opinion in the Julia community that this, unless it is really necessary, should be avoided, and use the dot notation to indicate the hierarchy.
The following example uses the Reexport package that provides an handy method to re-export automatically all the objects of an imported module.
Assuming we have loaded it into memory (e.g. by typing it directly in the REPL) we can now use the module in several ways:
You can read more about modules in the modules section of the official documentation.
Developing Julia packages
A package is nothing else that a collection of one or more hierarchically connected modules (at least one with the same name of the package acting on top of the hierarchy) logically connected to provide a given functionality, enriched with metadata that make the discovery, usage and interconnection of modules much simpler. Hence, before developing a package, be sure you fully understand how modules behave in Julia, as described in the previous section.
Note: As in the rest of the tutorial, when you read julia> command
the command has to be issued in a Julia session, while with (@v1.X) pkg> command
the command has to be issued in the special "package" mode of Julia (type ]
in a Julia session to access it,[DEL]
to return to the Julia main prompt).
We assume that we want to create a package using GitHub as repository host and continuous integration tools. We also assume we are using Julia >= v.1.2.0. We will go trough the process of creating the package from scratch and implement automatic testing (next section), document our newly created package (folowing section) and finally register it within the official Julia Register, so that other people can find and install the package easily (last section).
Creating the package and implementing tests
First, we create the repository in GitHub, considering a package name that follows the Julia's package naming guidelines and, by custom, calling the repository by the package name followed by the ".jl" prefix. For example, in this tutorial we chose as package name MyAwesomePackage
, and the corresponding GitHub repository will be MyAwesomePackage.jl
.
Don't forget to add a readme (so we can already clone the repository) and choose Julia
as gitignore template:
Tip: Are you in a rush? Fork MyAwesomePackage.jl and adapt it to your needs.
The repository doesn't yet contain the minimum set of information that allows it to be downloaded as a "Julia package", hence we will first generate a basic package structure locally, using the Julia tool generate
. We will then commit and link this git repository with the remote GitHub repository we just made, push the basic package structure to GitHub and, at that point, we can download it again as a Julia Package and continue its development.
So, let's generate our package locally. We cd to a directory where we want the new package to appear as a subfolder (for example our Desktop), enter the Julia prompt and type (@v1.X) pkg> generate MyAwesomePackage
.
This will create a new folder MyAwesomePackage
with a src
subfolder that includes a "Hello world" version of our awesome package and, most importantly, the Project.toml
file. For now, this includes just: (a) the name of the package; (b) the author; (c ) the unique id that has been assigned to the new package (this is a code that depends from stuff like the MAC address, the exact time, the process id, etc...) and (d) the initial version of the package. Check that the author and initial version are ok for you (for example I prefer to start a package with version 0.0.1
rather than the default 0.1.0
).
Project.toml
will also hold the dependencies that our package will require. So, for example, let's assume that MyAwesomePackage
depends from the packages LinearAlgebra
(a standard lib package) and DataFrames
(a popular third-party package to work with tabular data, aka "dataframes").
We first "activate" the new MyAwesomePackage
folder with (@v1.X) pkg> activate(FULL_PATH/MyAwesomePackage)
. We can now add the two packages with (MyAwesomePackage) pkg> add LinearAlgebra DataFrames
. From the message in output, we take note that the DataFrame version is v0.22.2
. Going back to our new Project.toml
file, the two packages should have been added to the [deps]
section. We now want to specify the version of the third-party packages our MyAwesomePackage
works with, and in particular, if we want to register it in the official Julia registry, we need to specify an upper version. We can do that by adding to Project.toml
a new [compat]
section where we write down DataFrames = "0.21, 0.22"
, that is we allow MyAwesomePackage
to work with any DataFrames
in the v0.21.x
or v0.22.x
series. In other words, we assume that the functionality our package depends from has been introduced in DataFrames
v0.21.O
, and it is still available in the v0.22.x
serie.
We also add julia = "1.2.1"
assuming that our package works only with Julia >= v1.2.1
within the v1.x
serie. Finally, we now need to specify the versions also of the standard lib packages, as they become independent packages that may be in the future updated independently of the Julia core itself. We hance add in this case LinearAlgebra = "1.2.1"
to the [compat]
section.
NOTE: All Julia versions in the v1.x
serie are "guaranteed" to be backward compatible with code wrote for Julia v1.0.0
, but the reverse is not true, e.g. new features could still be added in the v1.x
serie so that certain packages work only with them.
On one side this way to manage dependencies brings a considerable burden on the package maintainer, as it is his responsibility to determine which exact versions of the dependent package his package works with, but on the other side it guarantees a smooth usage of his/her package. More information on Julia semantic rules can be found here and in particular the requirements for automatic merging of the package in the registry are given here.
Project.toml
should look at the end similar to this :
Our new package is now ready to be linked with the repository we created in GitHub. While in this tutorial we will use a terminal, you can just use the github web interface to copy the files generate
has produced to your github repository.
Let leave Julia for now and go to the local repository with a terminal and run the following commands in the terminal:
Once we have the package in GitHub, we can add it to our Julia local installation with (@v1.X) pkg> add git@github.com:sylvaticus/MyAwesomePackage.jl.git
and (@v1.X) pkg> dev MyAwesomePackage
. The package should now located in [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage
, where [USER_HOME_FOLDER]
is the home folder for the local user, e.g. ~
in Linux or %HOMEPATH%
in Windows and we can cancel the original location where we generated it (for example in our Desktop).
Before we add a test suite, let's add a minimum of functionality to our package. I highly advise you to use the Revise package. When you import Revise
in a new Julia session before importing a package it lets you account for changes that you make when you save on disk any modification you make on the package without having to restart the Julia session.
So let's add a function plusTwo
to our package by editing MyAwesomePackage/src/MyAwesomePackage.jl
as follow:
We can now add a test suite to our package. We add the following file (and folder) [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/test/runtests.jl
:
Before we can run the test we still need to tell Julia that when testing the package, the standard lib Test
needs to be added to the package:
From now on, to add a dependency:
to the package itself -->
activate [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage
and(MyAwesomePackage) pkg> add MyDependencyPackage
--> the new package will be recorded in the[deps]
section of[USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/Project.toml
(don't forget to add a upper bound if the package is not in the standard library)to the testing system only (i.e. something the package doesn't rely to, but it is needed for testing, for example a package to load a specific datasets) -->
activate [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/test
and(test) pkg> add MyDependencyPackage
--> the new package will be recorded in the[deps]
section of[USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/test/Project.toml
Before we git commit
and git push
our MyAwesomePackage
, let's add a functionality that at each time the package is pushed, github performs an operation (github calls these operation "actions"), and in particular it automatically runs the test for us.
Let's add the folder/file [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/.github/workflows/ci.yml
:
The above script, on top of running the tests, will (1) initialise the service codecov to highlight the lines of code that are actually covered by the testing (you will need to first authorise codecov with the "Add new repository" on its web interface - after you log in), and (2) will start the building of the package's documentation (see the following section). If you need to add some tests that are "allowed" to fail (typically, to test on Julia night builds), you can write a similar workflow file (see an example).
At this point we can add the first "badges" on our package README.md:
The first one concerns the building status and the second one the line coverage.
Having an upper limit of the dependencies means that our package, if installed, would "limit" that dependencies in the user's environment. We hence need to continuously check for new versions of the dependencies or it will ends that our package constrains the users to keep old packages installed on his/her system. Something that comes in handy is the following action: it checks for us if any of our dependencies has a new version and open for us a pull request to account for such version in our project's Project.toml
. But attention, it is still up to us to check that indeed the new version works with our package!
[USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/.github/workflows/CompatHelper.yml
:
Also, please note that this action is automatically triggered at any given interval. GitHub stops this kinds of cron-based actions for inactive repositories, sending a warning email to you to prevent it. So, until a more solid solution is found, check your email if your repository is no longer actively developed!
With out package in [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/
we can continue to develop it, commit the changes and push them to GitHub (behind the scenes, the add
and dev
Julia package commands did actually cloned the GitHub repository to .julia/dev/MyAwesomePackage/
, so we can use directly git commands on that directory, like git commit -a -m "Commit message"
or git push
).
This conclude the section about developing (and testing) a package. You can read more details here, including how to specify a personalised building workflow.
Documenting our package
It is a good practice to document your own functions. You can use triple quoted strings (""") just before the function to document and use Markdown syntax on it. The Julia documentation recommends that you insert a simplified version of the function, together with an Arguments
and an Examples
sections.
For example, this is the documentation string for our plusTwo
function.
The documentation string for a function or a module should be placed exactly on top of the item to document (no empty lines) and it will be rendered when a user type ?<FUNCTION OR MODULE NAME>
or in the documentation pages that we are going to build.
In order to process to the building of the documentation pages, we produce a skeleton of the documentation configuration files using the following commands:
This should produce an output similar to the following:
While generate()
produce a basic documentation configuration we are going to extend it a bit in order to have (a) multiple pages, (b) automatic inclusion of all documented items and (c ) hosting (deployment) of the documentation in GitHub pages. All these steps are documented in deep in the Documenter
own documentation. Note that the docs
folder has its own Project.toml
, so you can add documentation-specific packages there.
Let's then change the [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/docs/make.jl
:
Let's consider [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/docs/anotherPage.md
:
In anotherPage.md
we first document the module, we then create an "index" of all the documented items, and we finally detail them.
I also suggests to edit the index.md
to provide a link back to the github repository.
We can check that the documentation compiles as desired by running the make.jl as a script from within the docs folder:
This should have produced a static build of the documentation on [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/docs/build
. We finally "solve" the warning above and take care of documentation's deployment, whose steps are documented in detail here.
Now copy the first key (the public part starting with "ssh-rsa") to https://github.com/YOUR_USERNAME/MyAwesomePackage.jl/settings/keys -> "Add deploy key" using "documenter" as title of the key and allowing write access to the key.
Finally (almost) in https://github.com/YOUR_USERNAME/MyAwesomePackage.jl/settings/secrets/actions add a new repository secret with "DOCUMENTER_KEY" as secret name and the second, long key from the output of the command above as its value.
We can add the two badges for the stable and development versions of the documentation to the readme:
When we push our commits to GitHub, the CI.yml action that we set earlier (a) build the documentation by running "make.jl" and (b) deploy it to the github pages. The documentation is built on its own branch, gh-pages
. This branch is added automatically during the process of deploying the documentation the first time. At the same time the "source branch" for the documentation pages in the repository settings (https://github.com/YOUR_USERNAME/MyAwesomePackage.jl/settings) should already be set to gh-pages
(see the GitHub pages documentation to set it manually if this for any reason doesn't happen).
For now, the documentation is available on https://YOUR_USERNAME.github.io/MyAwesomePackage.jl/dev, in the branch gh-pages
. The first time we will make a release, its documentation will be available under the stable
directory.
Registration of our package
We can finally register our Package, but before let's add a further action to it, the TagBot Action [USER_HOME_FOLDER]/.julia/dev/MyAwesomePackage/.github/workflows/TagBot.yml
, documented here:
When we register a new release in the Julia registry, TagBot will create a corresponding release on our package's GitHub repository.
We are now ready to register the package, with the simplest way being to use the web app "Registrator".
Simply click "Install app" and authorise it to the GitHub repositories you wish (or on all your repositories if you prefer).
Now we can register the package with a simple @JuliaRegistrator register
"command" in the comment section of the commit we want to register.
We can also write detailed release notes by adding a Release notes:
after the @JuliaRegistrator
command, for example:
Congratulation ! A "robot" will respond to your command confirming the opening of the pull request in the Julia registry and, if the package meet all the requirements, it should soon automatically registered (once a 3 days waiting period has elapsed) and everyone will be able to simply add and use it with (@v1.X) pkg> add MyAwesomePackage
. If something goes wrong - in our case let's say that we forgot to put the upper bound for Julia itself - you will receive an informative message explaining what went wrong and we can try the registration again (issuing an other @JuliaRegistrator register
"command", that in turn will update the pull request).
Further development of the package
In the most basic situation, we would keep the main
branch locally, continue our development, and when we consider it is "time" to release a new version we just change the version
information in the package Project.toml
, we commit/push the new "version" and we raise the @JuliaRegistrator register
"command" again in GitHub. This will have the effect to register the new version in the Julia official Register, with TagBot creating an equivalent version on the GitHub repository.
Last updated