Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions concore_cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import click
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import print as rprint
import sys
import os
from pathlib import Path

from .commands.init import init_project
from .commands.run import run_workflow
from .commands.validate import validate_workflow
from .commands.status import show_status
from .commands.stop import stop_all
from .commands.inspect import inspect_workflow

console = Console()

Expand Down Expand Up @@ -55,6 +51,18 @@ def validate(workflow_file):
console.print(f"[red]Error:[/red] {str(e)}")
sys.exit(1)

@cli.command()
@click.argument('workflow_file', type=click.Path(exists=True))
@click.option('--source', '-s', default='src', help='Source directory')
@click.option('--json', 'output_json', is_flag=True, help='Output in JSON format')
def inspect(workflow_file, source, output_json):
"""Inspect a workflow file and show its structure"""
try:
inspect_workflow(workflow_file, source, output_json, console)
except Exception as e:
console.print(f"[red]Error:[/red] {str(e)}")
sys.exit(1)

@cli.command()
def status():
"""Show running concore processes"""
Expand Down
263 changes: 263 additions & 0 deletions concore_cli/commands/inspect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
from pathlib import Path
from bs4 import BeautifulSoup
from rich.table import Table
from rich.tree import Tree
from rich.panel import Panel
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panel is imported but never used in this module. Remove the unused import to avoid lint noise.

Suggested change
from rich.panel import Panel

Copilot uses AI. Check for mistakes.
from collections import defaultdict
import re

def inspect_workflow(workflow_file, source_dir, output_json, console):
workflow_path = Path(workflow_file)

if output_json:
return _inspect_json(workflow_path, source_dir)

_inspect_rich(workflow_path, source_dir, console)

def _inspect_rich(workflow_path, source_dir, console):
console.print()
console.print(f"[bold cyan]Workflow:[/bold cyan] {workflow_path.name}")
console.print()

try:
with open(workflow_path, 'r') as f:
content = f.read()

soup = BeautifulSoup(content, 'xml')

if not soup.find('graphml'):
console.print("[red]Not a valid GraphML file[/red]")
return

nodes = soup.find_all('node')
edges = soup.find_all('edge')

tree = Tree("📊 [bold]Workflow Overview[/bold]")

lang_counts = defaultdict(int)
node_files = []
missing_files = []
Comment on lines +37 to +39
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node_files is populated but never used afterwards. Consider removing it (or using it to de-duplicate/validate filenames) to keep the inspector maintainable.

Copilot uses AI. Check for mistakes.

for node in nodes:
label_tag = node.find('y:NodeLabel')
if label_tag and label_tag.text:
label = label_tag.text.strip()
if ':' in label:
_, filename = label.split(':', 1)
node_files.append(filename)

ext = Path(filename).suffix
if ext == '.py':
lang_counts['Python'] += 1
elif ext == '.m':
lang_counts['MATLAB'] += 1
elif ext == '.java':
lang_counts['Java'] += 1
elif ext == '.cpp' or ext == '.hpp':
lang_counts['C++'] += 1
elif ext == '.v':
lang_counts['Verilog'] += 1
else:
lang_counts['Other'] += 1

src_dir = workflow_path.parent / source_dir
if not (src_dir / filename).exists():
missing_files.append(filename)

nodes_branch = tree.add(f"Nodes: [bold]{len(nodes)}[/bold]")
if lang_counts:
for lang, count in sorted(lang_counts.items(), key=lambda x: -x[1]):
nodes_branch.add(f"{lang}: {count}")

edges_branch = tree.add(f"Edges: [bold]{len(edges)}[/bold]")

edge_label_regex = re.compile(r"0x([a-fA-F0-9]+)_(\S+)")
zmq_count = 0
file_count = 0

for edge in edges:
label_tag = edge.find('y:EdgeLabel')
label_text = label_tag.text.strip() if label_tag and label_tag.text else ""
if label_text and edge_label_regex.match(label_text):
zmq_count += 1
else:
file_count += 1

if zmq_count > 0:
edges_branch.add(f"ZMQ: {zmq_count}")
if file_count > 0:
edges_branch.add(f"File-based: {file_count}")

comm_type = "ZMQ (0mq)" if zmq_count > 0 else "File-based" if file_count > 0 else "None"
tree.add(f"Communication: [bold]{comm_type}[/bold]")
Comment on lines +91 to +92
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comm_type is derived from zmq_count/file_count, so when edges exist but labels are missing/empty it can incorrectly show "None". Consider treating missing/empty labels as file-based (or reporting an explicit "unknown" category) so this stays consistent with Edges: {len(edges)}.

Copilot uses AI. Check for mistakes.

if missing_files:
missing_branch = tree.add(f"[yellow]Missing files: {len(missing_files)}[/yellow]")
for f in missing_files[:5]:
missing_branch.add(f"[yellow]{f}[/yellow]")
if len(missing_files) > 5:
missing_branch.add(f"[dim]...and {len(missing_files) - 5} more[/dim]")

console.print(tree)
console.print()

if nodes:
table = Table(title="Node Details", show_header=True, header_style="bold magenta")
table.add_column("ID", style="cyan", width=12)
table.add_column("File", style="white")
table.add_column("Language", style="green")
table.add_column("Status", style="yellow")

for node in nodes[:10]:
label_tag = node.find('y:NodeLabel')
if label_tag and label_tag.text:
label = label_tag.text.strip()
if ':' in label:
node_id, filename = label.split(':', 1)

ext = Path(filename).suffix
lang_map = {
'.py': 'Python',
'.m': 'MATLAB',
'.java': 'Java',
'.cpp': 'C++',
'.hpp': 'C++',
'.v': 'Verilog'
}
lang = lang_map.get(ext, 'Other')

src_dir = workflow_path.parent / source_dir
status = "✓" if (src_dir / filename).exists() else "✗"

table.add_row(node_id, filename, lang, status)

if len(nodes) > 10:
table.caption = f"Showing 10 of {len(nodes)} nodes"

console.print(table)
console.print()

if edges:
edge_table = Table(title="Edge Connections", show_header=True, header_style="bold magenta")
edge_table.add_column("From", style="cyan", width=12)
edge_table.add_column("To", style="cyan", width=12)
edge_table.add_column("Type", style="green")

for edge in edges[:10]:
source = edge.get('source', 'unknown')
target = edge.get('target', 'unknown')

label_tag = edge.find('y:EdgeLabel')
edge_type = "File"
if label_tag and label_tag.text:
if edge_label_regex.match(label_tag.text.strip()):
edge_type = "ZMQ"

edge_table.add_row(source, target, edge_type)

if len(edges) > 10:
edge_table.caption = f"Showing 10 of {len(edges)} edges"

console.print(edge_table)
console.print()

except FileNotFoundError:
console.print(f"[red]File not found:[/red] {workflow_path}")
except Exception as e:
console.print(f"[red]Inspection failed:[/red] {str(e)}")

def _inspect_json(workflow_path, source_dir):
import json

try:
with open(workflow_path, 'r') as f:
content = f.read()

soup = BeautifulSoup(content, 'xml')

if not soup.find('graphml'):
print(json.dumps({'error': 'Not a valid GraphML file'}, indent=2))
return

nodes = soup.find_all('node')
edges = soup.find_all('edge')

lang_counts = defaultdict(int)
node_list = []
edge_list = []
missing_files = []

for node in nodes:
label_tag = node.find('y:NodeLabel')
if label_tag and label_tag.text:
label = label_tag.text.strip()
if ':' in label:
node_id, filename = label.split(':', 1)

ext = Path(filename).suffix
lang_map = {
'.py': 'python',
'.m': 'matlab',
'.java': 'java',
'.cpp': 'cpp',
'.hpp': 'cpp',
'.v': 'verilog'
}
lang = lang_map.get(ext, 'other')
lang_counts[lang] += 1

src_dir = workflow_path.parent / source_dir
exists = (src_dir / filename).exists()
if not exists:
missing_files.append(filename)

node_list.append({
'id': node_id,
'file': filename,
'language': lang,
'exists': exists
})

edge_label_regex = re.compile(r"0x([a-fA-F0-9]+)_(\S+)")
zmq_count = 0
file_count = 0

for edge in edges:
source = edge.get('source')
target = edge.get('target')

label_tag = edge.find('y:EdgeLabel')
label_text = label_tag.text.strip() if label_tag and label_tag.text else ""
edge_type = 'file'
if label_text and edge_label_regex.match(label_text):
edge_type = 'zmq'
zmq_count += 1
else:
file_count += 1

edge_list.append({
'source': source,
'target': target,
'type': edge_type
})

result = {
'workflow': str(workflow_path.name),
'nodes': {
'total': len(nodes),
'by_language': dict(lang_counts),
'list': node_list
},
'edges': {
'total': len(edges),
'zmq': zmq_count,
'file': file_count,
'list': edge_list
},
'missing_files': missing_files
}

print(json.dumps(result, indent=2))

except Exception as e:
print(json.dumps({'error': str(e)}, indent=2))
3 changes: 3 additions & 0 deletions concore_cli/commands/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ def show_status(console):
minutes, seconds = divmod(remainder, 60)
uptime_str = f"{hours}h {minutes}m {seconds}s"
except:
# Failed to calculate uptime
uptime_str = "unknown"

try:
mem_mb = proc.info['memory_info'].rss / 1024 / 1024
mem_str = f"{mem_mb:.1f} MB"
except:
# Failed to get memory info
mem_str = "unknown"

command = ' '.join(cmdline[:3]) if len(cmdline) >= 3 else cmdline_str[:50]
Expand All @@ -56,6 +58,7 @@ def show_status(console):
'memory': mem_str
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
# Process may have exited or be inaccessible; safe to ignore
continue

except Exception as e:
Expand Down
1 change: 1 addition & 0 deletions concore_cli/commands/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def stop_all(console):
if is_concore:
processes_to_kill.append(proc)
except (psutil.NoSuchProcess, psutil.AccessDenied):
# Process already exited or access denied; continue
continue

except Exception as e:
Expand Down
31 changes: 31 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "concore"
version = "1.0.0"
description = "Concore workflow management CLI"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
dependencies = [
"click>=8.0.0",
"rich>=10.0.0",
"beautifulsoup4>=4.9.0",
"lxml>=4.6.0",
"psutil>=5.8.0",
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The project exposes a run command that relies on mkconcore, which in turn requires runtime deps like numpy. Those dependencies aren’t listed here, so a fresh install of this CLI won’t have what it needs to execute concore run. Either add the missing runtime dependencies, or remove/disable the run command in this package.

Suggested change
"psutil>=5.8.0",
"psutil>=5.8.0",
"mkconcore",
"numpy",

Copilot uses AI. Check for mistakes.
]

[project.optional-dependencies]
dev = [
"pytest>=6.0.0",
"pytest-cov>=2.10.0",
]

[project.scripts]
concore = "concore_cli.cli:cli"

[tool.setuptools]
packages = ["concore_cli", "concore_cli.commands"]
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pyproject.toml lists concore_cli and concore_cli.commands as setuptools packages, but those directories don’t contain __init__.py files. With this setuptools configuration, builds commonly fail because the directories aren’t treated as packages. Add __init__.py files (recommended) or switch to proper namespace package configuration (find_namespace_packages / tool.setuptools.packages.find with namespaces = true).

Suggested change
packages = ["concore_cli", "concore_cli.commands"]
[tool.setuptools.packages.find]
namespaces = true
include = ["concore_cli*"]

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The console script points at concore_cli.cli, but this build config only declares concore_cli packages and doesn’t include the mkconcore.py module that concore run imports. Include mkconcore in the distribution (e.g., py-modules = ["mkconcore"] or move it under a package) or rework run to call an external executable.

Suggested change
packages = ["concore_cli", "concore_cli.commands"]
packages = ["concore_cli", "concore_cli.commands"]
py-modules = ["mkconcore"]

Copilot uses AI. Check for mistakes.
py-modules = ["mkconcore"]