hirundo 0.1.7__py3-none-any.whl → 0.1.9__py3-none-any.whl
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.
- hirundo/__init__.py +17 -9
- hirundo/_constraints.py +34 -2
- hirundo/_env.py +12 -1
- hirundo/_http.py +19 -0
- hirundo/_iter_sse_retrying.py +63 -19
- hirundo/cli.py +75 -16
- hirundo/dataset_optimization.py +519 -127
- hirundo/enum.py +8 -5
- hirundo/git.py +95 -28
- hirundo/logger.py +3 -1
- hirundo/storage.py +246 -75
- hirundo-0.1.9.dist-info/METADATA +212 -0
- hirundo-0.1.9.dist-info/RECORD +20 -0
- {hirundo-0.1.7.dist-info → hirundo-0.1.9.dist-info}/WHEEL +1 -1
- hirundo-0.1.7.dist-info/METADATA +0 -118
- hirundo-0.1.7.dist-info/RECORD +0 -19
- {hirundo-0.1.7.dist-info → hirundo-0.1.9.dist-info}/LICENSE +0 -0
- {hirundo-0.1.7.dist-info → hirundo-0.1.9.dist-info}/entry_points.txt +0 -0
- {hirundo-0.1.7.dist-info → hirundo-0.1.9.dist-info}/top_level.txt +0 -0
hirundo/__init__.py
CHANGED
|
@@ -1,35 +1,43 @@
|
|
|
1
1
|
from .dataset_optimization import (
|
|
2
|
+
COCO,
|
|
3
|
+
YOLO,
|
|
4
|
+
HirundoCSV,
|
|
2
5
|
HirundoError,
|
|
3
6
|
OptimizationDataset,
|
|
7
|
+
RunArgs,
|
|
8
|
+
VisionRunArgs,
|
|
4
9
|
)
|
|
5
10
|
from .enum import (
|
|
6
11
|
DatasetMetadataType,
|
|
7
|
-
|
|
12
|
+
LabelingType,
|
|
8
13
|
)
|
|
9
14
|
from .git import GitRepo
|
|
10
15
|
from .storage import (
|
|
16
|
+
StorageConfig,
|
|
11
17
|
StorageGCP,
|
|
12
|
-
# StorageAzure, TODO: Azure storage
|
|
18
|
+
# StorageAzure, TODO: Azure storage is coming soon
|
|
13
19
|
StorageGit,
|
|
14
|
-
StorageIntegration,
|
|
15
|
-
StorageLink,
|
|
16
20
|
StorageS3,
|
|
17
21
|
StorageTypes,
|
|
18
22
|
)
|
|
19
23
|
|
|
20
24
|
__all__ = [
|
|
25
|
+
"COCO",
|
|
26
|
+
"YOLO",
|
|
27
|
+
"HirundoCSV",
|
|
21
28
|
"HirundoError",
|
|
22
29
|
"OptimizationDataset",
|
|
23
|
-
"
|
|
30
|
+
"RunArgs",
|
|
31
|
+
"VisionRunArgs",
|
|
32
|
+
"LabelingType",
|
|
24
33
|
"DatasetMetadataType",
|
|
25
34
|
"GitRepo",
|
|
26
|
-
"StorageLink",
|
|
27
35
|
"StorageTypes",
|
|
28
36
|
"StorageS3",
|
|
29
37
|
"StorageGCP",
|
|
30
|
-
# "StorageAzure", TODO: Azure storage
|
|
38
|
+
# "StorageAzure", TODO: Azure storage is coming soon
|
|
31
39
|
"StorageGit",
|
|
32
|
-
"
|
|
40
|
+
"StorageConfig",
|
|
33
41
|
]
|
|
34
42
|
|
|
35
|
-
__version__ = "0.1.
|
|
43
|
+
__version__ = "0.1.9"
|
hirundo/_constraints.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from typing import Annotated
|
|
2
2
|
|
|
3
|
-
from pydantic import StringConstraints
|
|
3
|
+
from pydantic import StringConstraints, UrlConstraints
|
|
4
|
+
from pydantic_core import Url
|
|
4
5
|
|
|
5
6
|
S3BucketUrl = Annotated[
|
|
6
7
|
str,
|
|
@@ -11,7 +12,7 @@ S3BucketUrl = Annotated[
|
|
|
11
12
|
),
|
|
12
13
|
]
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
StorageConfigName = Annotated[
|
|
15
16
|
str,
|
|
16
17
|
StringConstraints(
|
|
17
18
|
min_length=1,
|
|
@@ -19,3 +20,34 @@ StorageIntegrationName = Annotated[
|
|
|
19
20
|
pattern=r"^[a-zA-Z0-9-_]+$",
|
|
20
21
|
),
|
|
21
22
|
]
|
|
23
|
+
|
|
24
|
+
S3_MIN_LENGTH = 8
|
|
25
|
+
S3_MAX_LENGTH = 1023
|
|
26
|
+
S3_PATTERN = r"s3://[a-zA-Z0-9.-]{3,64}/[a-zA-Z0-9.-/]+"
|
|
27
|
+
GCP_MIN_LENGTH = 8
|
|
28
|
+
GCP_MAX_LENGTH = 1023
|
|
29
|
+
GCP_PATTERN = r"gs://[a-zA-Z0-9.-]{3,64}/[a-zA-Z0-9.-/]+"
|
|
30
|
+
|
|
31
|
+
RepoUrl = Annotated[
|
|
32
|
+
Url,
|
|
33
|
+
UrlConstraints(
|
|
34
|
+
allowed_schemes=[
|
|
35
|
+
"ssh",
|
|
36
|
+
"https",
|
|
37
|
+
"http",
|
|
38
|
+
]
|
|
39
|
+
),
|
|
40
|
+
]
|
|
41
|
+
HirundoUrl = Annotated[
|
|
42
|
+
Url,
|
|
43
|
+
UrlConstraints(
|
|
44
|
+
allowed_schemes=[
|
|
45
|
+
"file",
|
|
46
|
+
"https",
|
|
47
|
+
"http",
|
|
48
|
+
"s3",
|
|
49
|
+
"gs",
|
|
50
|
+
"ssh",
|
|
51
|
+
]
|
|
52
|
+
),
|
|
53
|
+
]
|
hirundo/_env.py
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
import enum
|
|
1
2
|
import os
|
|
3
|
+
from pathlib import Path
|
|
2
4
|
|
|
3
5
|
from dotenv import load_dotenv
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
|
|
8
|
+
class EnvLocation(enum.Enum):
|
|
9
|
+
DOTENV = Path.cwd() / ".env"
|
|
10
|
+
HOME = Path.home() / ".hirundo.conf"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if os.path.exists(EnvLocation.DOTENV.value):
|
|
14
|
+
load_dotenv(EnvLocation.DOTENV.value)
|
|
15
|
+
elif os.path.exists(EnvLocation.HOME.value):
|
|
16
|
+
load_dotenv(EnvLocation.HOME.value)
|
|
6
17
|
|
|
7
18
|
API_HOST = os.getenv("API_HOST", "https://api.hirundo.io")
|
|
8
19
|
API_KEY = os.getenv("API_KEY")
|
hirundo/_http.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from requests import Response
|
|
2
|
+
|
|
3
|
+
import hirundo.logger
|
|
4
|
+
|
|
5
|
+
logger = hirundo.logger.get_logger(__name__)
|
|
6
|
+
|
|
7
|
+
MINIMUM_CLIENT_SERVER_ERROR_CODE = 400
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def raise_for_status_with_reason(response: Response):
|
|
11
|
+
try:
|
|
12
|
+
if response.status_code >= MINIMUM_CLIENT_SERVER_ERROR_CODE:
|
|
13
|
+
response.reason = response.json().get("reason", None)
|
|
14
|
+
if response.reason is None:
|
|
15
|
+
response.reason = response.json().get("detail", None)
|
|
16
|
+
except Exception as e:
|
|
17
|
+
logger.debug("Could not parse response as JSON: %s", e)
|
|
18
|
+
|
|
19
|
+
response.raise_for_status()
|
hirundo/_iter_sse_retrying.py
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import time
|
|
3
|
+
import typing
|
|
4
|
+
import uuid
|
|
3
5
|
from collections.abc import AsyncGenerator, Generator
|
|
4
|
-
from typing import Union
|
|
5
6
|
|
|
6
7
|
import httpx
|
|
7
|
-
|
|
8
|
+
import requests
|
|
9
|
+
import urllib3
|
|
10
|
+
from httpx_sse import ServerSentEvent, SSEError, aconnect_sse, connect_sse
|
|
8
11
|
from stamina import retry
|
|
9
12
|
|
|
13
|
+
from hirundo._timeouts import READ_TIMEOUT
|
|
14
|
+
from hirundo.logger import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
10
18
|
|
|
11
19
|
# Credit: https://github.com/florimondmanca/httpx-sse/blob/master/README.md#handling-reconnections
|
|
12
20
|
def iter_sse_retrying(
|
|
13
21
|
client: httpx.Client,
|
|
14
22
|
method: str,
|
|
15
23
|
url: str,
|
|
16
|
-
headers:
|
|
24
|
+
headers: typing.Optional[dict[str, str]] = None,
|
|
17
25
|
) -> Generator[ServerSentEvent, None, None]:
|
|
18
26
|
if headers is None:
|
|
19
27
|
headers = {}
|
|
@@ -28,7 +36,13 @@ def iter_sse_retrying(
|
|
|
28
36
|
# This may happen when the server is overloaded and closes the connection or
|
|
29
37
|
# when Kubernetes restarts / replaces a pod.
|
|
30
38
|
# Likewise, this will likely be temporary, hence the retries.
|
|
31
|
-
@retry(
|
|
39
|
+
@retry(
|
|
40
|
+
on=(
|
|
41
|
+
httpx.ReadError,
|
|
42
|
+
httpx.RemoteProtocolError,
|
|
43
|
+
urllib3.exceptions.ReadTimeoutError,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
32
46
|
def _iter_sse():
|
|
33
47
|
nonlocal last_event_id, reconnection_delay
|
|
34
48
|
|
|
@@ -44,13 +58,27 @@ def iter_sse_retrying(
|
|
|
44
58
|
connect_headers["Last-Event-ID"] = last_event_id
|
|
45
59
|
|
|
46
60
|
with connect_sse(client, method, url, headers=connect_headers) as event_source:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
try:
|
|
62
|
+
for sse in event_source.iter_sse():
|
|
63
|
+
last_event_id = sse.id
|
|
64
|
+
|
|
65
|
+
if sse.retry is not None:
|
|
66
|
+
reconnection_delay = sse.retry / 1000
|
|
67
|
+
|
|
68
|
+
yield sse
|
|
69
|
+
except SSEError:
|
|
70
|
+
logger.error("SSE error occurred. Trying regular request")
|
|
71
|
+
response = requests.get(
|
|
72
|
+
url,
|
|
73
|
+
headers=connect_headers,
|
|
74
|
+
timeout=READ_TIMEOUT,
|
|
75
|
+
)
|
|
76
|
+
yield ServerSentEvent(
|
|
77
|
+
event="",
|
|
78
|
+
data=response.text,
|
|
79
|
+
id=uuid.uuid4().hex,
|
|
80
|
+
retry=None,
|
|
81
|
+
)
|
|
54
82
|
|
|
55
83
|
return _iter_sse()
|
|
56
84
|
|
|
@@ -72,7 +100,13 @@ async def aiter_sse_retrying(
|
|
|
72
100
|
# This may happen when the server is overloaded and closes the connection or
|
|
73
101
|
# when Kubernetes restarts / replaces a pod.
|
|
74
102
|
# Likewise, this will likely be temporary, hence the retries.
|
|
75
|
-
@retry(
|
|
103
|
+
@retry(
|
|
104
|
+
on=(
|
|
105
|
+
httpx.ReadError,
|
|
106
|
+
httpx.RemoteProtocolError,
|
|
107
|
+
urllib3.exceptions.ReadTimeoutError,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
76
110
|
async def _iter_sse() -> AsyncGenerator[ServerSentEvent, None]:
|
|
77
111
|
nonlocal last_event_id, reconnection_delay
|
|
78
112
|
|
|
@@ -86,12 +120,22 @@ async def aiter_sse_retrying(
|
|
|
86
120
|
async with aconnect_sse(
|
|
87
121
|
client, method, url, headers=connect_headers
|
|
88
122
|
) as event_source:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
123
|
+
try:
|
|
124
|
+
async for sse in event_source.aiter_sse():
|
|
125
|
+
last_event_id = sse.id
|
|
126
|
+
|
|
127
|
+
if sse.retry is not None:
|
|
128
|
+
reconnection_delay = sse.retry / 1000
|
|
129
|
+
|
|
130
|
+
yield sse
|
|
131
|
+
except SSEError:
|
|
132
|
+
logger.error("SSE error occurred. Trying regular request")
|
|
133
|
+
response = await client.get(url, headers=connect_headers)
|
|
134
|
+
yield ServerSentEvent(
|
|
135
|
+
event="",
|
|
136
|
+
data=response.text,
|
|
137
|
+
id=uuid.uuid4().hex,
|
|
138
|
+
retry=None,
|
|
139
|
+
)
|
|
96
140
|
|
|
97
141
|
return _iter_sse()
|
hirundo/cli.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import os
|
|
1
2
|
import re
|
|
2
3
|
import sys
|
|
4
|
+
import typing
|
|
5
|
+
from pathlib import Path
|
|
3
6
|
from typing import Annotated
|
|
4
7
|
from urllib.parse import urlparse
|
|
5
8
|
|
|
6
9
|
import typer
|
|
7
10
|
|
|
8
|
-
from hirundo._env import API_HOST
|
|
11
|
+
from hirundo._env import API_HOST, EnvLocation
|
|
9
12
|
|
|
10
13
|
docs = "sphinx" in sys.modules
|
|
11
14
|
hirundo_epilog = (
|
|
@@ -23,7 +26,9 @@ app = typer.Typer(
|
|
|
23
26
|
)
|
|
24
27
|
|
|
25
28
|
|
|
26
|
-
def
|
|
29
|
+
def _upsert_env(
|
|
30
|
+
dotenv_filepath: typing.Union[str, Path], var_name: str, var_value: str
|
|
31
|
+
):
|
|
27
32
|
"""
|
|
28
33
|
Change an environment variable in the .env file.
|
|
29
34
|
If the variable does not exist, it will be added.
|
|
@@ -32,18 +37,30 @@ def upsert_env(var_name: str, var_value: str):
|
|
|
32
37
|
var_name: The name of the environment variable to change.
|
|
33
38
|
var_value: The new value of the environment variable.
|
|
34
39
|
"""
|
|
35
|
-
dotenv = "./.env"
|
|
36
40
|
regex = re.compile(rf"^{var_name}=.*$")
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
lines = []
|
|
42
|
+
if os.path.exists(dotenv_filepath):
|
|
43
|
+
with open(dotenv_filepath) as f:
|
|
44
|
+
lines = f.readlines()
|
|
39
45
|
|
|
40
|
-
with open(
|
|
46
|
+
with open(dotenv_filepath, "w") as f:
|
|
41
47
|
f.writelines(line for line in lines if not regex.search(line) and line != "\n")
|
|
42
48
|
|
|
43
|
-
with open(
|
|
49
|
+
with open(dotenv_filepath, "a") as f:
|
|
44
50
|
f.writelines(f"\n{var_name}={var_value}")
|
|
45
51
|
|
|
46
52
|
|
|
53
|
+
def upsert_env(var_name: str, var_value: str):
|
|
54
|
+
if os.path.exists(EnvLocation.DOTENV.value):
|
|
55
|
+
# If a `.env` file exists, re-use it
|
|
56
|
+
_upsert_env(EnvLocation.DOTENV.value, var_name, var_value)
|
|
57
|
+
return EnvLocation.DOTENV.name
|
|
58
|
+
else:
|
|
59
|
+
# Create a `.hirundo.conf` file with environment variables in the home directory
|
|
60
|
+
_upsert_env(EnvLocation.HOME.value, var_name, var_value)
|
|
61
|
+
return EnvLocation.HOME.name
|
|
62
|
+
|
|
63
|
+
|
|
47
64
|
def fix_api_host(api_host: str):
|
|
48
65
|
if not api_host.startswith("http") and not api_host.startswith("https"):
|
|
49
66
|
api_host = f"https://{api_host}"
|
|
@@ -72,8 +89,15 @@ def setup_api_key(
|
|
|
72
89
|
Setup the API key for the Hirundo client library.
|
|
73
90
|
Values are saved to a .env file in the current directory for use by the library in requests.
|
|
74
91
|
"""
|
|
75
|
-
upsert_env("API_KEY", api_key)
|
|
76
|
-
|
|
92
|
+
saved_to = upsert_env("API_KEY", api_key)
|
|
93
|
+
if saved_to == EnvLocation.HOME.name:
|
|
94
|
+
print(
|
|
95
|
+
"API key saved to ~/.hirundo.conf for future use. Please do not share the ~/.hirundo.conf file since it contains your secret API key."
|
|
96
|
+
)
|
|
97
|
+
elif saved_to == EnvLocation.DOTENV.name:
|
|
98
|
+
print(
|
|
99
|
+
"API key saved to local .env file for future use. Please do not share the .env file since it contains your secret API key."
|
|
100
|
+
)
|
|
77
101
|
|
|
78
102
|
|
|
79
103
|
@app.command("change-remote", epilog=hirundo_epilog)
|
|
@@ -94,8 +118,13 @@ def change_api_remote(
|
|
|
94
118
|
"""
|
|
95
119
|
api_host = fix_api_host(api_host)
|
|
96
120
|
|
|
97
|
-
upsert_env("API_HOST", api_host)
|
|
98
|
-
|
|
121
|
+
saved_to = upsert_env("API_HOST", api_host)
|
|
122
|
+
if saved_to == EnvLocation.HOME.name:
|
|
123
|
+
print(
|
|
124
|
+
"API host saved to ~/.hirundo.conf for future use. Please do not share the ~/.hirundo.conf file"
|
|
125
|
+
)
|
|
126
|
+
elif saved_to == EnvLocation.DOTENV.name:
|
|
127
|
+
print("API host saved to .env for future use. Please do not share this file")
|
|
99
128
|
|
|
100
129
|
|
|
101
130
|
@app.command("setup", epilog=hirundo_epilog)
|
|
@@ -123,11 +152,41 @@ def setup(
|
|
|
123
152
|
Setup the Hirundo client library.
|
|
124
153
|
"""
|
|
125
154
|
api_host = fix_api_host(api_host)
|
|
126
|
-
upsert_env("API_HOST", api_host)
|
|
127
|
-
upsert_env("API_KEY", api_key)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
155
|
+
api_host_saved_to = upsert_env("API_HOST", api_host)
|
|
156
|
+
api_key_saved_to = upsert_env("API_KEY", api_key)
|
|
157
|
+
if api_host_saved_to != api_key_saved_to:
|
|
158
|
+
print(
|
|
159
|
+
"API host and API key saved to different locations. This should not happen. Please report this issue."
|
|
160
|
+
)
|
|
161
|
+
if (
|
|
162
|
+
api_host_saved_to == EnvLocation.HOME.name
|
|
163
|
+
and api_key_saved_to == EnvLocation.DOTENV.name
|
|
164
|
+
):
|
|
165
|
+
print(
|
|
166
|
+
"API host saved to ~/.hirundo.conf for future use. Please do not share the ~/.hirundo.conf file"
|
|
167
|
+
)
|
|
168
|
+
print(
|
|
169
|
+
"API key saved to local .env file for future use. Please do not share the .env file since it contains your secret API key."
|
|
170
|
+
)
|
|
171
|
+
elif (
|
|
172
|
+
api_host_saved_to == EnvLocation.DOTENV.name
|
|
173
|
+
and api_key_saved_to == EnvLocation.HOME.name
|
|
174
|
+
):
|
|
175
|
+
print(
|
|
176
|
+
"API host saved to .env for future use. Please do not share this file"
|
|
177
|
+
)
|
|
178
|
+
print(
|
|
179
|
+
"API key saved to ~/.hirundo.conf for future use. Please do not share the ~/.hirundo.conf file since it contains your secret API key."
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
if api_host_saved_to == EnvLocation.HOME.name:
|
|
183
|
+
print(
|
|
184
|
+
"API host and API key saved to ~/.hirundo.conf for future use. Please do not share the ~/.hirundo.conf file since it contains your secret API key."
|
|
185
|
+
)
|
|
186
|
+
elif api_host_saved_to == EnvLocation.DOTENV.name:
|
|
187
|
+
print(
|
|
188
|
+
"API host and API key saved to .env for future use. Please do not share this file since it contains your secret API key."
|
|
189
|
+
)
|
|
131
190
|
|
|
132
191
|
|
|
133
192
|
typer_click_object = typer.main.get_command(app)
|