Yuvacodes 0.1.0__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.
- mycodes/__init__.py +42 -0
- mycodes/cli.py +137 -0
- mycodes/client.py +174 -0
- mycodes/config.py +192 -0
- mycodes/exceptions.py +54 -0
- mycodes/jupyter_extension.py +149 -0
- mycodes/registry.py +272 -0
- yuvacodes-0.1.0.dist-info/METADATA +443 -0
- yuvacodes-0.1.0.dist-info/RECORD +13 -0
- yuvacodes-0.1.0.dist-info/WHEEL +5 -0
- yuvacodes-0.1.0.dist-info/entry_points.txt +2 -0
- yuvacodes-0.1.0.dist-info/licenses/LICENSE +21 -0
- yuvacodes-0.1.0.dist-info/top_level.txt +1 -0
mycodes/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""MyCodes: Personal cloud-based code library for Python and Jupyter Notebooks."""
|
|
2
|
+
|
|
3
|
+
from mycodes.config import configure, get_config
|
|
4
|
+
from mycodes.registry import get_code, get_info, list_codes, search, run
|
|
5
|
+
from mycodes.exceptions import (
|
|
6
|
+
MyCodesError,
|
|
7
|
+
CodeNotFoundError,
|
|
8
|
+
RepositoryNotFoundError,
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
RemoteFetchError,
|
|
11
|
+
ExecutionDisabledError,
|
|
12
|
+
)
|
|
13
|
+
from mycodes.jupyter_extension import load_ipython_extension, unload_ipython_extension
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"configure",
|
|
19
|
+
"get_config",
|
|
20
|
+
"get_code",
|
|
21
|
+
"get_info",
|
|
22
|
+
"list_codes",
|
|
23
|
+
"search",
|
|
24
|
+
"run",
|
|
25
|
+
"MyCodesError",
|
|
26
|
+
"CodeNotFoundError",
|
|
27
|
+
"RepositoryNotFoundError",
|
|
28
|
+
"ConfigurationError",
|
|
29
|
+
"RemoteFetchError",
|
|
30
|
+
"ExecutionDisabledError",
|
|
31
|
+
"load_ipython_extension",
|
|
32
|
+
"unload_ipython_extension",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Auto-register IPython extension if running inside an active IPython session
|
|
36
|
+
try:
|
|
37
|
+
from IPython import get_ipython
|
|
38
|
+
_ip = get_ipython()
|
|
39
|
+
if _ip is not None:
|
|
40
|
+
load_ipython_extension(_ip)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
mycodes/cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Command-line interface (CLI) for the MyCodes package."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from mycodes.config import configure, get_config
|
|
6
|
+
from mycodes.exceptions import MyCodesError
|
|
7
|
+
from mycodes.registry import get_code, get_info, list_codes, search, run
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
11
|
+
"""Construct command-line argument parser."""
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="mycodes",
|
|
14
|
+
description="MyCodes - Your personal cloud-based code library CLI.",
|
|
15
|
+
)
|
|
16
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
17
|
+
|
|
18
|
+
# Command: list
|
|
19
|
+
list_p = subparsers.add_parser("list", help="List all available codes in the cloud library.")
|
|
20
|
+
list_p.add_argument("--refresh", "-r", action="store_true", help="Force refresh index from GitHub.")
|
|
21
|
+
|
|
22
|
+
# Command: get
|
|
23
|
+
get_p = subparsers.add_parser("get", help="Retrieve source code for a specific ID.")
|
|
24
|
+
get_p.add_argument("id", help="The numeric or string code ID (e.g., 101 or 001).")
|
|
25
|
+
get_p.add_argument("--cache", "-c", action="store_true", help="Cache the code locally.")
|
|
26
|
+
get_p.add_argument("--refresh", "-r", action="store_true", help="Force refresh from GitHub.")
|
|
27
|
+
|
|
28
|
+
# Command: info
|
|
29
|
+
info_p = subparsers.add_parser("info", help="Show metadata information for a code snippet.")
|
|
30
|
+
info_p.add_argument("id", help="The code ID.")
|
|
31
|
+
info_p.add_argument("--refresh", "-r", action="store_true", help="Force refresh from GitHub.")
|
|
32
|
+
|
|
33
|
+
# Command: search
|
|
34
|
+
search_p = subparsers.add_parser("search", help="Search codes by keyword, category, or language.")
|
|
35
|
+
search_p.add_argument("query", nargs="?", default=None, help="Search keyword.")
|
|
36
|
+
search_p.add_argument("--category", "-c", default=None, help="Filter by category.")
|
|
37
|
+
search_p.add_argument("--language", "-l", default=None, help="Filter by language.")
|
|
38
|
+
|
|
39
|
+
# Command: config
|
|
40
|
+
cfg_p = subparsers.add_parser("config", help="View or update MyCodes repository configuration.")
|
|
41
|
+
cfg_p.add_argument("--username", "-u", default=None, help="GitHub username.")
|
|
42
|
+
cfg_p.add_argument("--repository", "-r", default=None, help="Repository name.")
|
|
43
|
+
cfg_p.add_argument("--branch", "-b", default=None, help="Branch name.")
|
|
44
|
+
|
|
45
|
+
# Command: run
|
|
46
|
+
run_p = subparsers.add_parser("run", help="Execute remote code (requires explicit opt-in).")
|
|
47
|
+
run_p.add_argument("id", help="The code ID to execute.")
|
|
48
|
+
run_p.add_argument(
|
|
49
|
+
"--allow-execution",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Explicitly acknowledge and allow execution of remote code.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(args=None) -> int:
|
|
58
|
+
"""CLI entry point function."""
|
|
59
|
+
parser = build_parser()
|
|
60
|
+
parsed = parser.parse_args(args)
|
|
61
|
+
|
|
62
|
+
if not parsed.command:
|
|
63
|
+
parser.print_help()
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
if parsed.command == "list":
|
|
68
|
+
list_codes(return_data=False, refresh=parsed.refresh)
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
elif parsed.command == "get":
|
|
72
|
+
code = get_code(parsed.id, cache=parsed.cache, refresh=parsed.refresh)
|
|
73
|
+
print(code, end="")
|
|
74
|
+
if not code.endswith("\n"):
|
|
75
|
+
print()
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
elif parsed.command == "info":
|
|
79
|
+
info = get_info(parsed.id, refresh=parsed.refresh)
|
|
80
|
+
print(f"ID: {info.get('id')}")
|
|
81
|
+
print(f"Name: {info.get('name', 'N/A')}")
|
|
82
|
+
print(f"Description: {info.get('description', 'N/A')}")
|
|
83
|
+
print(f"Category: {info.get('category', 'N/A')}")
|
|
84
|
+
print(f"Language: {info.get('language', 'python')}")
|
|
85
|
+
if "tags" in info and info["tags"]:
|
|
86
|
+
tags_str = ", ".join(info["tags"]) if isinstance(info["tags"], list) else str(info["tags"])
|
|
87
|
+
print(f"Tags: {tags_str}")
|
|
88
|
+
print(f"File: {info.get('file', 'N/A')}")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
elif parsed.command == "search":
|
|
92
|
+
search(
|
|
93
|
+
query=parsed.query,
|
|
94
|
+
category=parsed.category,
|
|
95
|
+
language=parsed.language,
|
|
96
|
+
return_data=False,
|
|
97
|
+
)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
elif parsed.command == "config":
|
|
101
|
+
if parsed.username or parsed.repository or parsed.branch:
|
|
102
|
+
configure(
|
|
103
|
+
username=parsed.username,
|
|
104
|
+
repository=parsed.repository,
|
|
105
|
+
branch=parsed.branch,
|
|
106
|
+
persist=True,
|
|
107
|
+
)
|
|
108
|
+
print("Configuration saved to ~/.mycodes/config.json:")
|
|
109
|
+
current = get_config().to_dict()
|
|
110
|
+
print(f" Username: {current.get('username') or '(not set)'}")
|
|
111
|
+
print(f" Repository: {current.get('repository')}")
|
|
112
|
+
print(f" Branch: {current.get('branch')}")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
elif parsed.command == "run":
|
|
116
|
+
if not parsed.allow_execution:
|
|
117
|
+
print(
|
|
118
|
+
"Error: Remote code execution is disabled by default for security.\n"
|
|
119
|
+
"Use --allow-execution to run this code.",
|
|
120
|
+
file=sys.stderr,
|
|
121
|
+
)
|
|
122
|
+
return 1
|
|
123
|
+
run(parsed.id, allow_execution=True)
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
except MyCodesError as e:
|
|
127
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
except Exception as e:
|
|
130
|
+
print(f"Unexpected error: {e}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
sys.exit(main())
|
mycodes/client.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""HTTP client and caching layer for interacting with remote GitHub repository."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Dict, Any, Optional
|
|
9
|
+
from mycodes.config import Config, get_config
|
|
10
|
+
from mycodes.exceptions import (
|
|
11
|
+
RepositoryNotFoundError,
|
|
12
|
+
RemoteFetchError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
USER_AGENT = "MyCodes-Python-Client/0.1.0"
|
|
16
|
+
INDEX_CACHE_TTL = 300 # 5 minutes default in-memory TTL
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitHubClient:
|
|
20
|
+
"""Handles communication with GitHub raw content and manages caching."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: Optional[Config] = None):
|
|
23
|
+
self._config = config
|
|
24
|
+
self._index_cache: Optional[Dict[str, Any]] = None
|
|
25
|
+
self._index_fetched_at: float = 0.0
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def config(self) -> Config:
|
|
29
|
+
return self._config or get_config()
|
|
30
|
+
|
|
31
|
+
def fetch_url(self, url: str) -> str:
|
|
32
|
+
"""Fetch raw text content from a given URL with timeout and custom headers."""
|
|
33
|
+
headers = {
|
|
34
|
+
"User-Agent": USER_AGENT,
|
|
35
|
+
"Accept": "text/plain, application/json, */*",
|
|
36
|
+
}
|
|
37
|
+
req = urllib.request.Request(url, headers=headers)
|
|
38
|
+
timeout = self.config.get_timeout()
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
42
|
+
content_bytes = response.read()
|
|
43
|
+
return content_bytes.decode("utf-8")
|
|
44
|
+
except urllib.error.HTTPError as e:
|
|
45
|
+
if e.code == 404:
|
|
46
|
+
# 404 handled specifically by caller if needed
|
|
47
|
+
raise e
|
|
48
|
+
elif e.code == 403:
|
|
49
|
+
raise RemoteFetchError(
|
|
50
|
+
f"GitHub API/Raw rate limit exceeded or access forbidden (HTTP 403) at {url}."
|
|
51
|
+
) from e
|
|
52
|
+
elif e.code >= 500:
|
|
53
|
+
raise RemoteFetchError(
|
|
54
|
+
f"GitHub server error (HTTP {e.code}) while requesting {url}."
|
|
55
|
+
) from e
|
|
56
|
+
else:
|
|
57
|
+
raise RemoteFetchError(
|
|
58
|
+
f"HTTP error {e.code} ({e.reason}) while requesting {url}."
|
|
59
|
+
) from e
|
|
60
|
+
except urllib.error.URLError as e:
|
|
61
|
+
raise RemoteFetchError(
|
|
62
|
+
f"Network connection failed: {e.reason} while requesting {url}."
|
|
63
|
+
) from e
|
|
64
|
+
except TimeoutError as e:
|
|
65
|
+
raise RemoteFetchError(
|
|
66
|
+
f"Connection timed out after {timeout} seconds while requesting {url}."
|
|
67
|
+
) from e
|
|
68
|
+
except Exception as e:
|
|
69
|
+
raise RemoteFetchError(
|
|
70
|
+
f"Unexpected error while fetching {url}: {str(e)}"
|
|
71
|
+
) from e
|
|
72
|
+
|
|
73
|
+
def fetch_index(self, refresh: bool = False) -> Dict[str, Any]:
|
|
74
|
+
"""
|
|
75
|
+
Fetch and parse index.json from the configured GitHub repository.
|
|
76
|
+
Uses in-memory cache unless expired or refresh=True.
|
|
77
|
+
"""
|
|
78
|
+
now = time.time()
|
|
79
|
+
if (
|
|
80
|
+
not refresh
|
|
81
|
+
and self._index_cache is not None
|
|
82
|
+
and (now - self._index_fetched_at) < INDEX_CACHE_TTL
|
|
83
|
+
):
|
|
84
|
+
return self._index_cache
|
|
85
|
+
|
|
86
|
+
url = self.config.get_raw_url("index.json")
|
|
87
|
+
try:
|
|
88
|
+
raw_data = self.fetch_url(url)
|
|
89
|
+
except urllib.error.HTTPError as e:
|
|
90
|
+
if e.code == 404:
|
|
91
|
+
raise RepositoryNotFoundError(
|
|
92
|
+
username=self.config.username,
|
|
93
|
+
repository=self.config.get_repository(),
|
|
94
|
+
branch=self.config.get_branch(),
|
|
95
|
+
) from e
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
parsed = json.loads(raw_data)
|
|
100
|
+
except json.JSONDecodeError as e:
|
|
101
|
+
raise RemoteFetchError(
|
|
102
|
+
f"Failed to parse index.json from {url}: Invalid JSON structure ({str(e)})."
|
|
103
|
+
) from e
|
|
104
|
+
|
|
105
|
+
if not isinstance(parsed, dict):
|
|
106
|
+
raise RemoteFetchError(
|
|
107
|
+
f"Invalid index.json schema from {url}: Expected root object (dict), got {type(parsed).__name__}."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self._index_cache = parsed
|
|
111
|
+
self._index_fetched_at = now
|
|
112
|
+
return parsed
|
|
113
|
+
|
|
114
|
+
def fetch_code(self, file_path: str, cache: bool = False, refresh: bool = False) -> str:
|
|
115
|
+
"""
|
|
116
|
+
Fetch source code file from GitHub.
|
|
117
|
+
If cache=True, caches locally to avoid repeated downloads.
|
|
118
|
+
"""
|
|
119
|
+
cache_file = None
|
|
120
|
+
if cache:
|
|
121
|
+
cache_dir = self.config.cache_dir
|
|
122
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
# Create a deterministic filename based on url
|
|
124
|
+
cache_key = hashlib.sha256(file_path.encode("utf-8")).hexdigest()
|
|
125
|
+
cache_file = cache_dir / f"{cache_key}.py"
|
|
126
|
+
|
|
127
|
+
if not refresh and cache_file.exists():
|
|
128
|
+
try:
|
|
129
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
130
|
+
return f.read()
|
|
131
|
+
except Exception:
|
|
132
|
+
# On cache read failure, fall back to remote fetch
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
url = self.config.get_raw_url(file_path)
|
|
136
|
+
try:
|
|
137
|
+
code = self.fetch_url(url)
|
|
138
|
+
except urllib.error.HTTPError as e:
|
|
139
|
+
if e.code == 404:
|
|
140
|
+
raise RemoteFetchError(
|
|
141
|
+
f"Code file '{file_path}' was not found at {url} (HTTP 404)."
|
|
142
|
+
) from e
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
if cache and cache_file is not None:
|
|
146
|
+
try:
|
|
147
|
+
with open(cache_file, "w", encoding="utf-8") as f:
|
|
148
|
+
f.write(code)
|
|
149
|
+
except Exception:
|
|
150
|
+
# Cache write error should not fail the user's operation
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
return code
|
|
154
|
+
|
|
155
|
+
def clear_cache(self) -> None:
|
|
156
|
+
"""Clear both in-memory index cache and disk cache."""
|
|
157
|
+
self._index_cache = None
|
|
158
|
+
self._index_fetched_at = 0.0
|
|
159
|
+
cache_dir = self.config.cache_dir
|
|
160
|
+
if cache_dir.exists():
|
|
161
|
+
for f in cache_dir.glob("*.py"):
|
|
162
|
+
try:
|
|
163
|
+
f.unlink()
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Default singleton client
|
|
169
|
+
_DEFAULT_CLIENT = GitHubClient()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_client() -> GitHubClient:
|
|
173
|
+
"""Return default singleton client."""
|
|
174
|
+
return _DEFAULT_CLIENT
|
mycodes/config.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Configuration management for the MyCodes library."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
from mycodes.exceptions import ConfigurationError
|
|
8
|
+
|
|
9
|
+
CONFIG_DIR = Path.home() / ".mycodes"
|
|
10
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
11
|
+
|
|
12
|
+
DEFAULT_REPOSITORY = "my-code-library"
|
|
13
|
+
DEFAULT_BRANCH = "main"
|
|
14
|
+
DEFAULT_TIMEOUT = 10.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Config:
|
|
18
|
+
"""Holds configuration state for MyCodes."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
username: Optional[str] = None,
|
|
23
|
+
repository: Optional[str] = None,
|
|
24
|
+
branch: Optional[str] = None,
|
|
25
|
+
cache_dir: Optional[Path] = None,
|
|
26
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
27
|
+
):
|
|
28
|
+
self._username = username
|
|
29
|
+
self._repository = repository
|
|
30
|
+
self._branch = branch
|
|
31
|
+
self.cache_dir = cache_dir or (CONFIG_DIR / "cache")
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def username(self) -> str:
|
|
36
|
+
"""Get the configured GitHub username or raise ConfigurationError."""
|
|
37
|
+
# 1. In-memory explicit setting
|
|
38
|
+
if self._username:
|
|
39
|
+
return self._username
|
|
40
|
+
|
|
41
|
+
# 2. Environment variable
|
|
42
|
+
env_user = os.environ.get("MYCODES_GITHUB_USER")
|
|
43
|
+
if env_user:
|
|
44
|
+
return env_user
|
|
45
|
+
|
|
46
|
+
# 3. Persistent config file
|
|
47
|
+
file_config = self._load_file_config()
|
|
48
|
+
if "username" in file_config and file_config["username"]:
|
|
49
|
+
return file_config["username"]
|
|
50
|
+
|
|
51
|
+
raise ConfigurationError(
|
|
52
|
+
"GitHub username is not configured. Please configure it via:\n"
|
|
53
|
+
" 1. configure(username='YOUR_GITHUB_USERNAME') in Python\n"
|
|
54
|
+
" 2. Setting the MYCODES_GITHUB_USER environment variable\n"
|
|
55
|
+
" 3. Running 'mycodes config --username YOUR_GITHUB_USERNAME' in your terminal."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@username.setter
|
|
59
|
+
def username(self, val: Optional[str]):
|
|
60
|
+
self._username = val
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def repository(self) -> str:
|
|
64
|
+
return self.get_repository()
|
|
65
|
+
|
|
66
|
+
@repository.setter
|
|
67
|
+
def repository(self, val: Optional[str]):
|
|
68
|
+
self._repository = val
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def branch(self) -> str:
|
|
72
|
+
return self.get_branch()
|
|
73
|
+
|
|
74
|
+
@branch.setter
|
|
75
|
+
def branch(self, val: Optional[str]):
|
|
76
|
+
self._branch = val
|
|
77
|
+
|
|
78
|
+
def get_repository(self) -> str:
|
|
79
|
+
"""Get the configured repository name."""
|
|
80
|
+
if self._repository:
|
|
81
|
+
return self._repository
|
|
82
|
+
env_repo = os.environ.get("MYCODES_GITHUB_REPO")
|
|
83
|
+
if env_repo:
|
|
84
|
+
return env_repo
|
|
85
|
+
file_config = self._load_file_config()
|
|
86
|
+
return file_config.get("repository", DEFAULT_REPOSITORY)
|
|
87
|
+
|
|
88
|
+
def get_branch(self) -> str:
|
|
89
|
+
"""Get the configured branch name."""
|
|
90
|
+
if self._branch:
|
|
91
|
+
return self._branch
|
|
92
|
+
env_branch = os.environ.get("MYCODES_GITHUB_BRANCH")
|
|
93
|
+
if env_branch:
|
|
94
|
+
return env_branch
|
|
95
|
+
file_config = self._load_file_config()
|
|
96
|
+
return file_config.get("branch", DEFAULT_BRANCH)
|
|
97
|
+
|
|
98
|
+
def get_timeout(self) -> float:
|
|
99
|
+
"""Get network timeout in seconds."""
|
|
100
|
+
env_timeout = os.environ.get("MYCODES_TIMEOUT")
|
|
101
|
+
if env_timeout:
|
|
102
|
+
try:
|
|
103
|
+
return float(env_timeout)
|
|
104
|
+
except ValueError:
|
|
105
|
+
pass
|
|
106
|
+
return self.timeout
|
|
107
|
+
|
|
108
|
+
def get_raw_url(self, path: str) -> str:
|
|
109
|
+
"""Construct raw GitHub URL for a given repository relative path."""
|
|
110
|
+
user = self.username
|
|
111
|
+
repo = self.get_repository()
|
|
112
|
+
branch = self.get_branch()
|
|
113
|
+
clean_path = path.lstrip("/")
|
|
114
|
+
return f"https://raw.githubusercontent.com/{user}/{repo}/{branch}/{clean_path}"
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
117
|
+
"""Return configuration as a dictionary."""
|
|
118
|
+
return {
|
|
119
|
+
"username": self._username or os.environ.get("MYCODES_GITHUB_USER") or self._load_file_config().get("username"),
|
|
120
|
+
"repository": self.get_repository(),
|
|
121
|
+
"branch": self.get_branch(),
|
|
122
|
+
"timeout": self.get_timeout(),
|
|
123
|
+
"cache_dir": str(self.cache_dir),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def _load_file_config(self) -> Dict[str, Any]:
|
|
127
|
+
"""Load stored config from disk if exists."""
|
|
128
|
+
if CONFIG_FILE.exists():
|
|
129
|
+
try:
|
|
130
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
131
|
+
return json.load(f)
|
|
132
|
+
except Exception:
|
|
133
|
+
return {}
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
def save_to_file(self) -> None:
|
|
137
|
+
"""Save active config to ~/.mycodes/config.json."""
|
|
138
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
data = {
|
|
140
|
+
"username": self._username,
|
|
141
|
+
"repository": self.repository,
|
|
142
|
+
"branch": self.branch,
|
|
143
|
+
}
|
|
144
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
145
|
+
json.dump(data, f, indent=2)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Global singleton instance
|
|
149
|
+
_GLOBAL_CONFIG = Config()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_config() -> Config:
|
|
153
|
+
"""Return the active global configuration."""
|
|
154
|
+
return _GLOBAL_CONFIG
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def configure(
|
|
158
|
+
username: Optional[str] = None,
|
|
159
|
+
repository: Optional[str] = None,
|
|
160
|
+
branch: Optional[str] = None,
|
|
161
|
+
timeout: Optional[float] = None,
|
|
162
|
+
persist: bool = False,
|
|
163
|
+
) -> Config:
|
|
164
|
+
"""
|
|
165
|
+
Configure the MyCodes client.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
username: GitHub username/organization owning the repository.
|
|
169
|
+
repository: Name of the repository (defaults to 'my-code-library').
|
|
170
|
+
branch: Git branch name (defaults to 'main').
|
|
171
|
+
timeout: Network timeout in seconds (defaults to 10.0).
|
|
172
|
+
persist: If True, writes settings to ~/.mycodes/config.json.
|
|
173
|
+
"""
|
|
174
|
+
if username is not None:
|
|
175
|
+
_GLOBAL_CONFIG.username = username
|
|
176
|
+
if repository is not None:
|
|
177
|
+
_GLOBAL_CONFIG.repository = repository
|
|
178
|
+
if branch is not None:
|
|
179
|
+
_GLOBAL_CONFIG.branch = branch
|
|
180
|
+
if timeout is not None:
|
|
181
|
+
_GLOBAL_CONFIG.timeout = timeout
|
|
182
|
+
|
|
183
|
+
if persist:
|
|
184
|
+
_GLOBAL_CONFIG.save_to_file()
|
|
185
|
+
|
|
186
|
+
return _GLOBAL_CONFIG
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def reset_config() -> None:
|
|
190
|
+
"""Reset configuration to defaults (useful for testing)."""
|
|
191
|
+
global _GLOBAL_CONFIG
|
|
192
|
+
_GLOBAL_CONFIG = Config()
|
mycodes/exceptions.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Custom exceptions for the MyCodes library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MyCodesError(Exception):
|
|
5
|
+
"""Base exception for all MyCodes errors."""
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CodeNotFoundError(MyCodesError):
|
|
10
|
+
"""Raised when a requested code ID cannot be found in the repository."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, code_id: str or int, message: str = None):
|
|
13
|
+
self.code_id = code_id
|
|
14
|
+
if message is None:
|
|
15
|
+
message = f"Code {code_id} was not found in the MyCodes repository."
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RepositoryNotFoundError(MyCodesError):
|
|
20
|
+
"""Raised when the remote GitHub repository, branch, or index cannot be accessed."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, username: str, repository: str, branch: str = "main", message: str = None):
|
|
23
|
+
self.username = username
|
|
24
|
+
self.repository = repository
|
|
25
|
+
self.branch = branch
|
|
26
|
+
if message is None:
|
|
27
|
+
message = (
|
|
28
|
+
f"Repository '{username}/{repository}' on branch '{branch}' was not found on GitHub "
|
|
29
|
+
f"or index.json is missing."
|
|
30
|
+
)
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigurationError(MyCodesError):
|
|
35
|
+
"""Raised when required configuration settings are missing or invalid."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RemoteFetchError(MyCodesError):
|
|
40
|
+
"""Raised when a network failure, timeout, or HTTP error occurs while fetching remote code."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ExecutionDisabledError(MyCodesError):
|
|
45
|
+
"""Raised when code execution is attempted without explicit authorization."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
message: str = (
|
|
50
|
+
"Remote code execution is disabled by default for security. "
|
|
51
|
+
"Pass allow_execution=True to execute retrieved code."
|
|
52
|
+
),
|
|
53
|
+
):
|
|
54
|
+
super().__init__(message)
|