Skip to content

Commit

Permalink
convert package to PythonCall instead of PyCall (#13)
Browse files Browse the repository at this point in the history
* convert package to PythonCall instead of PyCall

* cruft + ci

* YASG enforcer

* CI concurrency

* Win32

* Update README.md

Co-authored-by: Alex Arslan <[email protected]>
  • Loading branch information
palday and ararslan committed Jul 28, 2022
1 parent faa186c commit 2b5e35c
Show file tree
Hide file tree
Showing 13 changed files with 340 additions and 246 deletions.
37 changes: 37 additions & 0 deletions .github/workflows/YASG.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: YASG-enforcer
on:
push:
branches:
- 'main'
tags: '*'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
# note: keep in sync with `format/run.jl`
paths:
- 'src/**'
- 'test/**'
- '.github/workflows/YASG.yml'
- 'format/**'
jobs:
format-check:
name: YASG Enforcement (Julia ${{ matrix.julia-version }} - ${{ github.event_name }})
# Run on push's or non-draft PRs
if: (github.event_name == 'push') || (github.event.pull_request.draft == false)
runs-on: ubuntu-latest
strategy:
matrix:
julia-version: [1.7]
steps:
- uses: julia-actions/setup-julia@latest
with:
version: ${{ matrix.julia-version }}
- uses: actions/checkout@v1
- name: Instantiate `format` environment and format
run: |
julia --project=format -e 'using Pkg; Pkg.instantiate()'
julia --project=format 'format/run.jl'
- uses: reviewdog/action-suggester@v1
if: github.event_name == 'pull_request'
with:
tool_name: JuliaFormatter
fail_on_error: true
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
name: CI
concurrency:
group: ${{ github.head_ref }}.ci
cancel-in-progress: true
on:
push:
paths-ignore:
Expand All @@ -18,7 +21,7 @@ jobs:
fail-fast: false
matrix:
version:
- '1.0'
- '1.6'
- '1'
- 'nightly'
os:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ docs/site/
# committed for packages, but should be committed for applications that require a static
# environment.
Manifest.toml

.CondaPkg/
11 changes: 11 additions & 0 deletions CondaPkg.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
channels = ["anaconda", "conda-forge"]

[deps]
# Conda package names and versions
python = ">=3.7,<4"
# see https://mne.tools/stable/install/manual_install.html#installing-mne-python-with-all-dependencies
# CondaPkg uses mamba by default
mne = "=1.0"

[pip.deps]
# Pip package names and versions
16 changes: 10 additions & 6 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
name = "PyMNE"
uuid = "6c5003b2-cbe8-491c-a0d1-70088e6a0fd6"
authors = ["Beacon Biosignals, Inc."]
version = "0.1.2"
version = "0.2.0"

[deps]
PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
CondaPkg = "992eb4ea-22a4-4c89-a5bb-47a3300528ab"
PythonCall = "6099a3de-0909-46bc-b1f4-468b9a2dfc0d"
Reexport = "189a3867-3050-52da-a836-e630ba90ab69"

[compat]
PyCall = "1.90"
julia = "1"
CondaPkg = "0.2.11"
PythonCall = "0.9.4"
Reexport = "1"
julia = "1.6"

[extras]
PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["PyCall", "Test"]
test = ["Random", "Test"]
182 changes: 29 additions & 153 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# PyMNE
Julia interface to MNE-Python via PyCall
Julia interface to MNE-Python via PythonCall

[![Build Status][build-img]][build-url] [![CodeCov][codecov-img]][codecov-url]

Expand All @@ -10,55 +10,26 @@ Julia interface to MNE-Python via PyCall


## Installation
This package uses [`PyCall`](https://github.com/JuliaPy/PyCall.jl/) to make
This package uses [`PythonCall`](https://cjdoris.github.io/PythonCall.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.

Note that MNE-Python uses [scikit-learn](https://scikit-learn.org/stable/) for certain functionality (e.g. ICA and the `decoding` module), but does not install it automatically as a dependency.
If you wish to take advantage of this functionality, the non-exported `install_sklearn` function will install `sklearn`, using the same environment variables as the main installation.

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")`.
and PyMNE will attempt to install when the package is built: this should happen
more or less automatically via [`CondaPkg`](https://github.com/cjdoris/CondaPkg.jl).
You can configure various options via `CondaPkg`. MNE-Python is installed via
Conda, not via pip.

## Usage

In the same philosophy as PyCall, this allows for the transparent use of
In the same philosophy as PythonCall, 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.
package installation and load all the MNE functionality into the module
namespace.
After that, it's just a Python package accessible via `using PyMNE` in
Julia. The usual conversion rules and behaviors from PythonCall apply.
The [tests](test/runtests.jl) test a few conversion gotchas, especially
compared to prior versions of this package, which were based on
[PyCall](https://github.com/JuliaPy/PyCall.jl).


### Exposing MNE-Python in Julia
Expand All @@ -79,118 +50,23 @@ using PyMNE
PyMNE.open_docs()
```

The PyCall infrastructure also means that Python docstrings are available
The PythonCall 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>
Python function 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.
```
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.
12 changes: 0 additions & 12 deletions deps/build.jl

This file was deleted.

Loading

2 comments on commit 2b5e35c

@palday
Copy link
Member Author

@palday palday commented on 2b5e35c Jul 28, 2022

Choose a reason for hiding this comment

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

@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/65217

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.2.0 -m "<description of version>" 2b5e35c0fc3af9039f8062ce919d40cbe6113f51
git push origin v0.2.0

Please sign in to comment.