iris-security-mcp 0.2.5__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.
- iris_mcp/__init__.py +3 -0
- iris_mcp/prompts/__init__.py +1 -0
- iris_mcp/prompts/system.py +36 -0
- iris_mcp/resources/__init__.py +1 -0
- iris_mcp/resources/frameworks.py +88 -0
- iris_mcp/server.py +237 -0
- iris_mcp/tools/__init__.py +1 -0
- iris_mcp/tools/_common.py +64 -0
- iris_mcp/tools/compliance.py +354 -0
- iris_mcp/tools/cost.py +142 -0
- iris_mcp/tools/discovery.py +214 -0
- iris_mcp/tools/evidence.py +156 -0
- iris_mcp/tools/governance.py +170 -0
- iris_mcp/tools/hitl.py +189 -0
- iris_mcp/tools/monitoring.py +213 -0
- iris_mcp/tools/regulatory.py +83 -0
- iris_security_mcp-0.2.5.dist-info/METADATA +32 -0
- iris_security_mcp-0.2.5.dist-info/RECORD +21 -0
- iris_security_mcp-0.2.5.dist-info/WHEEL +5 -0
- iris_security_mcp-0.2.5.dist-info/entry_points.txt +2 -0
- iris_security_mcp-0.2.5.dist-info/top_level.txt +1 -0
iris_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""IRIS MCP prompt templates."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""System prompts for IRIS MCP."""
|
|
2
|
+
|
|
3
|
+
IRIS_SYSTEM_PROMPT = """
|
|
4
|
+
You have access to IRIS — an AI agent governance platform.
|
|
5
|
+
IRIS helps developers declare what their AI agents are allowed
|
|
6
|
+
to do and enforces those policies at runtime.
|
|
7
|
+
|
|
8
|
+
When a developer asks about AI compliance, regulations, or
|
|
9
|
+
governing their agents, use IRIS tools to give them accurate,
|
|
10
|
+
real-time answers based on their actual codebase and agents.
|
|
11
|
+
|
|
12
|
+
Key IRIS concepts:
|
|
13
|
+
- AgentPassport: the agent's identity and compliance declaration
|
|
14
|
+
- Cedar policy: formally verified policy compiled from plain English
|
|
15
|
+
- Evidence Vault: tamper-evident audit trail of every decision
|
|
16
|
+
- HITL: human-in-the-loop approval for sensitive actions
|
|
17
|
+
|
|
18
|
+
Available frameworks (free):
|
|
19
|
+
- colorado-ai-act: Colorado SB 26-189 (effective Jan 1, 2027)
|
|
20
|
+
- ccpa-admt: California ADMT regulations (effective Jan 1, 2026)
|
|
21
|
+
- colorado-chatbot: Colorado HB 1263 (effective Jan 1, 2027)
|
|
22
|
+
- colorado-health-ai: Colorado HB 1139 (effective Jan 1, 2027)
|
|
23
|
+
- colorado-mental-health: Colorado HB 1195 (effective Aug 12, 2026)
|
|
24
|
+
- nyc-ll144: NYC Local Law 144 — AI in hiring (active now)
|
|
25
|
+
- illinois-ai-video: Illinois AI Video Interview Act (active now)
|
|
26
|
+
|
|
27
|
+
Available frameworks (Pro):
|
|
28
|
+
- nist-ai-rmf, fedramp-moderate, hipaa, soc2, gdpr, eu-ai-act,
|
|
29
|
+
china-pipl, hr-ai
|
|
30
|
+
|
|
31
|
+
When a developer asks which regulations apply to their agent,
|
|
32
|
+
call iris_framework_suggest with what you know about their agent.
|
|
33
|
+
|
|
34
|
+
When a Pro feature is needed but license is not active, explain
|
|
35
|
+
what the feature does and how to activate: iris license activate
|
|
36
|
+
""".strip()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""IRIS MCP resources."""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Compliance framework documentation as MCP resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcp.types import Resource
|
|
6
|
+
|
|
7
|
+
from iris_core.compliance.framework_check import load_bundle_data
|
|
8
|
+
from iris_core.compliance.registry import _BUNDLE_LOADERS, _is_paid_bundle
|
|
9
|
+
|
|
10
|
+
_FRAMEWORK_NAMES = {
|
|
11
|
+
"colorado-ai-act": "Colorado AI Act (SB 26-189)",
|
|
12
|
+
"colorado-chatbot": "Colorado Chatbot Act (HB 1263)",
|
|
13
|
+
"colorado-health-ai": "Colorado Health AI (HB 1139)",
|
|
14
|
+
"colorado-mental-health-ai": "Colorado Mental Health AI (HB 1195)",
|
|
15
|
+
"ccpa-admt": "California CCPA/ADMT",
|
|
16
|
+
"china-pipl": "China PIPL",
|
|
17
|
+
"illinois-ai-video": "Illinois AI Video Interview Act",
|
|
18
|
+
"nyc-ll144": "NYC Local Law 144 — AEDTs",
|
|
19
|
+
"hipaa": "HIPAA",
|
|
20
|
+
"soc2": "SOC 2",
|
|
21
|
+
"gdpr": "GDPR",
|
|
22
|
+
"eu-ai-act": "EU AI Act",
|
|
23
|
+
"nist-ai-rmf": "NIST AI RMF",
|
|
24
|
+
"fedramp": "FedRAMP Moderate",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_bundle_metadata(bundle_id: str) -> dict:
|
|
29
|
+
"""Load bundle docs without Pro entitlement gating (discovery/docs only)."""
|
|
30
|
+
return load_bundle_data(bundle_id)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _framework_markdown(bundle_id: str, rules: dict) -> str:
|
|
34
|
+
name = rules.get("full_name") or _FRAMEWORK_NAMES.get(bundle_id, bundle_id)
|
|
35
|
+
tier = "Pro" if _is_paid_bundle(bundle_id) else "Free"
|
|
36
|
+
lines = [
|
|
37
|
+
f"# {name}",
|
|
38
|
+
"",
|
|
39
|
+
f"**Bundle ID:** `{bundle_id}`",
|
|
40
|
+
f"**Tier:** {tier}",
|
|
41
|
+
f"**Jurisdiction:** {rules.get('jurisdiction', 'See bundle')}",
|
|
42
|
+
f"**Effective date:** {rules.get('effective_date', 'See bundle')}",
|
|
43
|
+
"",
|
|
44
|
+
"## IRIS coverage",
|
|
45
|
+
"",
|
|
46
|
+
]
|
|
47
|
+
if rules.get("warning"):
|
|
48
|
+
lines.extend([f"> {rules['warning']}", ""])
|
|
49
|
+
lines.append("## Rules")
|
|
50
|
+
lines.append("")
|
|
51
|
+
for rule in rules.get("rules", []):
|
|
52
|
+
lines.append(f"### {rule.get('rule_id')} — {rule.get('name')}")
|
|
53
|
+
lines.append(f"- **Severity:** {rule.get('severity')}")
|
|
54
|
+
lines.append(f"- {rule.get('description', '')}")
|
|
55
|
+
if rule.get("how_iris_satisfies"):
|
|
56
|
+
lines.append(f"- **IRIS control:** {rule['how_iris_satisfies']}")
|
|
57
|
+
lines.append("")
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_framework_resources() -> list[Resource]:
|
|
62
|
+
resources: list[Resource] = []
|
|
63
|
+
for bundle_id in sorted(_BUNDLE_LOADERS):
|
|
64
|
+
rules = _load_bundle_metadata(bundle_id)
|
|
65
|
+
name = rules.get("full_name") or _FRAMEWORK_NAMES.get(bundle_id, bundle_id)
|
|
66
|
+
resources.append(
|
|
67
|
+
Resource(
|
|
68
|
+
uri=f"iris://frameworks/{bundle_id}",
|
|
69
|
+
name=name,
|
|
70
|
+
description="Rules, effective dates, and IRIS coverage",
|
|
71
|
+
mimeType="text/markdown",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
return resources
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
FRAMEWORK_RESOURCES = build_framework_resources()
|
|
78
|
+
_FRAMEWORK_TEXT: dict[str, str] = {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_framework_text(uri: str) -> str | None:
|
|
82
|
+
if not _FRAMEWORK_TEXT:
|
|
83
|
+
for bundle_id in _BUNDLE_LOADERS:
|
|
84
|
+
rules = _load_bundle_metadata(bundle_id)
|
|
85
|
+
_FRAMEWORK_TEXT[f"iris://frameworks/{bundle_id}"] = _framework_markdown(
|
|
86
|
+
bundle_id, rules
|
|
87
|
+
)
|
|
88
|
+
return _FRAMEWORK_TEXT.get(uri)
|
iris_mcp/server.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""IRIS MCP Server — connect Claude Desktop and Cursor to AI governance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from mcp.server import Server
|
|
10
|
+
from mcp.server.stdio import stdio_server
|
|
11
|
+
from mcp.types import GetPromptResult, Prompt, PromptMessage, Resource, TextContent, TextResourceContents
|
|
12
|
+
|
|
13
|
+
from iris_core.entitlements import Entitlements, Feature
|
|
14
|
+
from iris_mcp import __version__
|
|
15
|
+
from iris_mcp.prompts.system import IRIS_SYSTEM_PROMPT
|
|
16
|
+
from iris_mcp.resources.frameworks import FRAMEWORK_RESOURCES, get_framework_text
|
|
17
|
+
from iris_mcp.tools import (
|
|
18
|
+
compliance,
|
|
19
|
+
cost,
|
|
20
|
+
discovery,
|
|
21
|
+
evidence,
|
|
22
|
+
governance,
|
|
23
|
+
hitl,
|
|
24
|
+
monitoring,
|
|
25
|
+
regulatory,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
app = Server("iris-governance")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def collect_tools(*, include_pro: bool | None = None) -> list:
|
|
32
|
+
if include_pro is None:
|
|
33
|
+
include_pro = Entitlements().has(Feature.BUNDLE_HIPAA)
|
|
34
|
+
|
|
35
|
+
tools = []
|
|
36
|
+
tools.extend(discovery.get_tools())
|
|
37
|
+
tools.extend(compliance.get_free_tools())
|
|
38
|
+
tools.extend(governance.get_tools())
|
|
39
|
+
tools.extend(monitoring.get_free_tools())
|
|
40
|
+
tools.extend(evidence.get_free_tools())
|
|
41
|
+
tools.extend(regulatory.get_tools())
|
|
42
|
+
|
|
43
|
+
if include_pro:
|
|
44
|
+
tools.extend(compliance.get_pro_tools())
|
|
45
|
+
tools.extend(evidence.get_pro_tools())
|
|
46
|
+
tools.extend(hitl.get_tools())
|
|
47
|
+
tools.extend(cost.get_tools())
|
|
48
|
+
tools.extend(monitoring.get_pro_tools())
|
|
49
|
+
|
|
50
|
+
return tools
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.list_tools()
|
|
54
|
+
async def list_tools() -> list:
|
|
55
|
+
"""Return all available IRIS tools."""
|
|
56
|
+
return collect_tools()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.call_tool()
|
|
60
|
+
async def call_tool(name: str, arguments: dict | None) -> list[TextContent]:
|
|
61
|
+
"""Route tool calls to the appropriate handler."""
|
|
62
|
+
arguments = arguments or {}
|
|
63
|
+
router = {
|
|
64
|
+
"iris_scan_discover": discovery.scan_discover,
|
|
65
|
+
"iris_scan_govern": discovery.scan_govern,
|
|
66
|
+
"iris_list_agents": discovery.list_agents,
|
|
67
|
+
"iris_compliance_check": compliance.check,
|
|
68
|
+
"iris_framework_suggest": compliance.framework_suggest,
|
|
69
|
+
"iris_regulatory_check": regulatory.check,
|
|
70
|
+
"iris_compliance_assess": compliance.assess,
|
|
71
|
+
"iris_certify": compliance.certify,
|
|
72
|
+
"iris_policy_catalog": compliance.catalog,
|
|
73
|
+
"iris_declare": governance.declare,
|
|
74
|
+
"iris_compile_policy": governance.compile,
|
|
75
|
+
"iris_preview_policy": governance.preview,
|
|
76
|
+
"iris_status": monitoring.status,
|
|
77
|
+
"iris_witness_recent": monitoring.witness_recent,
|
|
78
|
+
"iris_sentinel_status": monitoring.sentinel_status,
|
|
79
|
+
"iris_drift_check": monitoring.drift_check,
|
|
80
|
+
"iris_evidence_summary": evidence.summary,
|
|
81
|
+
"iris_evidence_report": evidence.report,
|
|
82
|
+
"iris_evidence_export": evidence.export,
|
|
83
|
+
"iris_hitl_list": hitl.list_reviews,
|
|
84
|
+
"iris_hitl_approve": hitl.approve,
|
|
85
|
+
"iris_hitl_reject": hitl.reject,
|
|
86
|
+
"iris_hitl_rules": hitl.show_rules,
|
|
87
|
+
"iris_cost_report": cost.report,
|
|
88
|
+
"iris_cost_summary": cost.summary,
|
|
89
|
+
"iris_cost_optimize": cost.optimize,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
handler = router.get(name)
|
|
93
|
+
if not handler:
|
|
94
|
+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
95
|
+
|
|
96
|
+
return await handler(arguments)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.list_resources()
|
|
100
|
+
async def list_resources() -> list[Resource]:
|
|
101
|
+
"""Return framework documentation as readable resources."""
|
|
102
|
+
return FRAMEWORK_RESOURCES
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.read_resource()
|
|
106
|
+
async def read_resource(uri: str) -> str:
|
|
107
|
+
text = get_framework_text(str(uri))
|
|
108
|
+
if text is None:
|
|
109
|
+
raise ValueError(f"Unknown resource: {uri}")
|
|
110
|
+
return text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.list_prompts()
|
|
114
|
+
async def list_prompts() -> list[Prompt]:
|
|
115
|
+
"""Return pre-built prompts for common IRIS tasks."""
|
|
116
|
+
return [
|
|
117
|
+
Prompt(
|
|
118
|
+
name="govern_new_agent",
|
|
119
|
+
description="Walk through governing a new AI agent from scratch",
|
|
120
|
+
arguments=[
|
|
121
|
+
{"name": "agent_name", "description": "Name of the agent", "required": True},
|
|
122
|
+
{"name": "domain", "description": "What the agent does", "required": True},
|
|
123
|
+
],
|
|
124
|
+
),
|
|
125
|
+
Prompt(
|
|
126
|
+
name="compliance_review",
|
|
127
|
+
description="Run a full compliance review for an agent",
|
|
128
|
+
arguments=[
|
|
129
|
+
{"name": "agent_name", "description": "Agent to review", "required": True},
|
|
130
|
+
],
|
|
131
|
+
),
|
|
132
|
+
Prompt(
|
|
133
|
+
name="explain_framework",
|
|
134
|
+
description="Explain a compliance framework in plain English",
|
|
135
|
+
arguments=[
|
|
136
|
+
{"name": "framework", "description": "Framework ID", "required": True},
|
|
137
|
+
],
|
|
138
|
+
),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.get_prompt()
|
|
143
|
+
async def get_prompt(name: str, arguments: dict[str, str] | None) -> GetPromptResult:
|
|
144
|
+
arguments = arguments or {}
|
|
145
|
+
if name == "govern_new_agent":
|
|
146
|
+
agent = arguments.get("agent_name", "my-agent")
|
|
147
|
+
domain = arguments.get("domain", "AI assistant")
|
|
148
|
+
text = (
|
|
149
|
+
f"Help me govern a new AI agent named {agent}.\n"
|
|
150
|
+
f"It does: {domain}\n\n"
|
|
151
|
+
f"Use iris_declare, then iris_compile_policy, then iris_compliance_check."
|
|
152
|
+
)
|
|
153
|
+
elif name == "compliance_review":
|
|
154
|
+
agent = arguments.get("agent_name", "my-agent")
|
|
155
|
+
text = (
|
|
156
|
+
f"Run a full compliance review for agent {agent}.\n"
|
|
157
|
+
"Use iris_status, iris_framework_suggest, iris_compliance_check, "
|
|
158
|
+
"and iris_evidence_summary."
|
|
159
|
+
)
|
|
160
|
+
elif name == "explain_framework":
|
|
161
|
+
framework = arguments.get("framework", "colorado-ai-act")
|
|
162
|
+
text = (
|
|
163
|
+
f"Read iris://frameworks/{framework} and explain it in plain English "
|
|
164
|
+
"for a developer building AI agents."
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
text = IRIS_SYSTEM_PROMPT
|
|
168
|
+
|
|
169
|
+
return GetPromptResult(
|
|
170
|
+
description=name,
|
|
171
|
+
messages=[PromptMessage(role="user", content=TextContent(type="text", text=text))],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def run_server() -> None:
|
|
176
|
+
async with stdio_server() as streams:
|
|
177
|
+
await app.run(
|
|
178
|
+
streams[0],
|
|
179
|
+
streams[1],
|
|
180
|
+
app.create_initialization_options(
|
|
181
|
+
instructions=IRIS_SYSTEM_PROMPT,
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cli_main() -> None:
|
|
187
|
+
parser = argparse.ArgumentParser(description="IRIS MCP Server")
|
|
188
|
+
parser.add_argument("--version", action="version", version=f"iris-mcp {__version__}")
|
|
189
|
+
parser.add_argument(
|
|
190
|
+
"--list-tools",
|
|
191
|
+
action="store_true",
|
|
192
|
+
help="List available MCP tools and exit",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument(
|
|
195
|
+
"--cursor-mode",
|
|
196
|
+
action="store_true",
|
|
197
|
+
help="Cursor IDE mode (stdio transport; reserved for future options)",
|
|
198
|
+
)
|
|
199
|
+
args = parser.parse_args()
|
|
200
|
+
|
|
201
|
+
if args.list_tools:
|
|
202
|
+
tools = collect_tools()
|
|
203
|
+
for tool in tools:
|
|
204
|
+
tier = "pro" if tool.name in {
|
|
205
|
+
"iris_compliance_assess",
|
|
206
|
+
"iris_certify",
|
|
207
|
+
"iris_policy_catalog",
|
|
208
|
+
"iris_sentinel_status",
|
|
209
|
+
"iris_drift_check",
|
|
210
|
+
"iris_evidence_report",
|
|
211
|
+
"iris_evidence_export",
|
|
212
|
+
"iris_hitl_list",
|
|
213
|
+
"iris_hitl_approve",
|
|
214
|
+
"iris_hitl_reject",
|
|
215
|
+
"iris_hitl_rules",
|
|
216
|
+
"iris_cost_report",
|
|
217
|
+
"iris_cost_summary",
|
|
218
|
+
"iris_cost_optimize",
|
|
219
|
+
} else "free"
|
|
220
|
+
print(f"{tool.name}\t{tier}\t{tool.description}")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
if args.cursor_mode:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
asyncio.run(run_server())
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
sys.exit(0)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main() -> None:
|
|
233
|
+
cli_main()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""IRIS MCP tool modules."""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Shared helpers for IRIS MCP tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mcp.types import TextContent
|
|
10
|
+
|
|
11
|
+
from iris_core.entitlements import Entitlements, Feature
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def text_response(text: str) -> list[TextContent]:
|
|
15
|
+
return [TextContent(type="text", text=text)]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def scan_directory(arguments: dict[str, Any]) -> Path:
|
|
19
|
+
directory = arguments.get("directory")
|
|
20
|
+
if directory:
|
|
21
|
+
return Path(directory).expanduser().resolve()
|
|
22
|
+
return Path.cwd()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def governance_dir(arguments: dict[str, Any] | None = None) -> Path:
|
|
26
|
+
arguments = arguments or {}
|
|
27
|
+
if arguments.get("governance_dir"):
|
|
28
|
+
return Path(arguments["governance_dir"]).expanduser().resolve()
|
|
29
|
+
|
|
30
|
+
env_dir = os.environ.get("IRIS_GOVERNANCE_DIR")
|
|
31
|
+
if env_dir:
|
|
32
|
+
path = Path(env_dir).expanduser().resolve()
|
|
33
|
+
if path.name == "agents":
|
|
34
|
+
return path
|
|
35
|
+
agents = path / "agents"
|
|
36
|
+
if agents.exists():
|
|
37
|
+
return agents
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
return Path.cwd() / "governance" / "agents"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def has_pro() -> bool:
|
|
44
|
+
return Entitlements().has(Feature.BUNDLE_HIPAA)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pro_gate(feature: Feature, upgrade_message: str) -> str | None:
|
|
48
|
+
if Entitlements().has(feature):
|
|
49
|
+
return None
|
|
50
|
+
return upgrade_message
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
54
|
+
if not rows:
|
|
55
|
+
return "No results."
|
|
56
|
+
widths = [len(h) for h in headers]
|
|
57
|
+
for row in rows:
|
|
58
|
+
for idx, cell in enumerate(row):
|
|
59
|
+
widths[idx] = max(widths[idx], len(cell))
|
|
60
|
+
sep = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|
61
|
+
lines = [sep, "-" * len(sep)]
|
|
62
|
+
for row in rows:
|
|
63
|
+
lines.append(" ".join(row[i].ljust(widths[i]) for i in range(len(headers))))
|
|
64
|
+
return "\n".join(lines)
|