tugboat-py 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tugboat_py-0.1.0/PKG-INFO +206 -0
- tugboat_py-0.1.0/README.md +193 -0
- tugboat_py-0.1.0/pyproject.toml +30 -0
- tugboat_py-0.1.0/src/tugboat/__init__.py +8 -0
- tugboat_py-0.1.0/src/tugboat/binderize.py +100 -0
- tugboat_py-0.1.0/src/tugboat/build.py +109 -0
- tugboat_py-0.1.0/src/tugboat/create.py +61 -0
- tugboat_py-0.1.0/src/tugboat/utils.py +69 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: tugboat-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple utilities to generate a Dockerfile from a directory or project, build the corresponding Docker image, push the image to DockerHub, and publicly share the project via Binder.
|
|
5
|
+
Author: Daniel Molitor
|
|
6
|
+
Author-email: Daniel Molitor <molitdj97@gmail.com>
|
|
7
|
+
Requires-Dist: pigar>=2.2.0
|
|
8
|
+
Requires-Dist: pygit2>=1.19.3
|
|
9
|
+
Requires-Dist: pyperclip>=1.11.0
|
|
10
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# tugboat <!-- <img src='man/figures/logo-no-bg.png' align="right" height="140"/> -->
|
|
15
|
+
|
|
16
|
+
<!-- badges: start -->
|
|
17
|
+
<!-- badges: end -->
|
|
18
|
+
|
|
19
|
+
A simple Python package to generate a Dockerfile and corresponding Docker image
|
|
20
|
+
from an analysis directory. tugboat also prepares your analysis repository to be
|
|
21
|
+
shared via [Binder](https://mybinder.readthedocs.io/en/latest/index.html).
|
|
22
|
+
|
|
23
|
+
tugboat uses the [pigar](https://github.com/damnever/pigar) package to automatically
|
|
24
|
+
detect all the packages necessary to replicate your analysis and will generate
|
|
25
|
+
a Dockerfile that contains an exact copy of your entire directory with all
|
|
26
|
+
the packages installed. tugboat transforms an unstructured analysis folder into a `requirements.txt` file
|
|
27
|
+
and constructs a Docker image that includes all your essential R packages
|
|
28
|
+
based on this file. tugboat utilizes [uv](https://docs.astral.sh/uv/) under the hood;
|
|
29
|
+
as a result, projects that already utilize uv should be directly compatible with no
|
|
30
|
+
additional setup.
|
|
31
|
+
|
|
32
|
+
tugboat may be of use, for example, when preparing a replication package for
|
|
33
|
+
research. With tugboat, you can take a directory on your local computer
|
|
34
|
+
and quickly generate a corresponding Dockerfile and Docker image that contains all the
|
|
35
|
+
code and the necessary software to reproduce your findings.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Install tugboat from GitHub:
|
|
40
|
+
```r
|
|
41
|
+
pip install git+https://github.com/dmolitor/tugboat-py
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
tugboat has three primary functions; one to create a Dockerfile from your
|
|
47
|
+
analysis directory, one to build the corresponding Docker image, and one to make
|
|
48
|
+
your project ready to share and run in an online, interactive compute environment
|
|
49
|
+
via [Binder](https://mybinder.readthedocs.io/en/latest/index.html).
|
|
50
|
+
|
|
51
|
+
### Create the Dockerfile
|
|
52
|
+
|
|
53
|
+
The primary function from tugboat is `create()`. This function converts
|
|
54
|
+
your analysis directory into a Dockerfile that includes all your code
|
|
55
|
+
and essential Python packages.
|
|
56
|
+
|
|
57
|
+
This function scans all files in the current analysis directory,
|
|
58
|
+
attempts to detect all Python packages, and installs these packages in
|
|
59
|
+
the resulting Docker image. It also copies the entire contents of the
|
|
60
|
+
analysis directory into the Docker image. For example, if
|
|
61
|
+
your analysis directory is named `incredible_analysis`, the corresponding
|
|
62
|
+
location of your code and data files in the generated Docker image will
|
|
63
|
+
be `/incredible_analysis`.
|
|
64
|
+
|
|
65
|
+
For the most common use-cases, there are a couple of arguments in this
|
|
66
|
+
function that are particularly important:
|
|
67
|
+
|
|
68
|
+
- `project`: This argument tells tugboat which directory is the one to generate
|
|
69
|
+
the Dockerfile from. You can set this value yourself, or you can just use
|
|
70
|
+
the default value. By default, tugboat uses the working directory to
|
|
71
|
+
determine the analysis directory.
|
|
72
|
+
- `exclude`: A list of files or sub-directories in your analysis directory
|
|
73
|
+
that should ***NOT*** be included in the Docker image. This is particularly
|
|
74
|
+
important when you have, for example, a sub-directory with large data files
|
|
75
|
+
that would make the resulting Docker image extremely large if included. You
|
|
76
|
+
can tell tugboat to exclude this sub-directory and then simply mount it to
|
|
77
|
+
a Docker container as needed.
|
|
78
|
+
|
|
79
|
+
Below I'll outline a couple examples.
|
|
80
|
+
```python
|
|
81
|
+
from tugboat import create
|
|
82
|
+
|
|
83
|
+
# The simplest scenario where your analysis directory is your current
|
|
84
|
+
# working directory, you are fine with the default base "python:3.x-slim"
|
|
85
|
+
# Docker image, and you want to include all files/directories:
|
|
86
|
+
create()
|
|
87
|
+
|
|
88
|
+
# Suppose your analysis directory is actually a sub-directory of your
|
|
89
|
+
# main project directory:
|
|
90
|
+
create(project="./sub-directory")
|
|
91
|
+
|
|
92
|
+
# Suppose that you specifically need a Docker base image that has uv
|
|
93
|
+
# installed. To do this, we will explicitly specify a different Docker
|
|
94
|
+
# base image using the `FROM` argument.
|
|
95
|
+
create(FROM="ghcr.io/astral-sh/uv:latest")
|
|
96
|
+
|
|
97
|
+
# Finally, suppose that we want to include all files except a couple
|
|
98
|
+
# particularly data-heavy sub-directories:
|
|
99
|
+
create(exclude=["data/big_directory_1", "data/big_directory_2"])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Build the Docker image
|
|
103
|
+
|
|
104
|
+
Once the Dockerfile has been created, we can build the Docker image
|
|
105
|
+
with the `build()` function. By default this will assume the Dockerfile
|
|
106
|
+
is located in the current working directory. This function assumes a little knowledge
|
|
107
|
+
about Docker; if you aren't sure where to start,
|
|
108
|
+
[this is a great starting point](https://colinfay.me/docker-r-reproducibility/).
|
|
109
|
+
|
|
110
|
+
The following example will do the simplest thing and will build the
|
|
111
|
+
image locally.
|
|
112
|
+
```python
|
|
113
|
+
build(image_name="awesome_analysis")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Suppose that, like above, your analysis directory is a sub-directory of
|
|
117
|
+
your main project directory:
|
|
118
|
+
```r
|
|
119
|
+
build(
|
|
120
|
+
dockerfile="./sub-directory",
|
|
121
|
+
build_context="./sub-directory",
|
|
122
|
+
image_name="awesome_analysis"
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Push to DockerHub
|
|
127
|
+
|
|
128
|
+
If, instead of just building the Docker image locally, you want to build
|
|
129
|
+
the image and then push to DockerHub, you can make a couple small additions
|
|
130
|
+
to the code above:
|
|
131
|
+
```python
|
|
132
|
+
import os
|
|
133
|
+
import dotenv
|
|
134
|
+
from tugboat import build
|
|
135
|
+
|
|
136
|
+
load_dotenv()
|
|
137
|
+
|
|
138
|
+
build(
|
|
139
|
+
dockerfile="./sub-directory",
|
|
140
|
+
build_context="./sub-directory",
|
|
141
|
+
image_name="awesome_analysis"
|
|
142
|
+
image_name="awesome_analysis",
|
|
143
|
+
push=True,
|
|
144
|
+
dh_username=os.environ["DOCKERHUB_USERNAME"],
|
|
145
|
+
dh_password=os.environ["DOCKERHUB_USERNAME"]
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Note: If you choose to push, you also need to provide your DockerHub
|
|
150
|
+
username and password. Typically you don't want to pass these in
|
|
151
|
+
directly and should instead use environment variables (or a similar
|
|
152
|
+
method) instead.
|
|
153
|
+
|
|
154
|
+
### Share your project via Binder
|
|
155
|
+
|
|
156
|
+
Binder lets others instantly launch and interact with your R project in a
|
|
157
|
+
live, cloud-based environment with no local setup required. tugboat will
|
|
158
|
+
prepare your project to be shared with Binder. The process is simple:
|
|
159
|
+
|
|
160
|
+
- First, create the Dockerfile from your analysis directory:
|
|
161
|
+
|
|
162
|
+
``` python
|
|
163
|
+
create(
|
|
164
|
+
project=".",
|
|
165
|
+
exclude=["data/big_directory_1", "data/big_directory_2"]
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- Then, prep your directory for Binder. Your analysis directory _must_ be
|
|
170
|
+
a GitHub repository:
|
|
171
|
+
|
|
172
|
+
``` python
|
|
173
|
+
binderize(branch="main")
|
|
174
|
+
```
|
|
175
|
+
By default this will add a Binder badge to your README.md file if it already has a section for badges:
|
|
176
|
+
|
|
177
|
+
``` python
|
|
178
|
+
Added badge to /.../README.md
|
|
179
|
+
```
|
|
180
|
+
If your README file does _not_ have a section for badges, it will automatically
|
|
181
|
+
save the badge to your clipboard and you will need to manually insert it
|
|
182
|
+
into the README.
|
|
183
|
+
|
|
184
|
+
``` python
|
|
185
|
+
Add the following to your README.md file:
|
|
186
|
+
|
|
187
|
+
<!-- badges: start -->
|
|
188
|
+
[](https://mybinder.org/v2/gh/{username}/{repo}/{branch}?urlpath=rstudio)
|
|
189
|
+
<!-- badges: end -->
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
After running `binderize()` you will see the following message:
|
|
193
|
+
```
|
|
194
|
+
Your repository has been configured for Binder.
|
|
195
|
+
[x] Commit and push all changes
|
|
196
|
+
[x] Launch Binder at: https://mybinder.org/v2/gh/{username}/{repo}/{branch}?urlpath=rstudio
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
You must commit and push all changes _before_ visiting the Binder link,
|
|
200
|
+
otherwise it will likely fail. Binder can automatically detect changes
|
|
201
|
+
to the repository and will rebuild as necessary, ensuring that the Binder
|
|
202
|
+
repository stays up to date.
|
|
203
|
+
|
|
204
|
+
## R package
|
|
205
|
+
|
|
206
|
+
This package has a sibling [R package](https://github.com/dmolitor/tugboat)!
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# tugboat <!-- <img src='man/figures/logo-no-bg.png' align="right" height="140"/> -->
|
|
2
|
+
|
|
3
|
+
<!-- badges: start -->
|
|
4
|
+
<!-- badges: end -->
|
|
5
|
+
|
|
6
|
+
A simple Python package to generate a Dockerfile and corresponding Docker image
|
|
7
|
+
from an analysis directory. tugboat also prepares your analysis repository to be
|
|
8
|
+
shared via [Binder](https://mybinder.readthedocs.io/en/latest/index.html).
|
|
9
|
+
|
|
10
|
+
tugboat uses the [pigar](https://github.com/damnever/pigar) package to automatically
|
|
11
|
+
detect all the packages necessary to replicate your analysis and will generate
|
|
12
|
+
a Dockerfile that contains an exact copy of your entire directory with all
|
|
13
|
+
the packages installed. tugboat transforms an unstructured analysis folder into a `requirements.txt` file
|
|
14
|
+
and constructs a Docker image that includes all your essential R packages
|
|
15
|
+
based on this file. tugboat utilizes [uv](https://docs.astral.sh/uv/) under the hood;
|
|
16
|
+
as a result, projects that already utilize uv should be directly compatible with no
|
|
17
|
+
additional setup.
|
|
18
|
+
|
|
19
|
+
tugboat may be of use, for example, when preparing a replication package for
|
|
20
|
+
research. With tugboat, you can take a directory on your local computer
|
|
21
|
+
and quickly generate a corresponding Dockerfile and Docker image that contains all the
|
|
22
|
+
code and the necessary software to reproduce your findings.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
Install tugboat from GitHub:
|
|
27
|
+
```r
|
|
28
|
+
pip install git+https://github.com/dmolitor/tugboat-py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
tugboat has three primary functions; one to create a Dockerfile from your
|
|
34
|
+
analysis directory, one to build the corresponding Docker image, and one to make
|
|
35
|
+
your project ready to share and run in an online, interactive compute environment
|
|
36
|
+
via [Binder](https://mybinder.readthedocs.io/en/latest/index.html).
|
|
37
|
+
|
|
38
|
+
### Create the Dockerfile
|
|
39
|
+
|
|
40
|
+
The primary function from tugboat is `create()`. This function converts
|
|
41
|
+
your analysis directory into a Dockerfile that includes all your code
|
|
42
|
+
and essential Python packages.
|
|
43
|
+
|
|
44
|
+
This function scans all files in the current analysis directory,
|
|
45
|
+
attempts to detect all Python packages, and installs these packages in
|
|
46
|
+
the resulting Docker image. It also copies the entire contents of the
|
|
47
|
+
analysis directory into the Docker image. For example, if
|
|
48
|
+
your analysis directory is named `incredible_analysis`, the corresponding
|
|
49
|
+
location of your code and data files in the generated Docker image will
|
|
50
|
+
be `/incredible_analysis`.
|
|
51
|
+
|
|
52
|
+
For the most common use-cases, there are a couple of arguments in this
|
|
53
|
+
function that are particularly important:
|
|
54
|
+
|
|
55
|
+
- `project`: This argument tells tugboat which directory is the one to generate
|
|
56
|
+
the Dockerfile from. You can set this value yourself, or you can just use
|
|
57
|
+
the default value. By default, tugboat uses the working directory to
|
|
58
|
+
determine the analysis directory.
|
|
59
|
+
- `exclude`: A list of files or sub-directories in your analysis directory
|
|
60
|
+
that should ***NOT*** be included in the Docker image. This is particularly
|
|
61
|
+
important when you have, for example, a sub-directory with large data files
|
|
62
|
+
that would make the resulting Docker image extremely large if included. You
|
|
63
|
+
can tell tugboat to exclude this sub-directory and then simply mount it to
|
|
64
|
+
a Docker container as needed.
|
|
65
|
+
|
|
66
|
+
Below I'll outline a couple examples.
|
|
67
|
+
```python
|
|
68
|
+
from tugboat import create
|
|
69
|
+
|
|
70
|
+
# The simplest scenario where your analysis directory is your current
|
|
71
|
+
# working directory, you are fine with the default base "python:3.x-slim"
|
|
72
|
+
# Docker image, and you want to include all files/directories:
|
|
73
|
+
create()
|
|
74
|
+
|
|
75
|
+
# Suppose your analysis directory is actually a sub-directory of your
|
|
76
|
+
# main project directory:
|
|
77
|
+
create(project="./sub-directory")
|
|
78
|
+
|
|
79
|
+
# Suppose that you specifically need a Docker base image that has uv
|
|
80
|
+
# installed. To do this, we will explicitly specify a different Docker
|
|
81
|
+
# base image using the `FROM` argument.
|
|
82
|
+
create(FROM="ghcr.io/astral-sh/uv:latest")
|
|
83
|
+
|
|
84
|
+
# Finally, suppose that we want to include all files except a couple
|
|
85
|
+
# particularly data-heavy sub-directories:
|
|
86
|
+
create(exclude=["data/big_directory_1", "data/big_directory_2"])
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Build the Docker image
|
|
90
|
+
|
|
91
|
+
Once the Dockerfile has been created, we can build the Docker image
|
|
92
|
+
with the `build()` function. By default this will assume the Dockerfile
|
|
93
|
+
is located in the current working directory. This function assumes a little knowledge
|
|
94
|
+
about Docker; if you aren't sure where to start,
|
|
95
|
+
[this is a great starting point](https://colinfay.me/docker-r-reproducibility/).
|
|
96
|
+
|
|
97
|
+
The following example will do the simplest thing and will build the
|
|
98
|
+
image locally.
|
|
99
|
+
```python
|
|
100
|
+
build(image_name="awesome_analysis")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Suppose that, like above, your analysis directory is a sub-directory of
|
|
104
|
+
your main project directory:
|
|
105
|
+
```r
|
|
106
|
+
build(
|
|
107
|
+
dockerfile="./sub-directory",
|
|
108
|
+
build_context="./sub-directory",
|
|
109
|
+
image_name="awesome_analysis"
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Push to DockerHub
|
|
114
|
+
|
|
115
|
+
If, instead of just building the Docker image locally, you want to build
|
|
116
|
+
the image and then push to DockerHub, you can make a couple small additions
|
|
117
|
+
to the code above:
|
|
118
|
+
```python
|
|
119
|
+
import os
|
|
120
|
+
import dotenv
|
|
121
|
+
from tugboat import build
|
|
122
|
+
|
|
123
|
+
load_dotenv()
|
|
124
|
+
|
|
125
|
+
build(
|
|
126
|
+
dockerfile="./sub-directory",
|
|
127
|
+
build_context="./sub-directory",
|
|
128
|
+
image_name="awesome_analysis"
|
|
129
|
+
image_name="awesome_analysis",
|
|
130
|
+
push=True,
|
|
131
|
+
dh_username=os.environ["DOCKERHUB_USERNAME"],
|
|
132
|
+
dh_password=os.environ["DOCKERHUB_USERNAME"]
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Note: If you choose to push, you also need to provide your DockerHub
|
|
137
|
+
username and password. Typically you don't want to pass these in
|
|
138
|
+
directly and should instead use environment variables (or a similar
|
|
139
|
+
method) instead.
|
|
140
|
+
|
|
141
|
+
### Share your project via Binder
|
|
142
|
+
|
|
143
|
+
Binder lets others instantly launch and interact with your R project in a
|
|
144
|
+
live, cloud-based environment with no local setup required. tugboat will
|
|
145
|
+
prepare your project to be shared with Binder. The process is simple:
|
|
146
|
+
|
|
147
|
+
- First, create the Dockerfile from your analysis directory:
|
|
148
|
+
|
|
149
|
+
``` python
|
|
150
|
+
create(
|
|
151
|
+
project=".",
|
|
152
|
+
exclude=["data/big_directory_1", "data/big_directory_2"]
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- Then, prep your directory for Binder. Your analysis directory _must_ be
|
|
157
|
+
a GitHub repository:
|
|
158
|
+
|
|
159
|
+
``` python
|
|
160
|
+
binderize(branch="main")
|
|
161
|
+
```
|
|
162
|
+
By default this will add a Binder badge to your README.md file if it already has a section for badges:
|
|
163
|
+
|
|
164
|
+
``` python
|
|
165
|
+
Added badge to /.../README.md
|
|
166
|
+
```
|
|
167
|
+
If your README file does _not_ have a section for badges, it will automatically
|
|
168
|
+
save the badge to your clipboard and you will need to manually insert it
|
|
169
|
+
into the README.
|
|
170
|
+
|
|
171
|
+
``` python
|
|
172
|
+
Add the following to your README.md file:
|
|
173
|
+
|
|
174
|
+
<!-- badges: start -->
|
|
175
|
+
[](https://mybinder.org/v2/gh/{username}/{repo}/{branch}?urlpath=rstudio)
|
|
176
|
+
<!-- badges: end -->
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
After running `binderize()` you will see the following message:
|
|
180
|
+
```
|
|
181
|
+
Your repository has been configured for Binder.
|
|
182
|
+
[x] Commit and push all changes
|
|
183
|
+
[x] Launch Binder at: https://mybinder.org/v2/gh/{username}/{repo}/{branch}?urlpath=rstudio
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
You must commit and push all changes _before_ visiting the Binder link,
|
|
187
|
+
otherwise it will likely fail. Binder can automatically detect changes
|
|
188
|
+
to the repository and will rebuild as necessary, ensuring that the Binder
|
|
189
|
+
repository stays up to date.
|
|
190
|
+
|
|
191
|
+
## R package
|
|
192
|
+
|
|
193
|
+
This package has a sibling [R package](https://github.com/dmolitor/tugboat)!
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tugboat-py"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Simple utilities to generate a Dockerfile from a directory or project, build the corresponding Docker image, push the image to DockerHub, and publicly share the project via Binder."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Daniel Molitor", email = "molitdj97@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pigar>=2.2.0",
|
|
12
|
+
"pygit2>=1.19.3",
|
|
13
|
+
"pyperclip>=1.11.0",
|
|
14
|
+
"python-dotenv>=1.2.2",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
tugboat-py = "tugboat:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.9.22,<0.10.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[tool.uv.build-backend]
|
|
25
|
+
module-name = "tugboat"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"black>=26.5.1",
|
|
30
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import pyperclip
|
|
3
|
+
from pygit2 import Repository
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
BADGE_URL = "https://mybinder.org/badge_logo.svg"
|
|
7
|
+
DEFAULT_IMAGE = "rocker/binder:4"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _use_badge(
|
|
11
|
+
label: str,
|
|
12
|
+
href: str,
|
|
13
|
+
image_url: str,
|
|
14
|
+
readme: str | Path = "README.md",
|
|
15
|
+
add_readme_badge: bool = True,
|
|
16
|
+
) -> str:
|
|
17
|
+
readme = Path(readme)
|
|
18
|
+
badge = f"[]({href})"
|
|
19
|
+
start = "<!-- badges: start -->"
|
|
20
|
+
end = "<!-- badges: end -->"
|
|
21
|
+
instructions = f"{start}\n" f"{badge}\n" f"{end}"
|
|
22
|
+
|
|
23
|
+
# Copy instructions to the clipboard and also print them
|
|
24
|
+
def copy_instructions() -> None:
|
|
25
|
+
pyperclip.copy(instructions)
|
|
26
|
+
print("Add the following to your README.md file:\n\n" + instructions)
|
|
27
|
+
print("\nCopied to clipboard.")
|
|
28
|
+
|
|
29
|
+
# Determine whether to modify README.md or just return instructions
|
|
30
|
+
if not add_readme_badge:
|
|
31
|
+
return copy_instructions()
|
|
32
|
+
if not readme.exists():
|
|
33
|
+
return copy_instructions()
|
|
34
|
+
# Modify README.md
|
|
35
|
+
text = readme.read_text()
|
|
36
|
+
if badge in text:
|
|
37
|
+
print(f"Badge already exists in {readme}")
|
|
38
|
+
return badge
|
|
39
|
+
if start not in text or end not in text:
|
|
40
|
+
return copy_instructions()
|
|
41
|
+
before, rest = text.split(start, maxsplit=1)
|
|
42
|
+
badges, after = rest.split(end, maxsplit=1)
|
|
43
|
+
badges = badges.rstrip() + f"\n{badge}\n"
|
|
44
|
+
readme.write_text(f"{before}{start}{badges}{end}{after}")
|
|
45
|
+
print(f"Added badge to {readme}")
|
|
46
|
+
return badge
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _binder_dockerfile() -> str:
|
|
50
|
+
dock = f"""FROM {DEFAULT_IMAGE}""" + """
|
|
51
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
|
52
|
+
COPY --chown=${NB_USER} . /home/rstudio
|
|
53
|
+
WORKDIR /home/rstudio
|
|
54
|
+
USER root
|
|
55
|
+
RUN printf "RETICULATE_PYTHON_ENV=/home/rstudio/.venv\\nVIRTUAL_ENV=/home/rstudio/.venv\\n" >> /usr/local/lib/R/etc/Renviron.site
|
|
56
|
+
USER ${NB_USER}
|
|
57
|
+
RUN test -f pyproject.toml || uv init --app . || true
|
|
58
|
+
RUN uv sync --all-groups --all-extras
|
|
59
|
+
RUN uv add -r requirements-tugboat.txt"""
|
|
60
|
+
return dock
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def binderize(
|
|
64
|
+
project: Path | str = Path("."),
|
|
65
|
+
branch: str = "main",
|
|
66
|
+
urlpath: str = "rstudio",
|
|
67
|
+
add_readme_badge: bool = True,
|
|
68
|
+
overwrite: bool = True,
|
|
69
|
+
verbose: bool = False,
|
|
70
|
+
) -> None:
|
|
71
|
+
# Create repo object and extract url, username, repo name, etc.
|
|
72
|
+
repo = Repository(project)
|
|
73
|
+
git_remote = repo.remotes["origin"].url
|
|
74
|
+
local_repo = Path(repo.workdir)
|
|
75
|
+
username_repo = re.sub(r".*github\.com[:/](.*)\.git$", r"\1", git_remote).split("/")
|
|
76
|
+
if git_remote.find("github.com") == -1:
|
|
77
|
+
raise ValueError("Only GitHub repositories are currently supported.")
|
|
78
|
+
# Generate Dockerfile
|
|
79
|
+
dock = _binder_dockerfile()
|
|
80
|
+
if verbose:
|
|
81
|
+
print(dock)
|
|
82
|
+
binder_dir = local_repo / ".binder"
|
|
83
|
+
if not binder_dir.is_dir():
|
|
84
|
+
binder_dir.mkdir()
|
|
85
|
+
dockerfile_path = binder_dir / "Dockerfile"
|
|
86
|
+
if overwrite:
|
|
87
|
+
dockerfile_path.write_text(dock)
|
|
88
|
+
# Construct Binder badge and insert into README (if possible)
|
|
89
|
+
binder_url = f"https://mybinder.org/v2/gh/{'/'.join(username_repo)}/{branch}?urlpath={urlpath}"
|
|
90
|
+
_use_badge(
|
|
91
|
+
label="Launch RStudio Binder",
|
|
92
|
+
href=binder_url,
|
|
93
|
+
image_url=BADGE_URL,
|
|
94
|
+
readme=local_repo / "README.md",
|
|
95
|
+
add_readme_badge=add_readme_badge,
|
|
96
|
+
)
|
|
97
|
+
# Give the user final instructions
|
|
98
|
+
print("Your repository has been configured for Binder.")
|
|
99
|
+
print("[x] Commit and push all changes")
|
|
100
|
+
print("[x] Launch Binder at: ", binder_url)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess as sp
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .utils import is_windows, stop_if_docker_not_installed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _copy_build_context_to_temp(build_context: str) -> str | None:
|
|
11
|
+
if not is_windows():
|
|
12
|
+
return None
|
|
13
|
+
tmp = tempfile.TemporaryDirectory()
|
|
14
|
+
tmp_path = Path(tmp.name)
|
|
15
|
+
build_context = Path(build_context)
|
|
16
|
+
for item in build_context.iterdir():
|
|
17
|
+
dest = tmp_path / item.name
|
|
18
|
+
if item.is_dir():
|
|
19
|
+
shutil.copytree(item, dest)
|
|
20
|
+
else:
|
|
21
|
+
shutil.copy2(item, dest)
|
|
22
|
+
return tmp.name
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_image(
|
|
26
|
+
dockerfile: str,
|
|
27
|
+
platforms: List[str] | str,
|
|
28
|
+
repository: str,
|
|
29
|
+
tag: str,
|
|
30
|
+
build_args: List[str] | None,
|
|
31
|
+
build_context: str,
|
|
32
|
+
push: bool,
|
|
33
|
+
verbose: bool,
|
|
34
|
+
) -> None:
|
|
35
|
+
tmp = _copy_build_context_to_temp(build_context)
|
|
36
|
+
if not isinstance(platforms, list):
|
|
37
|
+
platforms = [platforms]
|
|
38
|
+
try:
|
|
39
|
+
if tmp is not None:
|
|
40
|
+
build_context = tmp
|
|
41
|
+
exec_args = [
|
|
42
|
+
"docker",
|
|
43
|
+
"buildx",
|
|
44
|
+
"build",
|
|
45
|
+
"-f",
|
|
46
|
+
str(Path(dockerfile).resolve()),
|
|
47
|
+
"--platform",
|
|
48
|
+
",".join(platforms),
|
|
49
|
+
"-t",
|
|
50
|
+
f"{repository}:{tag}",
|
|
51
|
+
]
|
|
52
|
+
if build_args:
|
|
53
|
+
exec_args.extend(build_args)
|
|
54
|
+
exec_args.append(str(build_context))
|
|
55
|
+
if push:
|
|
56
|
+
exec_args.append("--push")
|
|
57
|
+
if verbose:
|
|
58
|
+
print("Building:")
|
|
59
|
+
print(" ".join(exec_args))
|
|
60
|
+
result = sp.run(exec_args)
|
|
61
|
+
if result.returncode != 0:
|
|
62
|
+
raise RuntimeError(f"Build failed with status: {result.returncode}")
|
|
63
|
+
finally:
|
|
64
|
+
if tmp is not None:
|
|
65
|
+
tmp.cleanup()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build(
|
|
69
|
+
dockerfile: str = Path(".") / "Dockerfile",
|
|
70
|
+
image_name: str = "tugboat",
|
|
71
|
+
tag: str = "latest",
|
|
72
|
+
platforms: List[str] | str = ["linux/amd64", "linux/arm64"],
|
|
73
|
+
build_args: List[str] | None = None,
|
|
74
|
+
build_context: str = str(Path(".").resolve()),
|
|
75
|
+
push: bool = False,
|
|
76
|
+
dh_username: str | None = None,
|
|
77
|
+
dh_password: str | None = None,
|
|
78
|
+
verbose: bool = False,
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Build a Docker image from a Dockerfile"""
|
|
81
|
+
stop_if_docker_not_installed()
|
|
82
|
+
if push:
|
|
83
|
+
if dh_username is None or dh_password is None:
|
|
84
|
+
raise RuntimeError("Both `dh_username` and `dh_password` must be provided")
|
|
85
|
+
login_result = sp.run(
|
|
86
|
+
["docker", "login", "-u", dh_username, "--password-stdin"],
|
|
87
|
+
input=dh_password,
|
|
88
|
+
text=True,
|
|
89
|
+
check=True,
|
|
90
|
+
)
|
|
91
|
+
if login_result.returncode != 0:
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
f"Docker login failed with status: {login_result.returncode}"
|
|
94
|
+
)
|
|
95
|
+
if dh_username is None:
|
|
96
|
+
repository = image_name
|
|
97
|
+
else:
|
|
98
|
+
repository = f"{dh_username}/{image_name}"
|
|
99
|
+
_build_image(
|
|
100
|
+
dockerfile=dockerfile,
|
|
101
|
+
platforms=platforms,
|
|
102
|
+
repository=repository,
|
|
103
|
+
tag=tag,
|
|
104
|
+
build_args=build_args,
|
|
105
|
+
build_context=build_context,
|
|
106
|
+
push=push,
|
|
107
|
+
verbose=verbose,
|
|
108
|
+
)
|
|
109
|
+
return f"{repository}:{tag}"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import sys
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from .utils import _generate
|
|
7
|
+
|
|
8
|
+
PY_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
9
|
+
DEFAULT_IMAGE = f"python:{PY_VERSION}-slim"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _dockerfile(
|
|
13
|
+
project_name: str | None = None,
|
|
14
|
+
project: str = str(Path(".").resolve()),
|
|
15
|
+
FROM: str | None = None,
|
|
16
|
+
) -> str:
|
|
17
|
+
if project_name is None:
|
|
18
|
+
project_dir = f"/{Path(project).name}"
|
|
19
|
+
else:
|
|
20
|
+
project_dir = f"/{project_name}"
|
|
21
|
+
if not FROM:
|
|
22
|
+
FROM = DEFAULT_IMAGE
|
|
23
|
+
dock = f"""FROM {FROM}
|
|
24
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
|
25
|
+
COPY . {project_dir}
|
|
26
|
+
WORKDIR {project_dir}
|
|
27
|
+
RUN test -f pyproject.toml || uv init --app . || true
|
|
28
|
+
RUN uv sync --all-groups --all-extras
|
|
29
|
+
RUN uv add -r requirements-tugboat.txt"""
|
|
30
|
+
return dock
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _dockerignore(project: str, exclude: List[str] | str | None = None) -> None:
|
|
34
|
+
if not isinstance(exclude, List) and exclude:
|
|
35
|
+
exclude = [exclude]
|
|
36
|
+
elif not exclude:
|
|
37
|
+
exclude = []
|
|
38
|
+
exclude = list(set(exclude + ["Dockerfile", ".dockerignore", "**/.DS_Store"]))
|
|
39
|
+
dockerignore_path = str(Path(project) / ".dockerignore")
|
|
40
|
+
with open(dockerignore_path, "w") as path:
|
|
41
|
+
path.writelines(f"{item}\n" for item in exclude)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create(
|
|
45
|
+
project: str = str(Path(".").resolve()),
|
|
46
|
+
FROM: str | None = None,
|
|
47
|
+
exclude: List[str] | str | None = None,
|
|
48
|
+
verbose: bool = False,
|
|
49
|
+
**kwargs,
|
|
50
|
+
) -> None:
|
|
51
|
+
project = os.path.abspath(project)
|
|
52
|
+
# Scan for dependencies and generate requirements.txt
|
|
53
|
+
_generate(project_path=project, **kwargs)
|
|
54
|
+
# Generate .dockerignore
|
|
55
|
+
_dockerignore(project=project, exclude=exclude)
|
|
56
|
+
# Generate Dockerfile
|
|
57
|
+
dock = _dockerfile(project=project, FROM=FROM)
|
|
58
|
+
if verbose:
|
|
59
|
+
print(dock)
|
|
60
|
+
dockerfile_path = Path(project) / "Dockerfile"
|
|
61
|
+
dockerfile_path.write_text(dock)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
2
|
+
import os
|
|
3
|
+
from pigar.dist import DEFAULT_PYPI_INDEX_URL
|
|
4
|
+
from pigar.parser import DEFAULT_GLOB_EXCLUDE_PATTERNS
|
|
5
|
+
from pigar.__main__ import generate as generate_cli
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DockerNotFoundError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run_in_thread(fn, *args, **kwargs):
|
|
16
|
+
"""Execute a function in a separate thread"""
|
|
17
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
18
|
+
return executor.submit(fn, *args, **kwargs).result()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_windows() -> bool:
|
|
22
|
+
return platform.system() == "Windows"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def stop_if_docker_not_installed() -> None:
|
|
26
|
+
"""Ensure Docker is installed"""
|
|
27
|
+
if not shutil.which("docker"):
|
|
28
|
+
raise DockerNotFoundError(
|
|
29
|
+
"Visit https://docs.docker.com/get-docker/ to get started!"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# `generate` is a Click (https://click.palletsprojects.com/en/stable/)
|
|
34
|
+
# cli object. The underlying `generate` function is stored at `generate.callback`
|
|
35
|
+
def _generate(
|
|
36
|
+
requirement_file: str = "requirements-tugboat.txt",
|
|
37
|
+
with_referenced_comments: bool = False,
|
|
38
|
+
comparison_specifier: str = list(("==", "~=", ">=", ">", "-"))[4],
|
|
39
|
+
show_differences: bool = True,
|
|
40
|
+
visit_doc_string: bool = False,
|
|
41
|
+
exclude_glob: List[str] = list(DEFAULT_GLOB_EXCLUDE_PATTERNS),
|
|
42
|
+
follow_symbolic_links: bool = True,
|
|
43
|
+
dry_run: bool = False,
|
|
44
|
+
index_url: str = DEFAULT_PYPI_INDEX_URL,
|
|
45
|
+
include_prereleases: bool = False,
|
|
46
|
+
question_answer: str = "yes",
|
|
47
|
+
auto_select: bool = False,
|
|
48
|
+
experimental_features: List[str] = [],
|
|
49
|
+
project_path: str = os.curdir,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Recreate an internal API for pigar's `generate` CLI function"""
|
|
52
|
+
kwargs = dict(
|
|
53
|
+
requirement_file=requirement_file,
|
|
54
|
+
with_referenced_comments=with_referenced_comments,
|
|
55
|
+
comparison_specifier=comparison_specifier,
|
|
56
|
+
show_differences=show_differences,
|
|
57
|
+
visit_doc_string=visit_doc_string,
|
|
58
|
+
exclude_glob=exclude_glob,
|
|
59
|
+
follow_symbolic_links=follow_symbolic_links,
|
|
60
|
+
dry_run=dry_run,
|
|
61
|
+
index_url=index_url,
|
|
62
|
+
include_prereleases=include_prereleases,
|
|
63
|
+
question_answer=question_answer,
|
|
64
|
+
auto_select=auto_select,
|
|
65
|
+
experimental_features=experimental_features,
|
|
66
|
+
project_path=project_path,
|
|
67
|
+
)
|
|
68
|
+
# Run in separate thread so this does NOT fail when run in Jupyter environment
|
|
69
|
+
_run_in_thread(generate_cli.callback, **kwargs)
|