Skip to content

Commit

Permalink
Initial documentation and functionality (#1)
Browse files Browse the repository at this point in the history
* create_info and wrapper example, delegation

* fix delegation + tests

* path in init

* test dependencies

* tweak CI to run on min compat, release and nightly

* README text

* coverage

* add a token

Co-authored-by: Alex Arslan <ararslan@comcast.net>
  • Loading branch information
palday and ararslan committed Dec 11, 2020
1 parent f202a11 commit bc1a70d
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 10 deletions.
18 changes: 15 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
name: CI
on:
- push
- pull_request
push:
paths-ignore:
- 'README.md'
branches:
- main
pull_request:
paths-ignore:
- 'README.md'
branches:
- main
jobs:
test:
name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }}
Expand All @@ -11,7 +19,7 @@ jobs:
matrix:
version:
- '1.0'
- '1.5'
- '1'
- 'nightly'
os:
- ubuntu-latest
Expand All @@ -37,3 +45,7 @@ jobs:
${{ runner.os }}-
- uses: julia-actions/julia-buildpkg@v1
- uses: julia-actions/julia-runtest@v1
- uses: julia-actions/julia-uploadcodecov@v0.1
if: ${{ startsWith(matrix.os, 'ubuntu') && (matrix.version == '1') }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
7 changes: 6 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ uuid = "6c5003b2-cbe8-491c-a0d1-70088e6a0fd6"
authors = ["Beacon Biosignals, Inc."]
version = "0.1.0"

[deps]
PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"

[compat]
PyCall = "1.90"
julia = "1"

[extras]
PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["Test"]
test = ["PyCall", "Test"]
192 changes: 191 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,194 @@
# PyMNE
Julia interface to MNE-Python via PyCall

[![Build Status](https://github.com/''/PyMNE.jl/workflows/CI/badge.svg)](https://github.com/''/PyMNE.jl/actions)
[![Build Status][build-img]][build-url] [![CodeCov][codecov-img]][codecov-url]

[build-img]: https://github.com/beacon-biosignals/PyMNE.jl/workflows/CI/badge.svg
[build-url]: https://github.com/beacon-biosignals/PyMNE.jl/actions
[codecov-img]: https://codecov.io/github/beacon-biosignals/PyMNE.jl/badge.svg?branch=master
[codecov-url]: https://codecov.io/github/beacon-biosignals/PyMNE.jl?branch=master


## Installation
This package uses [`PyCall`](https://github.com/JuliaPy/PyCall.jl/) to make
[MNE-Python](https://mne.tools) available from within Julia. Unsurprisingly,
MNE-Python and its dependencies need to be installed in order for this to work
and PyMNE will attempt to install when the package is built.

By default, this installation happens in the "global" path for the Python used
by PyCall. If you're using PyCall via its hidden Miniconda install, your own
Anaconda environment, or a Python virtual environment, this is what you want.
(The "global" path is sandboxed to the Conda/virtual environment.) If you are
however using system Python, then you should set `ENV["PIPFLAGS"] = "--user"`
before `add`ing / `build`ing the package. By default, PyMNE will use the latest
MNE release available on [PyPI](https://pypi.org/project/mne/), but this can also
be changed via the `ENV["MNEVERSION"] = version_number` for your preferred
`version_number`. Note that PyMNE explicitly does not try to abstract out
the rather rapid API changes and deprecation cycle in MNE and as such, it is
incumbent upon the user to manage these versions accordingly.


MNE-Python can also be installed them manually ahead of time.
From the shell, use `python -m pip install mne` for the latest stable release
or `python -m pip install mne==version_number` for a given `version_number`,
ensuring that `python` is the same one that PyCall is using. Alternatively,
you can run this from within Julia:
```julia
using PyCall
pip = pyimport("pip")
pip.main(["install", "mne==version_number"]) # specific version
```

If you do not specify a version via `==version`, then the latest versions will be
installed. If you wish to upgrade versions, you can use
`python -m pip install --upgrade mne` or
```julia
using PyCall
pip = pyimport("pip")
pip.main(["install", "--upgrade", "mne"])
```

You can test your setup with `using PyCall; pyimport("mne")`.

## Usage

In the same philosophy as PyCall, this allows for the transparent use of
MNE-Python from within Julia.
The major things the package does are wrap the installation of MNE in the
package `build` step, load all the MNE functionality into the module namespace,
and provide a few accessors.


### Exposing MNE-Python in Julia

For example, in Python you can access the MNE docs like this:

```python
import mne

mne.open_docs()
```

With PyMNE, you can do this from within Julia.

```julia
using PyMNE

PyMNE.open_docs()
```

The PyCall infrastructure also means that Python docstrings are available
in Julia:

```julia
help?> PyMNE.open_docs
Launch a new web browser tab with the MNE documentation.

Parameters
----------
kind : str | None
Can be "api" (default), "tutorials", or "examples".
The default can be changed by setting the configuration value
MNE_DOCS_KIND.
version : str | None
Can be "stable" (default) or "dev".
The default can be changed by setting the configuration value
MNE_DOCS_VERSION.
```

### Helping with type conversions

PyCall can be rather aggressive in converting standard types, such as
dictionaries, to their native Julia equivalents, but this can create problems
due to differences in the way inheritance is traditionally used between
languages.
As a concrete example, MNE-Python defines an `Info` type that extends the
Python dictionary.
If an `Info` object is accessed naively from Julia, then it is converted to a
dictionary and the subtyping is lost when passed back to Python, which can
result in type/method errors.
(There is [some discussion](https://github.com/JuliaPy/PyCall.jl/issues/629)
about not automatically converting derived types in PyCall 2.0, exactly
because of this.)
To avoid this problem, PyMNE wraps a few methods to avoid this conversion,
namely Python's `mne.create_info` and the `info` property of many MNE types.

```julia
julia> using PyMNE
julia> dat = zeros(1, 100); # fake data
julia> PyMNE.mne # direct access to the mne Python module without any wrapping
PyObject <module 'mne' from '/home/ubuntu/.julia/conda/3/lib/python3.8/site-packages/mne/__init__.py'>
julia> naive_info = PyMNE.mne.create_info([:a], 100) # gets converted to a Julia dictionary
Dict{Any,Any} with 36 entries:
"projs" => Any[]
"utc_offset" => nothing
"dev_head_t" => Dict{Any,Any}("trans"=>[1.0 0.0 0.0 0.0; 0.0 1.0 0.0 0.0; 0.0 0.0 1.0 0.0; 0.0 0.0 0.0 1.0],"to"=>4,"from"=>1)
"experimenter" => nothing
"proj_name" => nothing
"nchan" => 1
"ctf_head_t" => nothing
"acq_stim" => nothing
"events" => Any[]
"lowpass" => 50.0
"helium_info" => nothing
"proc_history" => Any[]
"xplotter_layout" => nothing
"dig" => nothing
"kit_system_id" => nothing
"file_id" => nothing
=>
julia> PyMNE.io.RawArray(dat, naive_info) # RawArray requires an Info object and not a 'simple' dictionary
ERROR: PyError ($(Expr(:escape, :(ccall(#= /home/ubuntu/.julia/packages/PyCall/BcTLp/src/pyfncall.jl:43 =# @pysym(:PyObject_Call), PyPtr, (PyPtr, PyPtr, PyPtr), o, pyargsptr, kw))))) <class 'TypeError'>
TypeError("info must be an instance of Info, got <class 'dict'> instead")
File "<decorator-gen-158>", line 21, in __init__
File "/home/ubuntu/.julia/conda/3/lib/python3.8/site-packages/mne/io/array/array.py", line 56, in __init__
_validate_type(info, 'info', 'info')
File "/home/ubuntu/.julia/conda/3/lib/python3.8/site-packages/mne/utils/check.py", line 379, in _validate_type
raise TypeError('%s must be an instance of %s, got %s instead'

Stacktrace:
[1] pyerr_check at /home/ubuntu/.julia/packages/PyCall/BcTLp/src/exception.jl:62 [inlined]
. . .

julia> wrapped_info = PyMNE.create_info([:a], 100) # preserves Python type and show method
PyObject <Info | 7 non-empty values
bads: []
ch_names: a
chs: 1 MISC
custom_ref_applied: False
highpass: 0.0 Hz
lowpass: 50.0 Hz
meas_date: unspecified
nchan: 1
projs: []
sfreq: 100.0 Hz
>

julia> raw = PyMNE.io.RawArray(dat, wrapped_info) # now has right type
Creating RawArray with float64 data, n_channels=1, n_times=100
Range : 0 ... 99 = 0.000 ... 0.990 secs
Ready.
PyObject <RawArray | 1 x 100 (1.0 s), ~8 kB, data loaded>
```
This also leads to the only exported function `get_info`, which is just a
type-preserving accessor the `info` property of many MNE types:
```julia
julia> get_info(raw)
PyObject <Info | 7 non-empty values
bads: []
ch_names: a
chs: 1 MISC
custom_ref_applied: False
highpass: 0.0 Hz
lowpass: 50.0 Hz
meas_date: unspecified
nchan: 1
projs: []
sfreq: 100.0 Hz
>
```
If other automatic type conversions are found to be problematic or there are
particular MNE functions that don't play nice via the default PyCall mechanisms,
then issues and pull requests are welcome.
1 change: 1 addition & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
comment: off
6 changes: 4 additions & 2 deletions deps/build.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
using PyCall
pip = pyimport("pip")
flags = split(get(ENV, "PIPFLAGS", ""))
packages = ["mne==0.20.8"]
ver = get(ENV, "MNEVERSION", "")
packages = ["""mne$(isempty(ver) ? "" : "==")$(ver)"""]

@info "Package requirements:" packages
@info "Flags for pip install:" flags

ver = isempty(ver) ? "latest" : ver
@info "MNE version:" ver
pip.main(["install"; flags; packages])
37 changes: 36 additions & 1 deletion src/PyMNE.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
module PyMNE

# Write your package code here.
#####
##### Dependencies
#####

using PyCall

#####
##### Exports
#####

export get_info

######
###### Actual functionality
######

const mne = PyNULL()

# TODO: examine how to do wrappers for subsubmodules. for now it's not a huge deal
# because the wrapper just gets put in the top-level package namespace and so
# is still accessible

include("wrappers.jl")

function __init__()
# all of this is __init__() so that it plays nice with precompilation
# see https://github.com/JuliaPy/PyCall.jl/#using-pycall-from-julia-modules
copy!(mne, pyimport("mne"))
# delegate everything else to mne
for pn in propertynames(mne)
isdefined(@__MODULE__, pn) && continue
prop = getproperty(mne, pn)
@eval $pn = $prop
end
return nothing
end

end # module
21 changes: 21 additions & 0 deletions src/wrappers.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""
create_info(args...; kwargs...)
A type-preserving wrapper around `mne.create_info`.
See [`PyMNE.mne.create_info`](@ref) for the associated MNE docstring.
"""
function create_info(args...; kwargs...)
# `mne.create_info(...)` on its own gives us back a Julia `Dict`, but we actually
# want the Python object, so we have to use `pycall`
return pycall(mne.create_info, PyObject, args...; kwargs...)
end

"""
get_info(::PyObject)
Extract an `Info` property from an MNE object while preserving Python type.
"""
get_info(o::PyObject) = o."info"

# TODO: add a converting for Julia Dict to Python Info <: Dict
14 changes: 12 additions & 2 deletions test/runtests.jl
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
using PyMNE
using PyCall
using Test

@testset "PyMNE.jl" begin
# Write your tests here.
using PyCall: PyError

@testset "create_info and get_info" begin
dat = zeros(1, 100)
naive_info = PyMNE.mne.create_info([:a], 100)
wrapped_info = PyMNE.create_info([:a], 100)
raw = PyMNE.io.RawArray(dat, wrapped_info)
@test raw.get_data() == dat
@test get_info(raw) isa PyObject
@test raw.info isa Dict
@test_throws PyError PyMNE.io.RawArray(dat, naive_info)
end

2 comments on commit bc1a70d

@palday
Copy link
Member Author

@palday palday commented on bc1a70d Dec 14, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JuliaRegistrator register()

@JuliaRegistrator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registration pull request created: JuliaRegistries/General/26361

After the above pull request is merged, it is recommended that a tag is created on this repository for the registered package version.

This will be done automatically if the Julia TagBot GitHub Action is installed, or can be done manually through the github interface, or via:

git tag -a v0.1.0 -m "<description of version>" bc1a70d720af2a05382c02b1ce857f72354a752c
git push origin v0.1.0

Please sign in to comment.