mystack-rig 1.0.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.
cli/git.py ADDED
@@ -0,0 +1,317 @@
1
+ """
2
+ Git module for workspace project management
3
+
4
+ This module provides functionality for:
5
+ - Cloning git repositories on remote development environments
6
+ - Managing git configurations
7
+ - Handling authentication with PAT and SSH keys
8
+ """
9
+
10
+ import os
11
+ import subprocess
12
+ from typing import Dict, List, Optional, TYPE_CHECKING
13
+ import yaml
14
+
15
+ if TYPE_CHECKING:
16
+ from cli.remote_dev import ProjectConfig
17
+
18
+
19
+ class GitManager:
20
+ """Manages git operations for workspace projects"""
21
+
22
+ def __init__(self, project_config: Optional["ProjectConfig"] = None):
23
+ self.project_config = project_config
24
+ self.repos_to_clone: List[Dict] = []
25
+ self.ssh_repos: List[Dict] = []
26
+ self.git_config: Dict = {}
27
+
28
+ def load_git_config(self, config_path: Optional[str] = None) -> bool:
29
+ """Load git configuration from vars.yml"""
30
+ try:
31
+ if self.project_config is None:
32
+ return False
33
+
34
+ self.project_config.load()
35
+ git_config = self.project_config.get_vars('git')
36
+
37
+ if git_config:
38
+ self.git_config = git_config
39
+ self.repos_to_clone = git_config.get('clone_repos', [])
40
+ self.ssh_repos = git_config.get('ssh_repos', [])
41
+ return True
42
+
43
+ return False
44
+ except Exception as e:
45
+ print(f"Warning: Could not load git config: {e}")
46
+ return False
47
+
48
+ def get_repos_to_clone(self) -> List[Dict]:
49
+ """Get list of repositories to clone"""
50
+ return self.repos_to_clone + self.ssh_repos
51
+
52
+ def generate_clone_tasks(self) -> List[Dict]:
53
+ """Generate Ansible tasks for cloning repositories"""
54
+ tasks = []
55
+
56
+ # Add SSH known hosts setup for public repos
57
+ for repo in self.repos_to_clone:
58
+ repo_url = repo.get('repo', '')
59
+ if repo_url.startswith('https://') or repo_url.startswith('http://'):
60
+ tasks.append({
61
+ 'name': f'Add {self._get_hostname(repo_url)} SSH known hosts',
62
+ 'shell': f'ssh-keyscan {self._get_hostname(repo_url)} >> ~/.ssh/known_hosts 2>/dev/null || true',
63
+ 'changed_when': False,
64
+ 'ignore_errors': True
65
+ })
66
+ break # Only add once
67
+
68
+ # Add clone tasks for public repos
69
+ for repo in self.repos_to_clone:
70
+ tasks.append({
71
+ 'name': f'Clone {self._get_repo_name(repo.get("repo", ""))}',
72
+ 'git': {
73
+ 'repo': repo.get('repo'),
74
+ 'dest': repo.get('dest'),
75
+ 'accept_hostkey': repo.get('accept_hostkey', True),
76
+ 'force': repo.get('force', False),
77
+ 'version': repo.get('version', 'main')
78
+ }
79
+ })
80
+
81
+ # Add clone tasks for SSH repos
82
+ for repo in self.ssh_repos:
83
+ tasks.append({
84
+ 'name': f'Clone {self._get_repo_name(repo.get("repo", ""))} (SSH)',
85
+ 'git': {
86
+ 'repo': repo.get('repo'),
87
+ 'dest': repo.get('dest'),
88
+ 'accept_hostkey': repo.get('accept_hostkey', True),
89
+ 'force': repo.get('force', False),
90
+ 'version': repo.get('version', 'main'),
91
+ 'ssh_key': repo.get('ssh_key', '/root/.ssh/id_rsa')
92
+ }
93
+ })
94
+
95
+ return tasks
96
+
97
+ def _get_hostname(self, repo_url: str) -> str:
98
+ """Extract hostname from repository URL"""
99
+ if repo_url.startswith('https://') or repo_url.startswith('http://'):
100
+ return repo_url.split('/')[2]
101
+ if repo_url.startswith('git@'):
102
+ # git@host:owner/repo.git → host
103
+ return repo_url[len('git@'):].split(':', 1)[0] or 'github.com'
104
+ return 'github.com'
105
+
106
+ def _get_repo_name(self, repo_url: str) -> str:
107
+ """Extract repository name from URL"""
108
+ # Remove .git suffix
109
+ name = repo_url.rstrip('/')
110
+ if name.endswith('.git'):
111
+ name = name[:-4]
112
+
113
+ # Get last part of URL
114
+ if '/' in name:
115
+ return name.split('/')[-1]
116
+ return name
117
+
118
+ def setup_git_environment(self, project_dir: str) -> bool:
119
+ """Set up git environment for a project"""
120
+ try:
121
+ # Ensure .rig directory exists
122
+ rig_dir = os.path.join(project_dir, '.rig')
123
+ if not os.path.exists(rig_dir):
124
+ os.makedirs(rig_dir)
125
+
126
+ # Load existing config if available
127
+ if self.project_config is None:
128
+ from cli.remote_dev import ProjectConfig
129
+ self.project_config = ProjectConfig(project_dir)
130
+
131
+ self.project_config.load()
132
+
133
+ # Set default git configuration if not present
134
+ if not self.project_config.get_vars('git'):
135
+ self.project_config.set_vars('git.clone_repos', [])
136
+ self.project_config.set_vars('git.ssh_repos', [])
137
+
138
+ return True
139
+ except Exception as e:
140
+ print(f"Error setting up git environment: {e}")
141
+ return False
142
+
143
+ def add_repo(
144
+ self,
145
+ repo_url: str,
146
+ dest: str,
147
+ is_ssh: bool = False,
148
+ password_var: Optional[str] = None,
149
+ ) -> bool:
150
+ """Add a repository to the clone list"""
151
+ try:
152
+ if self.project_config is None:
153
+ from cli.remote_dev import ProjectConfig
154
+ self.project_config = ProjectConfig()
155
+
156
+ self.project_config.load()
157
+
158
+ git_config = self.project_config.get_vars('git') or {}
159
+ clone_repos = git_config.get('clone_repos', [])
160
+ ssh_repos = git_config.get('ssh_repos', [])
161
+
162
+ repo_config = {
163
+ 'repo': repo_url,
164
+ 'dest': dest,
165
+ 'accept_hostkey': True
166
+ }
167
+ if password_var:
168
+ repo_config['password_var'] = password_var
169
+
170
+ if is_ssh:
171
+ repo_config['ssh_key'] = '/root/.ssh/id_rsa'
172
+ ssh_repos.append(repo_config)
173
+ else:
174
+ clone_repos.append(repo_config)
175
+
176
+ git_config['clone_repos'] = clone_repos
177
+ git_config['ssh_repos'] = ssh_repos
178
+
179
+ self.project_config.set_vars('git', git_config)
180
+ self.project_config.save(write_vars=False)
181
+
182
+ # Update internal state
183
+ self.repos_to_clone = clone_repos
184
+ self.ssh_repos = ssh_repos
185
+
186
+ return True
187
+ except Exception as e:
188
+ print(f"Error adding repository: {e}")
189
+ return False
190
+
191
+ def set_repo_password_var(self, repo_url: str, password_var: str) -> bool:
192
+ """Attach or update password_var on an existing clone_repos entry."""
193
+ try:
194
+ if self.project_config is None:
195
+ from cli.remote_dev import ProjectConfig
196
+ self.project_config = ProjectConfig()
197
+
198
+ self.project_config.load()
199
+ git_config = self.project_config.get_vars('git') or {}
200
+ clone_repos = git_config.get('clone_repos', [])
201
+ updated = False
202
+ for item in clone_repos:
203
+ if isinstance(item, dict) and item.get('repo') == repo_url:
204
+ item['password_var'] = password_var
205
+ updated = True
206
+ if not updated:
207
+ return False
208
+ git_config['clone_repos'] = clone_repos
209
+ self.project_config.set_vars('git', git_config)
210
+ self.project_config.save(write_vars=False)
211
+ self.repos_to_clone = clone_repos
212
+ return True
213
+ except Exception as e:
214
+ print(f"Error setting password_var: {e}")
215
+ return False
216
+
217
+ def set_repo_dest(self, repo_url: str, dest: str) -> bool:
218
+ """Update dest on an existing clone_repos / ssh_repos entry."""
219
+ try:
220
+ if self.project_config is None:
221
+ from cli.remote_dev import ProjectConfig
222
+ self.project_config = ProjectConfig()
223
+
224
+ self.project_config.load()
225
+ git_config = self.project_config.get_vars('git') or {}
226
+ updated = False
227
+ for key in ('clone_repos', 'ssh_repos'):
228
+ entries = git_config.get(key, []) or []
229
+ for item in entries:
230
+ if isinstance(item, dict) and item.get('repo') == repo_url:
231
+ if item.get('dest') != dest:
232
+ item['dest'] = dest
233
+ updated = True
234
+ git_config[key] = entries
235
+ if not updated:
236
+ return False
237
+ self.project_config.set_vars('git', git_config)
238
+ self.project_config.save(write_vars=False)
239
+ self.repos_to_clone = git_config.get('clone_repos', [])
240
+ self.ssh_repos = git_config.get('ssh_repos', [])
241
+ return True
242
+ except Exception as e:
243
+ print(f"Error setting repo dest: {e}")
244
+ return False
245
+
246
+ def normalize_workspace_dests(self) -> int:
247
+ """
248
+ Rewrite legacy ``/root/<name>`` clone destinations under ``/workspace``.
249
+
250
+ Returns the number of entries updated.
251
+ """
252
+ from cli.remote_dev import REMOTE_WORKSPACE_ROOT, remote_workspace_path
253
+
254
+ try:
255
+ if self.project_config is None:
256
+ return 0
257
+ self.project_config.load()
258
+ git_config = self.project_config.get_vars('git') or {}
259
+ changed = 0
260
+ for key in ('clone_repos', 'ssh_repos'):
261
+ entries = git_config.get(key, []) or []
262
+ for item in entries:
263
+ if not isinstance(item, dict):
264
+ continue
265
+ dest = str(item.get('dest') or '')
266
+ if not dest:
267
+ continue
268
+ # Legacy home clones → workspace; leave /root/.ssh alone.
269
+ if dest.startswith('/root/') and not dest.startswith('/root/.'):
270
+ name = dest.rstrip('/').split('/')[-1]
271
+ item['dest'] = remote_workspace_path(name)
272
+ changed += 1
273
+ elif not dest.startswith(REMOTE_WORKSPACE_ROOT + '/') and dest != REMOTE_WORKSPACE_ROOT:
274
+ # Keep absolute paths outside /root as-is (custom mounts).
275
+ pass
276
+ git_config[key] = entries
277
+ if changed:
278
+ self.project_config.set_vars('git', git_config)
279
+ self.project_config.save(write_vars=False)
280
+ self.repos_to_clone = git_config.get('clone_repos', [])
281
+ self.ssh_repos = git_config.get('ssh_repos', [])
282
+ return changed
283
+ except Exception as e:
284
+ print(f"Error normalizing workspace dests: {e}")
285
+ return 0
286
+
287
+ def remove_repo(self, repo_url: str) -> bool:
288
+ """Remove a repository from the clone list"""
289
+ try:
290
+ if self.project_config is None:
291
+ from cli.remote_dev import ProjectConfig
292
+ self.project_config = ProjectConfig()
293
+
294
+ self.project_config.load()
295
+
296
+ git_config = self.project_config.get_vars('git') or {}
297
+ clone_repos = git_config.get('clone_repos', [])
298
+ ssh_repos = git_config.get('ssh_repos', [])
299
+
300
+ # Remove matching repos
301
+ clone_repos = [r for r in clone_repos if r.get('repo') != repo_url]
302
+ ssh_repos = [r for r in ssh_repos if r.get('repo') != repo_url]
303
+
304
+ git_config['clone_repos'] = clone_repos
305
+ git_config['ssh_repos'] = ssh_repos
306
+
307
+ self.project_config.set_vars('git', git_config)
308
+ self.project_config.save()
309
+
310
+ # Update internal state
311
+ self.repos_to_clone = clone_repos
312
+ self.ssh_repos = ssh_repos
313
+
314
+ return True
315
+ except Exception as e:
316
+ print(f"Error removing repository: {e}")
317
+ return False
cli/last_env.py ADDED
@@ -0,0 +1,64 @@
1
+ """Remember the last handled remote env name under /tmp (cleared on reboot)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ DEFAULT_LAST_ENV_PATH = "/tmp/.rig/last-env"
9
+
10
+
11
+ def remember_last_env(name: str, path: str = DEFAULT_LAST_ENV_PATH) -> None:
12
+ """Persist *name* so later commands can omit it."""
13
+ if not name:
14
+ return
15
+ directory = os.path.dirname(path)
16
+ if directory:
17
+ os.makedirs(directory, mode=0o700, exist_ok=True)
18
+ with open(path, "w", encoding="utf-8") as handle:
19
+ handle.write(f"{name.strip()}\n")
20
+ try:
21
+ os.chmod(path, 0o600)
22
+ except OSError:
23
+ pass
24
+
25
+
26
+ def read_last_env(path: str = DEFAULT_LAST_ENV_PATH) -> Optional[str]:
27
+ """Return the last remembered environment name, or None."""
28
+ try:
29
+ with open(path, encoding="utf-8") as handle:
30
+ name = handle.read().strip()
31
+ except OSError:
32
+ return None
33
+ return name or None
34
+
35
+
36
+ def clear_last_env(
37
+ name: Optional[str] = None,
38
+ path: str = DEFAULT_LAST_ENV_PATH,
39
+ ) -> None:
40
+ """
41
+ Remove the remembered name.
42
+
43
+ If *name* is given, only clear when it matches the stored value
44
+ (so destroying another env does not forget the current one).
45
+ """
46
+ if name is not None:
47
+ current = read_last_env(path=path)
48
+ if current is None or current != name:
49
+ return
50
+ try:
51
+ os.remove(path)
52
+ except OSError:
53
+ pass
54
+
55
+
56
+ def resolve_env_name(
57
+ *candidates: Optional[str],
58
+ path: str = DEFAULT_LAST_ENV_PATH,
59
+ ) -> Optional[str]:
60
+ """First non-empty candidate, else the remembered last env."""
61
+ for value in candidates:
62
+ if value and str(value).strip():
63
+ return str(value).strip()
64
+ return read_last_env(path=path)
cli/last_session.py ADDED
@@ -0,0 +1,171 @@
1
+ """Remember the last coding-agent session id under /tmp (cleared on reboot)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from typing import List, Optional, Tuple
8
+
9
+ DEFAULT_SESSION_DIR = "/tmp/.rig/sessions"
10
+
11
+ # Strip common VT sequences / CR so script(1) logs parse cleanly.
12
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r")
13
+
14
+ # Prefer explicit resume/session flags from exit banners, then bare ids.
15
+ _SESSION_PATTERNS = (
16
+ re.compile(
17
+ r"--resume(?:=|\s+)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
18
+ re.IGNORECASE,
19
+ ),
20
+ re.compile(r"--resume(?:=|\s+)([A-Za-z0-9][A-Za-z0-9_-]{5,})"),
21
+ re.compile(r"(?:--session|-s)(?:=|\s+)(ses_[A-Za-z0-9_-]+)"),
22
+ re.compile(r"\bses_[A-Za-z0-9_-]+\b"),
23
+ re.compile(
24
+ r"\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b",
25
+ re.IGNORECASE,
26
+ ),
27
+ )
28
+
29
+
30
+ def _safe_env_name(name: str) -> str:
31
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", (name or "").strip())
32
+ return cleaned.strip("-._") or "env"
33
+
34
+
35
+ def session_path(env_name: str, directory: str = DEFAULT_SESSION_DIR) -> str:
36
+ return os.path.join(directory, _safe_env_name(env_name))
37
+
38
+
39
+ def remember_last_session(
40
+ env_name: str,
41
+ session_id: str,
42
+ agent_type: Optional[str] = None,
43
+ directory: str = DEFAULT_SESSION_DIR,
44
+ ) -> None:
45
+ """Persist the last session id for *env_name*."""
46
+ if not env_name or not session_id:
47
+ return
48
+ os.makedirs(directory, mode=0o700, exist_ok=True)
49
+ path = session_path(env_name, directory=directory)
50
+ lines = [f"session={session_id.strip()}\n"]
51
+ if agent_type:
52
+ lines.insert(0, f"agent={agent_type.strip()}\n")
53
+ with open(path, "w", encoding="utf-8") as handle:
54
+ handle.writelines(lines)
55
+ try:
56
+ os.chmod(path, 0o600)
57
+ os.chmod(directory, 0o700)
58
+ except OSError:
59
+ pass
60
+
61
+
62
+ def read_last_session(
63
+ env_name: str,
64
+ directory: str = DEFAULT_SESSION_DIR,
65
+ ) -> Optional[Tuple[str, Optional[str]]]:
66
+ """
67
+ Return ``(session_id, agent_type_or_None)`` for *env_name*, if known.
68
+ """
69
+ path = session_path(env_name, directory=directory)
70
+ try:
71
+ with open(path, encoding="utf-8") as handle:
72
+ text = handle.read()
73
+ except OSError:
74
+ return None
75
+
76
+ session_id: Optional[str] = None
77
+ agent_type: Optional[str] = None
78
+ for raw in text.splitlines():
79
+ line = raw.strip()
80
+ if not line or line.startswith("#"):
81
+ continue
82
+ if "=" in line:
83
+ key, value = line.split("=", 1)
84
+ key = key.strip().lower()
85
+ value = value.strip()
86
+ if key in {"session", "id", "session_id"} and value:
87
+ session_id = value
88
+ elif key == "agent" and value:
89
+ agent_type = value
90
+ elif session_id is None:
91
+ session_id = line
92
+ if not session_id:
93
+ return None
94
+ return session_id, agent_type
95
+
96
+
97
+ def clear_last_session(
98
+ env_name: Optional[str] = None,
99
+ directory: str = DEFAULT_SESSION_DIR,
100
+ ) -> None:
101
+ """Remove stored session for one env, or all sessions when *env_name* is None."""
102
+ if env_name is None:
103
+ try:
104
+ for name in os.listdir(directory):
105
+ try:
106
+ os.remove(os.path.join(directory, name))
107
+ except OSError:
108
+ pass
109
+ except OSError:
110
+ pass
111
+ return
112
+ try:
113
+ os.remove(session_path(env_name, directory=directory))
114
+ except OSError:
115
+ pass
116
+
117
+
118
+ def strip_ansi(text: str) -> str:
119
+ return _ANSI_RE.sub("", text or "")
120
+
121
+
122
+ def parse_session_id(text: str) -> Optional[str]:
123
+ """Extract the most likely session / thread id from agent exit output."""
124
+ cleaned = strip_ansi(text)
125
+ if not cleaned.strip():
126
+ return None
127
+ last: Optional[str] = None
128
+ for pattern in _SESSION_PATTERNS:
129
+ for match in pattern.finditer(cleaned):
130
+ value = match.group(1) if match.lastindex else match.group(0)
131
+ if value:
132
+ last = value
133
+ return last
134
+
135
+
136
+ def extract_session_from_agent_args(agent_args: Optional[List[str]]) -> Optional[str]:
137
+ """Return a session id explicitly present in forwarded agent argv."""
138
+ args = list(agent_args or [])
139
+ for index, token in enumerate(args):
140
+ if token in {"--session", "-s", "--resume"} and index + 1 < len(args):
141
+ value = args[index + 1]
142
+ if value and not str(value).startswith("-"):
143
+ return str(value)
144
+ for prefix in ("--session=", "--resume=", "-s="):
145
+ if token.startswith(prefix) and len(token) > len(prefix):
146
+ return token[len(prefix):]
147
+ return None
148
+
149
+
150
+ def agent_args_request_session(agent_args: Optional[List[str]]) -> bool:
151
+ """True when argv already asks to continue/resume/select a session."""
152
+ args = list(agent_args or [])
153
+ for index, token in enumerate(args):
154
+ if token in {"--continue", "-c", "--resume", "--session", "-s"}:
155
+ return True
156
+ if token.startswith("--resume=") or token.startswith("--session=") or token.startswith("-s="):
157
+ return True
158
+ # bare ``--resume`` with optional id already covered above
159
+ if token == "--" and index + 1 < len(args):
160
+ # remainder after -- still scanned in loop
161
+ continue
162
+ return False
163
+
164
+
165
+ def resume_args_for_agent(agent_type: Optional[str], session_id: str) -> List[str]:
166
+ """Build CLI flags to reopen *session_id* for the given agent."""
167
+ agent = (agent_type or "opencode").strip().lower()
168
+ if agent in {"opencode", "open-code"}:
169
+ return ["--session", session_id]
170
+ # Claude Code and Cursor CLI both use --resume <id>
171
+ return ["--resume", session_id]
cli/log.py ADDED
@@ -0,0 +1,105 @@
1
+ """Shared CLI logging for rig.
2
+
3
+ Default level is INFO: lifecycle lines only. Playbook task output is shown
4
+ when a task fails, or when verbosity is raised.
5
+
6
+ ``rig -v`` streams each playbook task and turns on DEBUG. ``-vv``, ``-vvv``,
7
+ and ``-vvvv`` pass that same verbosity through to ansible-playbook.
8
+ ``rig -q`` / ``--quiet`` shows warnings and errors only.
9
+
10
+ Format (stderr): INFO is message-only; DEBUG/WARNING/ERROR keep a level prefix.
11
+ Stdout stays free for command output (encrypt ciphertext, decrypt plaintext, etc.).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import sys
18
+ from typing import Optional
19
+
20
+ LOGGER_NAME = "rig"
21
+ _CONFIGURED = False
22
+ _VERBOSITY = 0
23
+
24
+
25
+ class _LevelFormatter(logging.Formatter):
26
+ """INFO: bare message. Other levels: ``LEVEL message``."""
27
+
28
+ def format(self, record: logging.LogRecord) -> str:
29
+ message = record.getMessage()
30
+ if record.levelno == logging.INFO:
31
+ return message
32
+ return f"{record.levelname.upper():<5} {message}"
33
+
34
+
35
+ def get_logger(name: Optional[str] = None) -> logging.Logger:
36
+ """Return the rig logger (or a child under ``rig.``)."""
37
+ if name and name != LOGGER_NAME:
38
+ if not name.startswith(f"{LOGGER_NAME}."):
39
+ name = f"{LOGGER_NAME}.{name}"
40
+ return logging.getLogger(name)
41
+ return logging.getLogger(LOGGER_NAME)
42
+
43
+
44
+ def verbosity() -> int:
45
+ """How much detail the current command asked for. ``0`` is the default."""
46
+ return _VERBOSITY
47
+
48
+
49
+ def configure_logging(
50
+ *,
51
+ verbose: bool = False,
52
+ quiet: bool = False,
53
+ level: Optional[int] = None,
54
+ verbosity: Optional[int] = None,
55
+ ) -> logging.Logger:
56
+ """
57
+ Configure the root ``rig`` logger once (or reconfigure on subsequent calls).
58
+
59
+ ``verbosity`` counts ``-v`` flags. Precedence for the level: explicit
60
+ ``level`` > any verbosity or ``verbose`` > ``quiet`` > INFO.
61
+ """
62
+ global _CONFIGURED, _VERBOSITY
63
+ if verbosity is None:
64
+ verbosity = 1 if verbose else 0
65
+ _VERBOSITY = max(0, int(verbosity))
66
+ if level is None:
67
+ if _VERBOSITY > 0 or verbose:
68
+ level = logging.DEBUG
69
+ elif quiet:
70
+ level = logging.WARNING
71
+ else:
72
+ level = logging.INFO
73
+
74
+ logger = get_logger()
75
+ logger.handlers.clear()
76
+ handler = logging.StreamHandler(sys.stderr)
77
+ handler.setFormatter(_LevelFormatter())
78
+ logger.addHandler(handler)
79
+ logger.setLevel(level)
80
+ logger.propagate = False
81
+ _CONFIGURED = True
82
+ return logger
83
+
84
+
85
+ def ensure_logging() -> logging.Logger:
86
+ """Configure with defaults if nothing has set logging up yet."""
87
+ if not _CONFIGURED:
88
+ return configure_logging()
89
+ return get_logger()
90
+
91
+
92
+ def debug(msg: str, *args, **kwargs) -> None:
93
+ ensure_logging().debug(msg, *args, **kwargs)
94
+
95
+
96
+ def info(msg: str, *args, **kwargs) -> None:
97
+ ensure_logging().info(msg, *args, **kwargs)
98
+
99
+
100
+ def warning(msg: str, *args, **kwargs) -> None:
101
+ ensure_logging().warning(msg, *args, **kwargs)
102
+
103
+
104
+ def error(msg: str, *args, **kwargs) -> None:
105
+ ensure_logging().error(msg, *args, **kwargs)