envis 0.0.1__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.
- envis-0.0.1/.gitignore +5 -0
- envis-0.0.1/LICENSE.txt +3 -0
- envis-0.0.1/PKG-INFO +40 -0
- envis-0.0.1/README.md +26 -0
- envis-0.0.1/pyproject.toml +64 -0
- envis-0.0.1/setup.py +32 -0
- envis-0.0.1/src/envis/__about__.py +4 -0
- envis-0.0.1/src/envis/__init__.py +8 -0
- envis-0.0.1/src/envis/auth.py +56 -0
- envis-0.0.1/src/envis/main.py +80 -0
- envis-0.0.1/src/envis/session.py +89 -0
- envis-0.0.1/tests/__init__.py +3 -0
envis-0.0.1/.gitignore
ADDED
envis-0.0.1/LICENSE.txt
ADDED
envis-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envis
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Secure, simple, and powerful secret management.
|
|
5
|
+
Project-URL: Homepage, https://envisible.dev
|
|
6
|
+
Project-URL: Documentation, https://github.com/umairx25/envis#readme
|
|
7
|
+
Project-URL: Issues, https://github.com/umairx25/envis/issues
|
|
8
|
+
Project-URL: Source, https://github.com/umairx25/envis
|
|
9
|
+
Author-email: Umair Arham <contact@uarham.me>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE.txt
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# envis
|
|
16
|
+
|
|
17
|
+
[](https://pypi.org/project/envis)
|
|
18
|
+
[](https://pypi.org/project/envis)
|
|
19
|
+
|
|
20
|
+
-----
|
|
21
|
+
|
|
22
|
+
## Table of Contents
|
|
23
|
+
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [License](#license)
|
|
26
|
+
- [Contact](#contact)
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```console
|
|
31
|
+
pip install envis
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
`envis` is released under a private source-available license: you can use the SDK to integrate with https://envisible.dev, but the backend platform remains proprietary.
|
|
37
|
+
|
|
38
|
+
## Contact
|
|
39
|
+
- [Website](https://envisible.dev)
|
|
40
|
+
- [Email](mailto:contact@uarham.me)
|
envis-0.0.1/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# envis
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/envis)
|
|
4
|
+
[](https://pypi.org/project/envis)
|
|
5
|
+
|
|
6
|
+
-----
|
|
7
|
+
|
|
8
|
+
## Table of Contents
|
|
9
|
+
|
|
10
|
+
- [Installation](#installation)
|
|
11
|
+
- [License](#license)
|
|
12
|
+
- [Contact](#contact)
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
pip install envis
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## License
|
|
21
|
+
|
|
22
|
+
`envis` is released under a private source-available license: you can use the SDK to integrate with https://envisible.dev, but the backend platform remains proprietary.
|
|
23
|
+
|
|
24
|
+
## Contact
|
|
25
|
+
- [Website](https://envisible.dev)
|
|
26
|
+
- [Email](mailto:contact@uarham.me)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "envis"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = 'Secure, simple, and powerful secret management.'
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = []
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
name = "Umair Arham"
|
|
15
|
+
email = "contact@uarham.me"
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Programming Language :: Python",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
25
|
+
"Programming Language :: Python :: Implementation :: PyPy",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"requests>=2.31.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://envisible.dev"
|
|
33
|
+
Documentation = "https://github.com/umairx25/envis#readme"
|
|
34
|
+
Issues = "https://github.com/umairx25/envis/issues"
|
|
35
|
+
Source = "https://github.com/umairx25/envis"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
path = "src/envis/__about__.py"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.envs.types]
|
|
41
|
+
extra-dependencies = [
|
|
42
|
+
"mypy>=1.0.0",
|
|
43
|
+
]
|
|
44
|
+
[tool.hatch.envs.types.scripts]
|
|
45
|
+
check = "mypy --install-types --non-interactive {args:src/envis tests}"
|
|
46
|
+
|
|
47
|
+
[tool.coverage.run]
|
|
48
|
+
source_pkgs = ["envis", "tests"]
|
|
49
|
+
branch = true
|
|
50
|
+
parallel = true
|
|
51
|
+
omit = [
|
|
52
|
+
"src/envis/__about__.py",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.coverage.paths]
|
|
56
|
+
envis = ["src/envis", "*/envis/src/envis"]
|
|
57
|
+
tests = ["tests", "*/envis/tests"]
|
|
58
|
+
|
|
59
|
+
[tool.coverage.report]
|
|
60
|
+
exclude_lines = [
|
|
61
|
+
"no cov",
|
|
62
|
+
"if __name__ == .__main__.:",
|
|
63
|
+
"if TYPE_CHECKING:",
|
|
64
|
+
]
|
envis-0.0.1/setup.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from setuptools import find_packages, setup
|
|
4
|
+
|
|
5
|
+
PACKAGE_ROOT = Path(__file__).parent
|
|
6
|
+
|
|
7
|
+
# Reuse the single source of truth for the package version.
|
|
8
|
+
ABOUT_PATH = PACKAGE_ROOT / "src" / "envis" / "__about__.py"
|
|
9
|
+
ABOUT: dict = {}
|
|
10
|
+
exec(ABOUT_PATH.read_text(encoding="utf-8"), ABOUT)
|
|
11
|
+
|
|
12
|
+
README_PATH = PACKAGE_ROOT / "README.md"
|
|
13
|
+
LONG_DESCRIPTION = README_PATH.read_text(encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
setup(
|
|
16
|
+
name="envis",
|
|
17
|
+
version=ABOUT["__version__"],
|
|
18
|
+
description="Secure, simple, and powerful secret management.",
|
|
19
|
+
long_description=LONG_DESCRIPTION,
|
|
20
|
+
long_description_content_type="text/markdown",
|
|
21
|
+
author="Umair Arham",
|
|
22
|
+
author_email="contact@uarham.me",
|
|
23
|
+
url="https://github.com/umairx25/envis",
|
|
24
|
+
packages=find_packages(where="src"),
|
|
25
|
+
package_dir={"": "src"},
|
|
26
|
+
python_requires=">=3.8",
|
|
27
|
+
install_requires=[
|
|
28
|
+
"requests>=2.31.0",
|
|
29
|
+
"termcolor>=2.3.0",
|
|
30
|
+
],
|
|
31
|
+
include_package_data=True
|
|
32
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
import time
|
|
3
|
+
import requests
|
|
4
|
+
import json
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
from .session import write_session, BASE_URL, WAIT_TIME, POLL_DELAY_SECONDS
|
|
7
|
+
|
|
8
|
+
def wait_for_auth(device_id: str) -> dict[str, Any]:
|
|
9
|
+
"""
|
|
10
|
+
Poll the backend until the device session is ready or the wait time expires.
|
|
11
|
+
"""
|
|
12
|
+
try:
|
|
13
|
+
UUID(device_id)
|
|
14
|
+
except ValueError as exc:
|
|
15
|
+
raise RuntimeError("Device id must be a valid UUID.") from exc
|
|
16
|
+
|
|
17
|
+
url = f"{BASE_URL}/v1/auth/{device_id}"
|
|
18
|
+
|
|
19
|
+
deadline = time.monotonic() + WAIT_TIME
|
|
20
|
+
|
|
21
|
+
while time.monotonic() < deadline:
|
|
22
|
+
try:
|
|
23
|
+
response = requests.get(url, timeout=10)
|
|
24
|
+
except requests.RequestException:
|
|
25
|
+
time.sleep(POLL_DELAY_SECONDS)
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
payload = response.json()
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise RuntimeError("Auth endpoint returned invalid JSON.") from exc
|
|
32
|
+
|
|
33
|
+
if response.status_code == 200 and payload.get("is_auth"):
|
|
34
|
+
session_blob = payload.get("content")
|
|
35
|
+
if not session_blob:
|
|
36
|
+
raise RuntimeError("Auth endpoint returned an empty session payload.")
|
|
37
|
+
try:
|
|
38
|
+
session = json.loads(session_blob)
|
|
39
|
+
except json.JSONDecodeError as exc:
|
|
40
|
+
raise RuntimeError("Auth endpoint returned malformed session data.") from exc
|
|
41
|
+
|
|
42
|
+
write_session(session)
|
|
43
|
+
return session
|
|
44
|
+
|
|
45
|
+
if response.status_code == 202:
|
|
46
|
+
retry_header = response.headers.get("Retry-After")
|
|
47
|
+
try:
|
|
48
|
+
delay = max(1, int(retry_header)) if retry_header else POLL_DELAY_SECONDS
|
|
49
|
+
except ValueError:
|
|
50
|
+
delay = POLL_DELAY_SECONDS
|
|
51
|
+
time.sleep(delay)
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
detail = payload.get("detail") if isinstance(payload, dict) else response.text
|
|
55
|
+
status_code = response.status_code
|
|
56
|
+
raise RuntimeError(f"Auth endpoint failed ({status_code}): {detail}")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from urllib.parse import urlparse
|
|
2
|
+
import requests
|
|
3
|
+
from .session import load_session, ensure_session, SESSION_PATH, BASE_URL
|
|
4
|
+
from termcolor import colored
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Helpers
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def _extract_user_id(session: dict) -> str | None:
|
|
11
|
+
"""
|
|
12
|
+
Return the Supabase user ID from the cached session, if present.
|
|
13
|
+
"""
|
|
14
|
+
user = session.get("user")
|
|
15
|
+
if isinstance(user, dict):
|
|
16
|
+
user_id = user.get("id")
|
|
17
|
+
if isinstance(user_id, str) and user_id.strip():
|
|
18
|
+
return user_id
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_local_base_url(base_url: str) -> bool:
|
|
23
|
+
"""
|
|
24
|
+
Determine if the API URL is targeting a local dev server.
|
|
25
|
+
"""
|
|
26
|
+
parsed = urlparse(base_url)
|
|
27
|
+
host = parsed.hostname or ""
|
|
28
|
+
return host in {"localhost", "127.0.0.1", "0.0.0.0"}
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
Main functions
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def logout() -> None:
|
|
35
|
+
"""
|
|
36
|
+
Remove the cached session so the next call forces re-authentication.
|
|
37
|
+
"""
|
|
38
|
+
if not SESSION_PATH.exists():
|
|
39
|
+
raise RuntimeError("Already logged out. Please authenticate again.")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
SESSION_PATH.unlink()
|
|
43
|
+
print(colored("Successfully logged out!", "green"))
|
|
44
|
+
except OSError as exc:
|
|
45
|
+
raise RuntimeError(f"Unable to remove session cache: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get(project_id: str, secret_name: str) -> dict:
|
|
49
|
+
"""
|
|
50
|
+
Fetch a secret value, enforcing that the caller has an authenticated session.
|
|
51
|
+
"""
|
|
52
|
+
ensure_session()
|
|
53
|
+
session = load_session()
|
|
54
|
+
headers = {
|
|
55
|
+
"Authorization": f"Bearer {session['access_token']}",
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
}
|
|
58
|
+
if _is_local_base_url(BASE_URL):
|
|
59
|
+
user_id = _extract_user_id(session)
|
|
60
|
+
if user_id:
|
|
61
|
+
headers["X-User-Id"] = user_id
|
|
62
|
+
|
|
63
|
+
url = f"{BASE_URL}/v1/projects/{project_id}/secrets/{secret_name}"
|
|
64
|
+
try:
|
|
65
|
+
resp = requests.get(url, headers=headers, timeout=10)
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
except requests.HTTPError as exc:
|
|
68
|
+
detail = exc.response.text if exc.response is not None else str(exc)
|
|
69
|
+
raise RuntimeError(f"Failed to fetch secret ({exc.response.status_code if exc.response else 'HTTP error'}): {detail}") from exc
|
|
70
|
+
except requests.RequestException as exc:
|
|
71
|
+
raise RuntimeError(f"Failed to reach Envault API: {exc}") from exc
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
res= resp.json()
|
|
75
|
+
return res["value"]
|
|
76
|
+
except ValueError:
|
|
77
|
+
text = resp.text.strip()
|
|
78
|
+
if text:
|
|
79
|
+
return {"raw": text}
|
|
80
|
+
raise RuntimeError("API returned an empty, non-JSON response when fetching secret.")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import webbrowser
|
|
7
|
+
from termcolor import colored
|
|
8
|
+
|
|
9
|
+
SESSION_PATH = Path.home() / ".envis" / "session.json"
|
|
10
|
+
|
|
11
|
+
def _get_env_url(var_name: str, default: str) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Read a URL override from environment variables with a safe fallback.
|
|
14
|
+
Trailing slashes are stripped so joins don't produce double slashes.
|
|
15
|
+
"""
|
|
16
|
+
value = os.getenv(var_name)
|
|
17
|
+
if not value:
|
|
18
|
+
return default
|
|
19
|
+
return value.rstrip("/")
|
|
20
|
+
|
|
21
|
+
BASE_URL = _get_env_url("ENVIS_API_URL", "https://envis.onrender.com")
|
|
22
|
+
FRONTEND_URL = _get_env_url("ENVIS_DASH_URL", "https://envisible.netlify.app")
|
|
23
|
+
WAIT_TIME = 120
|
|
24
|
+
POLL_DELAY_SECONDS = 5
|
|
25
|
+
|
|
26
|
+
def load_session() -> dict:
|
|
27
|
+
"""
|
|
28
|
+
Load the cached session tokens that the CLI stored after login.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
if not SESSION_PATH.exists():
|
|
32
|
+
device_code = uuid4()
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
f"Not authenticated. Visit {FRONTEND_URL}/auth?device_code={device_code} to link this device."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
session = json.loads(SESSION_PATH.read_text(encoding="utf-8"))
|
|
39
|
+
except json.JSONDecodeError as exc:
|
|
40
|
+
raise RuntimeError("Session file is corrupt. Re-run `envis login`.") from exc
|
|
41
|
+
|
|
42
|
+
access_token = session.get("access_token")
|
|
43
|
+
if not access_token:
|
|
44
|
+
raise RuntimeError("Session missing access_token. Re-run `envis login`.")
|
|
45
|
+
|
|
46
|
+
return session
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def write_session(session_info: dict[str, Any]) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Persist the Supabase session locally so future calls can reuse it.
|
|
52
|
+
"""
|
|
53
|
+
if not isinstance(session_info, dict):
|
|
54
|
+
raise RuntimeError("Session payload must be a dictionary.")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
payload = json.dumps(session_info, indent=2)
|
|
58
|
+
except (TypeError, ValueError) as exc:
|
|
59
|
+
raise RuntimeError("Session payload is not JSON serializable.") from exc
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
SESSION_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
SESSION_PATH.write_text(payload, encoding="utf-8")
|
|
64
|
+
os.chmod(SESSION_PATH, 0o600)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise RuntimeError(f"Failed to write session cache: {exc}") from exc
|
|
67
|
+
|
|
68
|
+
def ensure_session() -> None:
|
|
69
|
+
|
|
70
|
+
from .auth import wait_for_auth
|
|
71
|
+
|
|
72
|
+
if SESSION_PATH.exists():
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
device_code = str(uuid4())
|
|
76
|
+
auth_url = f"{FRONTEND_URL}/auth?device_code={device_code}"
|
|
77
|
+
|
|
78
|
+
print(colored("No cached session detected.", "red"))
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
webbrowser.open(auth_url, new=2)
|
|
82
|
+
print(f"\nTrying to open:")
|
|
83
|
+
print(colored(f" {auth_url}", "blue"))
|
|
84
|
+
except Exception:
|
|
85
|
+
print(colored("Could not open browser automatically – open \n {auth_url}", "red"))
|
|
86
|
+
|
|
87
|
+
print("\nWaiting for the session to be approved...")
|
|
88
|
+
wait_for_auth(device_code)
|
|
89
|
+
print(colored("\nDevice approved and session saved locally.", "green"))
|