jira-cli-toolkit 2.5.1__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.
jsup/credentials.py ADDED
@@ -0,0 +1,125 @@
1
+ """Credential storage with no implicit plaintext fallback."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from threading import RLock
8
+
9
+ SERVICE = "jira-cli-toolkit"
10
+ _STORE_LOCK = RLock()
11
+
12
+
13
+ class CredentialError(ValueError):
14
+ pass
15
+
16
+
17
+ def _keyring():
18
+ try:
19
+ import keyring
20
+ backend = keyring.get_keyring()
21
+ module = type(backend).__module__
22
+ # Accept only native OS stores; chained/plaintext third-party stores
23
+ # are not an implicit downgrade from secure storage.
24
+ trusted={"keyring.backends.macOS", "keyring.backends.Windows",
25
+ "keyring.backends.SecretService", "keyring.backends.kwallet"}
26
+ if module in trusted:
27
+ return backend
28
+ for candidate in getattr(backend, "backends", []):
29
+ if type(candidate).__module__ in trusted and candidate.priority > 0:
30
+ return candidate
31
+ raise CredentialError("No native credential store available. Use JIRA_API_TOKEN for headless use, or explicitly choose --storage file.")
32
+ except ImportError:
33
+ raise CredentialError("Credential-store support is missing; reinstall the package or use environment authentication.") from None
34
+
35
+
36
+ @contextmanager
37
+ def _store(allow_ui):
38
+ """Keep OS unlock dialogs out of scripted commands.
39
+
40
+ keyring 25 targets the macOS file-based Keychain, whose process-wide
41
+ interaction flag must be restored even if an operation fails.
42
+ """
43
+ backend = _keyring()
44
+ module = type(backend).__module__
45
+ if allow_ui or module == "keyring.backends.Windows":
46
+ yield backend
47
+ return
48
+ if module == "keyring.backends.macOS":
49
+ import ctypes
50
+ from keyring.backends.macOS import api
51
+ get_ui = api._sec.SecKeychainGetUserInteractionAllowed
52
+ set_ui = api._sec.SecKeychainSetUserInteractionAllowed
53
+ get_ui.argtypes = [ctypes.POINTER(ctypes.c_ubyte)]
54
+ set_ui.argtypes = [ctypes.c_ubyte]
55
+ get_ui.restype = set_ui.restype = ctypes.c_int32
56
+ with _STORE_LOCK:
57
+ previous = ctypes.c_ubyte()
58
+ api.Error.raise_for_status(get_ui(ctypes.byref(previous)))
59
+ api.Error.raise_for_status(set_ui(False))
60
+ try:
61
+ yield backend
62
+ finally:
63
+ api.Error.raise_for_status(set_ui(previous.value))
64
+ return
65
+ # Linux backends can create/unlock a wallet through an unbounded D-Bus
66
+ # prompt. Scripted use must choose an explicit noninteractive source.
67
+ raise CredentialError("This OS store can request an unlock dialog. Use environment authentication or explicitly choose --storage file for headless use; run auth login in a terminal for native storage.")
68
+
69
+
70
+ def get(profile, settings, config_path, *, allow_ui=False):
71
+ storage = settings.get("storage", "keyring")
72
+ if storage == "keyring":
73
+ try:
74
+ with _store(allow_ui) as backend:
75
+ return backend.get_password(SERVICE, settings["credential_id"]) or ""
76
+ except CredentialError:
77
+ raise
78
+ except Exception:
79
+ raise CredentialError("Could not read the OS credential store without approved access. Run auth login interactively or use environment authentication.") from None
80
+ path = Path(config_path).with_name("credentials.json")
81
+ try:
82
+ data = json.loads(path.read_text(encoding="utf-8"))
83
+ if not isinstance(data, dict):
84
+ raise CredentialError("Invalid credential file.")
85
+ return data.get(settings["credential_id"], "")
86
+ except FileNotFoundError:
87
+ return ""
88
+ except json.JSONDecodeError:
89
+ raise CredentialError("Invalid credential file.") from None
90
+
91
+
92
+ def put(settings, token, config_path, *, allow_ui=False):
93
+ if settings["storage"] == "keyring":
94
+ try:
95
+ with _store(allow_ui) as backend:
96
+ backend.set_password(SERVICE, settings["credential_id"], token)
97
+ return
98
+ except CredentialError:
99
+ raise
100
+ except Exception:
101
+ raise CredentialError("Could not save to the OS credential store. Run auth login interactively to approve access, use environment authentication, or explicitly choose --storage file (POSIX only). No plaintext fallback was written.") from None
102
+ import os
103
+ if os.name == "nt":
104
+ raise CredentialError("File storage on Windows is unsupported until ACL protection is implemented; use the Windows credential store or environment variables.")
105
+ from .config import atomic_json
106
+ path = Path(config_path).with_name("credentials.json")
107
+ data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
108
+ data[settings["credential_id"]] = token
109
+ atomic_json(path, data)
110
+
111
+
112
+ def delete(settings, config_path, *, allow_ui=False):
113
+ if settings["storage"] == "keyring":
114
+ try:
115
+ with _store(allow_ui) as backend:
116
+ backend.delete_password(SERVICE, settings["credential_id"])
117
+ except Exception:
118
+ raise CredentialError("Could not remove the OS credential. Unlock the store and retry logout.") from None
119
+ else:
120
+ from .config import atomic_json
121
+ path = Path(config_path).with_name("credentials.json")
122
+ if path.exists():
123
+ data = json.loads(path.read_text(encoding="utf-8"))
124
+ data.pop(settings["credential_id"], None)
125
+ atomic_json(path, data)
@@ -0,0 +1,13 @@
1
+ {
2
+ "schema_version": 1,
3
+ "name": "callback",
4
+ "type": "Task",
5
+ "summary": "Callback: {name} — {issue}",
6
+ "description": "Client: {name}\nIssue as reported: {issue}\nRequested callback: {callback}\nSource: jsup intake. Assumed open — team to review & update.",
7
+ "fields": {"priority": "High", "labels": ["callback"], "components": ["ops-runbook"]},
8
+ "variables": [
9
+ {"name": "name", "prompt": "Client name", "required": true},
10
+ {"name": "issue", "prompt": "Issue as reported", "required": true},
11
+ {"name": "callback", "prompt": "Requested callback", "default": "not specified"}
12
+ ]
13
+ }
jsup/fields.py ADDED
@@ -0,0 +1,204 @@
1
+ """Metadata-aware field input; arbitrary validators remain Jira's responsibility."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import date
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ import shlex
9
+ import subprocess
10
+ import os
11
+ import sys
12
+ import tempfile
13
+ import time
14
+
15
+ from . import config, ui
16
+ from .client import adf, JiraError
17
+
18
+ TTL=300
19
+
20
+
21
+ def cached(cfg,project,kind,fetch,refresh=False):
22
+ identity=[cfg['site'],cfg.get('email'),cfg.get('profile'),project,kind]
23
+ key=hashlib.sha256(json.dumps(identity).encode()).hexdigest()
24
+ path=config.CONFIG_PATH.parent/'cache'/(key+'.json')
25
+ if not refresh:
26
+ try:
27
+ entry=json.loads(path.read_text(encoding="utf-8"))
28
+ if 0<=time.time()-entry['saved_at']<TTL:
29
+ return entry['data']
30
+ except (OSError,ValueError,KeyError,TypeError): pass
31
+ data=fetch()
32
+ try: config.atomic_json(path,{'saved_at':time.time(),'data':data})
33
+ except OSError: pass # Cache availability must not block Jira operations.
34
+ return data
35
+
36
+
37
+ def invalidate(cfg):
38
+ # Invalidate this CLI's metadata cache after a validation failure.
39
+ for path in (config.CONFIG_PATH.parent/'cache').glob('*.json'):
40
+ try: path.unlink()
41
+ except OSError: pass
42
+
43
+
44
+ def choose(items,value,label):
45
+ if not isinstance(items,list) or any(not isinstance(i,dict) or 'id' not in i for i in items):
46
+ raise ValueError('Jira returned invalid value metadata.')
47
+ if value is None: raise ValueError(f'Pass {label}; available values: '+', '.join(f"{i['name']} [id:{i['id']}]" for i in items))
48
+ raw=str(value).removeprefix('id:')
49
+ exact=[i for i in items if str(i.get('id'))==raw]
50
+ matches=exact or [i for i in items if str(i.get('name',i.get('value',''))).casefold()==raw.casefold()]
51
+ if len(matches)!=1:
52
+ raise ValueError(f'{label} {value!r} is unavailable or ambiguous; use an explicit ID. Available: '+', '.join(f"{i.get('name',i.get('value',''))} [id:{i.get('id')}]" for i in items))
53
+ return matches[0]
54
+
55
+
56
+ def normalize(meta):
57
+ if isinstance(meta,dict):
58
+ if any(not isinstance(value,dict) for value in meta.values()):
59
+ raise ValueError('Jira returned invalid field metadata.')
60
+ return meta
61
+ if not isinstance(meta,list) or any(not isinstance(item,dict) or not (item.get('fieldId') or item.get('key')) for item in meta):
62
+ raise ValueError('Jira returned invalid field metadata.')
63
+ return {item.get('fieldId') or item['key']:item for item in meta}
64
+
65
+
66
+ def field_key(meta,key):
67
+ if key in meta: return key
68
+ matches=[fid for fid,f in meta.items() if f.get('name','').casefold()==key.casefold()]
69
+ if len(matches)!=1: raise ValueError(f'Unknown or ambiguous field {key!r}; use its field ID (project fields).')
70
+ return matches[0]
71
+
72
+
73
+ def typed(field,value):
74
+ schema=field.get('schema',{}); kind=schema.get('type')
75
+ if value is None: return None
76
+ if isinstance(value,(dict,list,int,float,bool)):
77
+ return value # Explicit structured values are passed intact for Jira validation.
78
+ if kind=='array':
79
+ values=value.split(',')
80
+ element={**field,'schema':{**schema,'type':schema.get('items')}}
81
+ return [typed(element,v.strip()) for v in values if v.strip()]
82
+ allowed=field.get('allowedValues',[])
83
+ if allowed:
84
+ if isinstance(allowed[0],str):
85
+ matches=[option for option in allowed if option.casefold()==value.casefold()]
86
+ if len(matches)!=1: raise ValueError(f"{field.get('name')}: value is not allowed.")
87
+ return matches[0]
88
+ item=choose(allowed,value,field.get('name','field'))
89
+ if isinstance(item,dict):
90
+ return {'id':str(item['id'])} if 'id' in item else {'value':item.get('value',item.get('name'))}
91
+ return item
92
+ if kind in ('number','integer'):
93
+ try: return int(value) if kind=='integer' else float(value)
94
+ except ValueError: raise ValueError(f"{field.get('name')}: expected a number.") from None
95
+ if kind=='date':
96
+ try: return date.fromisoformat(value).isoformat()
97
+ except ValueError: raise ValueError(f"{field.get('name')}: use YYYY-MM-DD.") from None
98
+ if kind=='user': return {'accountId':value.removeprefix('id:')}
99
+ if kind in ('priority','component','option','issuetype','resolution','version'):
100
+ if not value.startswith('id:'): raise ValueError(f"{field.get('name')}: pass id:<ID> or structured JSON; no allowed-value names were returned.")
101
+ return {'id':value[3:]}
102
+ if field.get('key')=='description' or schema.get('system')=='description' or schema.get('custom','').endswith(':textarea'):
103
+ return adf(value)
104
+ if kind in ('string',None): return value
105
+ if kind=='issuelink': return {'key':value}
106
+ raise ValueError(f"{field.get('name')}: unsupported field format ({kind}); supply a structured JSON value using --field FIELD:=JSON or --fields-file.")
107
+
108
+
109
+ def supplied(args,meta):
110
+ values={}
111
+ if getattr(args,'fields_file',None):
112
+ values=json.loads(Path(args.fields_file).read_text(encoding="utf-8"))
113
+ if not isinstance(values,dict): raise ValueError('--fields-file must contain a JSON object of field values.')
114
+ for entry in getattr(args,'field',[]):
115
+ separator=':=' if ':=' in entry else '='
116
+ key,found,value=entry.partition(separator)
117
+ if not found or not key: raise ValueError('Use --field FIELD=VALUE or FIELD:=JSON.')
118
+ values[key]=json.loads(value) if separator==':=' else value
119
+ result={}
120
+ for key,value in values.items():
121
+ fid=field_key(meta,key)
122
+ if fid in result: raise ValueError(f'Duplicate values refer to field {fid}; use one ID/name.')
123
+ result[fid]=typed(meta[fid],value)
124
+ return result
125
+
126
+
127
+ def description(args):
128
+ if getattr(args,'desc_file',None): return Path(args.desc_file).read_text(encoding="utf-8")
129
+ if getattr(args,'editor',False):
130
+ if args.no_input or args.json or not sys.stdin.isatty(): raise ValueError('--editor requires interactive input without --json.')
131
+ editor=os.getenv('VISUAL') or os.getenv('EDITOR')
132
+ if not editor: raise ValueError('Set VISUAL or EDITOR before using --editor.')
133
+ with tempfile.TemporaryDirectory(prefix='jira-editor-') as temp:
134
+ path=Path(temp)/'description.txt'; path.write_text(getattr(args,'desc',None) or '', encoding='utf-8')
135
+ try: subprocess.run([*shlex.split(editor),str(path)],check=True)
136
+ except subprocess.SubprocessError: raise ValueError('Editor failed; no Jira operation was submitted.') from None
137
+ return path.read_text(encoding="utf-8")
138
+ return getattr(args,'desc',None)
139
+
140
+
141
+ def require_fields(args,meta,fields):
142
+ missing=[key for key,f in meta.items() if f.get('required') and not f.get('hasDefaultValue') and fields.get(key) in (None,'',[])]
143
+ if missing and (args.no_input or args.json or not sys.stdin.isatty()):
144
+ raise ValueError('Required fields missing: '+', '.join(f"{meta[k].get('name',k)} ({k})" for k in missing)+'. Supply --field FIELD=VALUE or --fields-file.')
145
+ for key in missing:
146
+ field=meta[key]; allowed=field.get('allowedValues',[])
147
+ if allowed:
148
+ index=ui.pick(field.get('name',key),[f"{v.get('name',v.get('value'))} [id:{v.get('id')}]" for v in allowed])
149
+ fields[key]=typed(field,'id:'+str(allowed[index]['id']))
150
+ else:
151
+ fields[key]=typed(field,input(f"{field.get('name',key)} ({key}): ").strip())
152
+ if fields[key] in (None,'',[]): raise ValueError(f'Required field {key} cannot be empty.')
153
+
154
+
155
+ def prepare_create(j,args,cfg):
156
+ project=cfg['project']
157
+ if not project: raise ValueError('Select a project with --project or context use.')
158
+ types=cached(cfg,project,'types',lambda:j.issue_types(project),args.refresh)
159
+ issue_type=args.type
160
+ if not issue_type and not args.no_input and not args.json and sys.stdin.isatty():
161
+ issue_type=types[ui.pick('Issue type',[f"{i['name']} [{i['id']}]" for i in types])]['id']
162
+ selected=choose(types,issue_type,'--type')
163
+ meta=normalize(cached(cfg,project,'create:'+selected['id'],lambda:j.create_fields(project,selected['id']),args.refresh))
164
+ provided=supplied(args,meta)
165
+ explicit={'summary':args.summary,'description':description(args),'priority':args.priority,
166
+ 'assignee':args.assignee,'labels':args.label or None,'components':args.component or None,'parent':args.parent}
167
+ fields={}
168
+ for key,value in getattr(args,'template_fields',{}).items():
169
+ fid=field_key(meta,key)
170
+ if fid in provided or explicit.get(fid) is not None:
171
+ continue
172
+ if isinstance(value,list) and all(isinstance(v,str) for v in value): value=','.join(value)
173
+ fields[fid]=typed(meta[fid],value)
174
+ fields.update(provided)
175
+ if 'project' in fields or 'issuetype' in fields: raise ValueError('Select project/type with --project and --type, not --field.')
176
+ fields.update(project={'key':project},issuetype={'id':selected['id']})
177
+ for key,value in explicit.items():
178
+ if value is not None:
179
+ if key not in meta: raise ValueError(f'Field {key} is not available for creation in this project/type.')
180
+ if key in ('parent',): fields[key]={'key':value}
181
+ else: fields[key]=typed(meta[key],','.join(value) if isinstance(value,list) else value)
182
+ require_fields(args,meta,fields)
183
+ return fields
184
+
185
+
186
+ def create(j,args,cfg):
187
+ if getattr(args,'template',None):
188
+ from .templates import apply
189
+ apply(args)
190
+ fields=prepare_create(j,args,cfg)
191
+ try: return j.create_with_fields(fields)
192
+ except JiraError as error:
193
+ if error.status in (400,422): invalidate(cfg)
194
+ raise
195
+
196
+
197
+ def transition(j,args,cfg):
198
+ transitions=j.transition_fields(args.key).get('transitions',[])
199
+ selected=choose(transitions,args.to,'--to')
200
+ meta=normalize(selected.get('fields',{}))
201
+ fields=supplied(args,meta)
202
+ require_fields(args,meta,fields)
203
+ result=j.transition_with_fields(args.key,selected['id'],fields)
204
+ return {**result, 'moved':args.key, 'to':selected['name']}
jsup/maintenance.py ADDED
@@ -0,0 +1,105 @@
1
+ """Issue maintenance with metadata validation and explicit destructive actions."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+
8
+ from . import fields
9
+ from .client import JiraError, project_jql
10
+
11
+
12
+ def edit(j,args,cfg):
13
+ meta=fields.normalize(j.edit_fields(args.key))
14
+ values=fields.supplied(args,meta)
15
+ explicit={'summary':args.summary,'description':fields.description(args),'priority':args.priority,
16
+ 'duedate':args.due_date,'parent':args.parent}
17
+ for key,value in explicit.items():
18
+ if value is not None:
19
+ if key not in meta: raise ValueError(f'Field {key} is not editable on this issue.')
20
+ values[key]={'key':value} if key=='parent' else fields.typed(meta[key],value)
21
+ updates={}
22
+ for singular,plural in [('label','labels'),('component','components')]:
23
+ replace=getattr(args,singular); add=getattr(args,'add_'+singular); remove=getattr(args,'remove_'+singular)
24
+ if replace and (add or remove): raise ValueError(f'Use either --{singular} replacement or add/remove operations.')
25
+ if (replace or add or remove) and plural not in meta: raise ValueError(f'Field {plural} is not editable.')
26
+ if replace: values[plural]=fields.typed(meta[plural],','.join(replace))
27
+ if add or remove:
28
+ if plural in values: raise ValueError(f'Field {plural} cannot be both replaced and incrementally updated.')
29
+ permitted=meta[plural].get('operations',[])
30
+ for operation,items in [('add',add),('remove',remove)]:
31
+ if items and operation not in permitted: raise ValueError(f'Jira does not allow {operation} on {plural} for this issue.')
32
+ element={**meta[plural],'schema':{**meta[plural].get('schema',{}),'type':meta[plural].get('schema',{}).get('items')}}
33
+ updates.setdefault(plural,[]).extend({operation:fields.typed(element,item)} for item in items)
34
+ if not values and not updates: raise ValueError('No changes supplied. Use issue edit --help.')
35
+ for key,value in values.items():
36
+ if meta[key].get('required') and value in (None,'',[]): raise ValueError(f'Required field {key} cannot be cleared.')
37
+ if 'set' not in meta[key].get('operations',['set']): raise ValueError(f'Jira does not allow setting {key}.')
38
+ try: return j.edit_issue(args.key,values,updates or None)
39
+ except JiraError as error:
40
+ if error.status in (400,422): fields.invalidate(cfg)
41
+ raise
42
+
43
+
44
+ def body(args):
45
+ if args.message is not None: return args.message
46
+ if getattr(args,'message_file',None): return Path(args.message_file).read_text(encoding="utf-8")
47
+ if getattr(args,'editor',False):
48
+ proxy=SimpleNamespace(desc=None,desc_file=None,editor=True,json=args.json,no_input=args.no_input)
49
+ return fields.description(proxy)
50
+ raise ValueError('Supply --message, --message-file, or interactive --editor.')
51
+
52
+
53
+ def issue_list(j,args,cfg):
54
+ filters=bool(args.open or args.project or args.assignee or args.status or args.type or args.label or args.board)
55
+ if args.jql:
56
+ if filters or args.order_by!='updated' or args.order!='desc': raise ValueError('--jql is a complete query; omit generated filters and ordering flags.')
57
+ jql=args.jql
58
+ else:
59
+ clauses=[]
60
+ project=args.project or (None if args.board else cfg['project'])
61
+ if not project and not args.board: raise ValueError('Select --project or --board, or supply complete --jql.')
62
+ if project: clauses.append(project_jql(project))
63
+ if args.open: clauses.append('statusCategory != Done')
64
+ if args.assignee: clauses.append('assignee = '+('currentUser()' if args.assignee=='me' else json.dumps(args.assignee.removeprefix('id:'))))
65
+ if args.status: clauses.append('status IN ('+', '.join(json.dumps(s) for s in args.status)+')')
66
+ if args.type: clauses.append('issuetype = '+json.dumps(args.type))
67
+ for label in args.label: clauses.append('labels = '+json.dumps(label))
68
+ jql=' AND '.join(clauses)+f' ORDER BY {args.order_by} {args.order.upper()}'
69
+ jql=jql.strip()
70
+ options={}
71
+ if getattr(args,'all',False): options['all_results']=True
72
+ if args.fields: options['fields']=args.fields
73
+ if args.board: return j.board_issues(args.board,jql,args.max,**options)
74
+ return j.search(jql,args.max,**options)
75
+
76
+
77
+ def handle(j,args,cfg):
78
+ cmd=args.cmd
79
+ if cmd=='issue-edit': return edit(j,args,cfg)
80
+ if cmd=='issue-unassign': return j.assign(args.key,None)
81
+ if cmd=='issue-assign':
82
+ if args.user=='me': account=j.me()['accountId']
83
+ elif args.user.startswith('id:'): account=args.user[3:]
84
+ else:
85
+ candidates=j.assignable_users(args.key,args.user)
86
+ matches=[u for u in candidates if args.user.casefold() in (u.get('displayName','').casefold(),u.get('emailAddress','').casefold())]
87
+ if len(matches)!=1: raise ValueError('User is unavailable or ambiguous; use --user id:<account ID>.')
88
+ account=matches[0]['accountId']
89
+ if not account: raise ValueError('A nonempty account ID is required.')
90
+ return j.assign(args.key,account)
91
+ if cmd=='issue-link':
92
+ kind=fields.choose(j.link_types(),args.type,'--type')
93
+ inward,outward=(args.other,args.key) if args.direction=='outward' else (args.key,args.other)
94
+ return j.link(inward,outward,kind['id'])
95
+ if cmd=='issue-unlink': return j.unlink(args.id)
96
+ if cmd in ('comment-add','comment-edit'):
97
+ text=body(args)
98
+ if not text.strip(): raise ValueError('Comment body cannot be empty.')
99
+ return j.comment_add(args.key,text) if cmd=='comment-add' else j.comment_edit(args.key,args.comment_id,text)
100
+ if cmd=='comment-delete': return j.comment_delete(args.key,args.comment_id)
101
+ if cmd=='attachment-list': return j.issue_get(args.key).get('fields',{}).get('attachment',[])
102
+ if cmd=='attachment-upload': return j.attachment_upload(args.key,args.files)
103
+ if cmd=='attachment-download': return j.attachment_download(args.id,args.output)
104
+ if cmd=='attachment-delete': return j.attachment_delete(args.id)
105
+ raise ValueError('Unsupported maintenance operation.')
jsup/templates.py ADDED
@@ -0,0 +1,89 @@
1
+ """Versioned, non-executable team templates."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ from importlib.resources import files
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ import string
10
+ import sys
11
+
12
+ from . import config
13
+
14
+ ALLOWED={'schema_version','name','type','summary','description','fields','variables'}
15
+
16
+
17
+ def load(name):
18
+ if name=='callback':
19
+ data=json.loads(files('jsup').joinpath('data/callback.json').read_text(encoding="utf-8"))
20
+ else:
21
+ path=Path(name)
22
+ if path.suffix!='.json' and len(path.parts)==1:
23
+ if not re.fullmatch(r'[a-zA-Z0-9_-]+',name): raise ValueError('Invalid template name.')
24
+ path=config.CONFIG_PATH.parent/'templates'/(name+'.json')
25
+ data=json.loads(path.read_text(encoding="utf-8"))
26
+ validate(data)
27
+ return data
28
+
29
+
30
+ def validate(data):
31
+ if not isinstance(data,dict) or data.get('schema_version')!=1 or set(data)-ALLOWED:
32
+ raise ValueError('Unsupported template schema/keys. Templates cannot contain hooks or credentials.')
33
+ if not isinstance(data.get('fields',{}),dict) or not isinstance(data.get('variables',[]),list):
34
+ raise ValueError('Template fields must be an object and variables a list.')
35
+ names=set()
36
+ for variable in data.get('variables',[]):
37
+ if not isinstance(variable,dict) or set(variable)-{'name','prompt','required','default'}:
38
+ raise ValueError('Invalid template variable.')
39
+ name=variable.get('name','')
40
+ if not re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_]*',name) or name in names:
41
+ raise ValueError('Invalid or duplicate template variable name.')
42
+ names.add(name)
43
+ for key in ('summary','description'):
44
+ if key in data:
45
+ if not isinstance(data[key],str): raise ValueError('Template summary/description must be strings.')
46
+ try:
47
+ for literal,name,spec,conversion in string.Formatter().parse(data[key]):
48
+ if name is not None and (name not in names or spec or conversion):
49
+ raise ValueError('Template placeholders must be declared simple variable names; expressions are unsupported.')
50
+ except ValueError:
51
+ raise ValueError('Invalid template pattern or placeholder.') from None
52
+ return data
53
+
54
+
55
+ def render(data,variables):
56
+ validate(data)
57
+ declared={v['name']:v for v in data.get('variables',[])}
58
+ if set(variables)-set(declared): raise ValueError('Unknown template variables: '+', '.join(sorted(set(variables)-set(declared))))
59
+ resolved={name:variables.get(name,entry.get('default','')) for name,entry in declared.items()}
60
+ missing=[name for name,entry in declared.items() if entry.get('required') and not resolved[name]]
61
+ if missing: raise ValueError('Missing template variables: '+', '.join(missing))
62
+ fields=copy.deepcopy(data.get('fields',{}))
63
+ if 'summary' in data: fields['summary']=data['summary'].format_map(resolved)
64
+ if 'description' in data: fields['description']=data['description'].format_map(resolved)
65
+ return fields
66
+
67
+
68
+ def apply(args, *, validating=False):
69
+ data=load(args.template)
70
+ values={}
71
+ for item in args.var:
72
+ key,sep,value=item.partition('=')
73
+ if not sep: raise ValueError('Use --var NAME=VALUE.')
74
+ values[key]=value
75
+ for variable in data.get('variables',[]):
76
+ name=variable['name']
77
+ if name not in values and variable.get('required'):
78
+ if validating:
79
+ values[name]='example'
80
+ elif not args.no_input and not args.json and sys.stdin.isatty():
81
+ values[name]=input(variable.get('prompt',name)+': ').strip()
82
+ args.template_fields=render(data,values)
83
+ if not args.type: args.type=data.get('type')
84
+ return args
85
+
86
+
87
+ def list_templates():
88
+ local=config.CONFIG_PATH.parent/'templates'
89
+ return {'templates':['callback']+sorted(p.stem for p in local.glob('*.json') if p.stem!='callback')}