Skip to content

Commit 92ba417

Browse files
authored
feat: add pythonenv executor
* feat: add pythonenv executor * create cmds * make linter happy * add python formatter * improve permissions in linux * format * add desktop app * update to new requirements * use cobra instead * add comment * add logs * add list
1 parent aaa6165 commit 92ba417

File tree

9 files changed

+507
-2
lines changed

9 files changed

+507
-2
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ go.work.sum
2626

2727
# local tools directory
2828
.bin/
29+
30+
.venv/

cmd/pythonenv/main.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"log"
8+
"os"
9+
"os/user"
10+
"path/filepath"
11+
"runtime"
12+
"slices"
13+
"strings"
14+
15+
"github.com/docker/docker/api/types/container"
16+
dockerClient "github.com/docker/docker/client"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
const pythonImage = "arduino-pythonenv"
21+
22+
func main() {
23+
docker, err := dockerClient.NewClientWithOpts(dockerClient.FromEnv)
24+
if err != nil {
25+
panic(err)
26+
}
27+
defer docker.Close()
28+
29+
rootCmd := &cobra.Command{
30+
Use: "app",
31+
Short: "A CLI to manage the Python app",
32+
}
33+
34+
rootCmd.AddCommand(
35+
&cobra.Command{
36+
Use: "stop",
37+
Short: "Stop the Python app",
38+
Run: func(cmd *cobra.Command, args []string) {
39+
stopHandler(docker, args[0])
40+
},
41+
},
42+
&cobra.Command{
43+
Use: "start",
44+
Short: "Start the Python app",
45+
Run: func(cmd *cobra.Command, args []string) {
46+
startHandler(docker, args[0])
47+
},
48+
},
49+
&cobra.Command{
50+
Use: "logs",
51+
Short: "Show the logs of the Python app",
52+
Run: func(cmd *cobra.Command, args []string) {
53+
logsHandler(docker, args[0])
54+
},
55+
},
56+
&cobra.Command{
57+
Use: "list",
58+
Short: "List all running Python apps",
59+
Run: func(cmd *cobra.Command, args []string) {
60+
listHandler(docker)
61+
},
62+
},
63+
)
64+
65+
if err := rootCmd.Execute(); err != nil {
66+
log.Panic(err)
67+
}
68+
}
69+
70+
func startHandler(docker *dockerClient.Client, appPath string) {
71+
ctx := context.Background()
72+
73+
app := filepath.Base(appPath)
74+
75+
absAppPath, err := filepath.Abs(appPath)
76+
if err != nil {
77+
log.Panic(err)
78+
}
79+
80+
// Map user to avoid permission issues.
81+
// MacOS and Windows uses a VM so we don't need to map the user.
82+
var userMapping string
83+
if runtime.GOOS == "linux" {
84+
user, err := user.Current()
85+
if err != nil {
86+
log.Panic("cannot get linux user: %w", err)
87+
}
88+
userMapping = user.Uid + ":" + user.Gid
89+
}
90+
91+
name := appToContainerName(app)
92+
resp, err := docker.ContainerCreate(ctx, &container.Config{
93+
Image: pythonImage,
94+
User: userMapping,
95+
}, &container.HostConfig{
96+
Binds: []string{absAppPath + ":/app"},
97+
AutoRemove: true,
98+
}, nil, nil, name)
99+
if err != nil {
100+
log.Panic(err)
101+
}
102+
103+
if err := docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
104+
log.Panic(err)
105+
}
106+
107+
fmt.Println("Container started with ID:", resp.ID)
108+
}
109+
110+
func stopHandler(docker *dockerClient.Client, appPath string) {
111+
ctx := context.Background()
112+
113+
app := filepath.Base(appPath)
114+
115+
name := appToContainerName(app)
116+
id, err := findContainer(ctx, docker, name)
117+
if err != nil {
118+
log.Panic(err)
119+
}
120+
121+
if err := docker.ContainerRemove(ctx, id, container.RemoveOptions{Force: true}); err != nil {
122+
log.Panic(err)
123+
}
124+
125+
fmt.Println("Container stopped and removed")
126+
}
127+
128+
func logsHandler(docker *dockerClient.Client, appPath string) {
129+
ctx := context.Background()
130+
131+
app := filepath.Base(appPath)
132+
133+
name := appToContainerName(app)
134+
id, err := findContainer(ctx, docker, name)
135+
if err != nil {
136+
log.Panic(err)
137+
}
138+
139+
out, err := docker.ContainerLogs(ctx, id, container.LogsOptions{
140+
ShowStdout: true,
141+
ShowStderr: true,
142+
Follow: true,
143+
})
144+
if err != nil {
145+
log.Panic(err)
146+
}
147+
defer out.Close()
148+
149+
if _, err := io.Copy(os.Stdout, out); err != nil {
150+
log.Panic(err)
151+
}
152+
}
153+
154+
func listHandler(docker *dockerClient.Client) {
155+
ctx := context.Background()
156+
157+
resp, err := docker.ContainerList(ctx, container.ListOptions{
158+
All: true,
159+
})
160+
if err != nil {
161+
log.Panic(err)
162+
}
163+
164+
for _, container := range resp {
165+
if strings.HasPrefix(container.Names[0], "/arduino_") && strings.HasSuffix(container.Names[0], "_python") {
166+
fmt.Println(containerToAppName(container.Names[0]))
167+
}
168+
}
169+
}
170+
171+
func appToContainerName(app string) string {
172+
app = strings.Trim(app, "/\n ")
173+
app = strings.ReplaceAll(app, "/", "_")
174+
return fmt.Sprintf("arduino_%s_python", app)
175+
}
176+
177+
func containerToAppName(name string) string {
178+
name = strings.TrimPrefix(name, "/")
179+
name = strings.TrimPrefix(name, "arduino_")
180+
name = strings.TrimSuffix(name, "_python")
181+
return name
182+
}
183+
184+
func findContainer(ctx context.Context, docker *dockerClient.Client, containerName string) (string, error) {
185+
resp, err := docker.ContainerList(ctx, container.ListOptions{
186+
All: true,
187+
})
188+
if err != nil {
189+
return "", err
190+
}
191+
192+
idx := slices.IndexFunc(resp, func(container container.Summary) bool {
193+
return container.Names[0] == "/"+containerName // Container name is prefixed with a slash
194+
})
195+
if idx == -1 {
196+
return "", fmt.Errorf("container not found")
197+
}
198+
199+
return resp[idx].ID, nil
200+
}

dprint.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@
44
"toml": {},
55
"dockerfile": {},
66
"yaml": {},
7-
"excludes": [],
7+
"ruff": {
8+
"indentStyle": "space",
9+
"lineLength": 100,
10+
"indentWidth": 4
11+
},
12+
"excludes": [
13+
"**/.venv/**"
14+
],
815
"plugins": [
916
"https://plugins.dprint.dev/json-0.19.4.wasm",
1017
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
1118
"https://plugins.dprint.dev/toml-0.6.3.wasm",
1219
"https://plugins.dprint.dev/dockerfile-0.3.2.wasm",
13-
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.0.wasm"
20+
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.0.wasm",
21+
"https://plugins.dprint.dev/ruff-0.3.9.wasm"
1422
]
1523
}

examples/iss-where-app/main.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import json
2+
from dataclasses import dataclass
3+
from time import sleep
4+
5+
import requests
6+
7+
8+
@dataclass
9+
class ISSLocation:
10+
latitude: float
11+
longitude: float
12+
13+
14+
def get_iss_location():
15+
"""Fetches and prints the current location of the ISS."""
16+
try:
17+
response = requests.get("http://api.open-notify.org/iss-now.json")
18+
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
19+
20+
data = response.json()
21+
iss_position = data["iss_position"]
22+
latitude = float(iss_position["latitude"])
23+
longitude = float(iss_position["longitude"])
24+
25+
return ISSLocation(latitude, longitude)
26+
27+
except requests.exceptions.RequestException as e:
28+
raise Exception(f"Error fetching ISS location: {e}")
29+
except json.JSONDecodeError as e:
30+
raise Exception(f"Error decoding JSON response: {e}")
31+
except KeyError as e:
32+
raise Exception(f"Error accessing data in JSON: {e}")
33+
except Exception as e:
34+
raise Exception(f"An error occurred: {e}")
35+
36+
37+
def main():
38+
while True:
39+
try:
40+
location = get_iss_location()
41+
print(
42+
f"The ISS is currently at {location.latitude}, {location.longitude}",
43+
flush=True,
44+
)
45+
46+
sleep(1)
47+
except KeyboardInterrupt:
48+
print("Exiting...")
49+
break
50+
51+
52+
if __name__ == "__main__":
53+
main()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
certifi==2025.1.31
2+
charset-normalizer==3.4.1
3+
idna==3.10
4+
requests==2.32.3
5+
urllib3==2.3.0

go.mod

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,37 @@
11
module github.com/arduino/arduino-app-cli
22

33
go 1.24
4+
5+
require (
6+
github.com/docker/docker v28.0.1+incompatible
7+
github.com/spf13/cobra v1.9.1
8+
)
9+
10+
require (
11+
github.com/Microsoft/go-winio v0.4.14 // indirect
12+
github.com/containerd/log v0.1.0 // indirect
13+
github.com/distribution/reference v0.6.0 // indirect
14+
github.com/docker/go-connections v0.5.0 // indirect
15+
github.com/docker/go-units v0.5.0 // indirect
16+
github.com/felixge/httpsnoop v1.0.4 // indirect
17+
github.com/go-logr/logr v1.4.2 // indirect
18+
github.com/go-logr/stdr v1.2.2 // indirect
19+
github.com/gogo/protobuf v1.3.2 // indirect
20+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
21+
github.com/moby/docker-image-spec v1.3.1 // indirect
22+
github.com/moby/term v0.5.2 // indirect
23+
github.com/morikuni/aec v1.0.0 // indirect
24+
github.com/opencontainers/go-digest v1.0.0 // indirect
25+
github.com/opencontainers/image-spec v1.1.1 // indirect
26+
github.com/pkg/errors v0.9.1 // indirect
27+
github.com/spf13/pflag v1.0.6 // indirect
28+
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
29+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
30+
go.opentelemetry.io/otel v1.35.0 // indirect
31+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
32+
go.opentelemetry.io/otel/metric v1.35.0 // indirect
33+
go.opentelemetry.io/otel/trace v1.35.0 // indirect
34+
golang.org/x/sys v0.30.0 // indirect
35+
golang.org/x/time v0.11.0 // indirect
36+
gotest.tools/v3 v3.5.2 // indirect
37+
)

0 commit comments

Comments
 (0)