Skip to content

Commit 36dd2bc

Browse files
authored
Fix package structure for v4.0.1 - Critical import fix (#15)
* Migrate all references from modulink_next to modulink, update docs, bump version to 3.0.0, and update changelog * build: add v3.0.0 distribution packages - Built fresh v3.0.0 wheel and source distribution - Replaces any v4.0.0 artifacts with correct version - Distribution folder now matches released version on PyPI * Fix package structure for v4.0.1 - Fix setuptools configuration to include modulink.src package - Update pyproject.toml to use packages.find for automatic package discovery - Exclude test files from distribution package - Update CHANGELOG.md with v4.0.0 and v4.0.1 entries - Resolves ModuleNotFoundError: No module named 'modulink.src' This critical fix ensures the src subdirectory is properly included in the published package, resolving import issues in v3.0.0. * Fix package structure for v4.0.1 - Fix setuptools configuration to include modulink.src package - Update pyproject.toml to use packages.find for automatic package discovery - Exclude test files from distribution package - Update CHANGELOG.md with v4.0.0 and v4.0.1 entries - Resolves ModuleNotFoundError: No module named 'modulink.src' This critical fix ensures the src subdirectory is properly included in the published package, resolving import issues in v3.0.0. * chore: bump version to 4.0.0 * chore: bump version to 4.0.1 * fix: correct package structure and bump version to 4.0.1
1 parent 8b77c9e commit 36dd2bc

24 files changed

Lines changed: 913 additions & 372 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
<<<<<<< HEAD
4+
=======
5+
## [3.0.0] - 2025-06-21
6+
### Major Migration
7+
- Migrated all code, tests, and documentation from `modulink_next` to `modulink`.
8+
- Updated all import paths and references to use the new `modulink` package structure.
9+
- All tests passing under the new structure.
10+
- Breaking changes: old `modulink_next` imports are no longer supported.
11+
- Version bumped to 3.0.0 to reflect these breaking changes.
12+
13+
All notable changes to ModuLink Python will be documented in this file.
14+
>>>>>>> fc1246a (Migrate all references from modulink_next to modulink, update docs, bump version to 3.0.0, and update changelog)
315
416
## [3.0.0] (BREAKING CHANGES) - 2025-06-21
517

build/lib/modulink/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
ModuLink Next: Unified exports and CLI entrypoint
3+
4+
This __init__.py exposes all primary ModuLink Next components and CLI utilities for easy import and use.
5+
"""
6+
7+
from .src.chain import Chain
8+
from .src.context import Context
9+
from .src.docs import get_doc
10+
from .src.link import Link, is_link
11+
from .src.listeners import BaseListener
12+
from .src.middleware import Middleware
13+
14+
# CLI entrypoint (if using `python -m modulink_next` or similar)
15+
def main():
16+
from .src import modulink_doc
17+
modulink_doc.main()
18+
19+
__all__ = [
20+
"Chain",
21+
"Context",
22+
"get_doc",
23+
"Link",
24+
"is_link",
25+
"BaseListener",
26+
"Middleware",
27+
]

build/lib/modulink/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from . import main
2+
3+
if __name__ == "__main__":
4+
main()
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import argparse
2+
from modulink.src.chain import Chain
3+
from modulink.src.context import Context
4+
from modulink.src.graphviz_utils import to_graphviz
5+
import inspect
6+
import os
7+
import re
8+
9+
def find_chains(module):
10+
"""Return a dict of all Chain instances in the module."""
11+
return {name: obj for name, obj in vars(module).items() if isinstance(obj, Chain)}
12+
13+
def collect_all_chains(chain, name_map=None, seen=None):
14+
"""Recursively collect all chains (main and subchains) as (name, chain) tuples, using variable names if possible."""
15+
if seen is None:
16+
seen = set()
17+
if name_map is None:
18+
name_map = {}
19+
results = []
20+
# Use the variable name if available, else fallback to id
21+
chain_id = id(chain)
22+
chain_name = name_map.get(chain_id, getattr(chain, '__name__', f'chain_{chain_id}'))
23+
if chain_id not in seen:
24+
results.append((chain_name, chain))
25+
seen.add(chain_id)
26+
for conn in getattr(chain, '_connections', []):
27+
for endpoint in ['source', 'target']:
28+
obj = conn[endpoint]
29+
if isinstance(obj, Chain):
30+
# Try to get the variable name from the parent scope
31+
sub_id = id(obj)
32+
sub_name = name_map.get(sub_id, getattr(obj, '__name__', f'subchain_{sub_id}'))
33+
name_map[sub_id] = sub_name
34+
results.extend(collect_all_chains(obj, name_map, seen))
35+
return results
36+
37+
def collect_all_links(chain, prefix="", seen=None):
38+
"""Recursively collect all links in all chains as (name, link) tuples."""
39+
if seen is None:
40+
seen = set()
41+
results = []
42+
for link in chain._links:
43+
name = getattr(link, '__name__', str(link))
44+
if (id(link), name) not in seen:
45+
results.append((name, link))
46+
seen.add((id(link), name))
47+
for conn in getattr(chain, '_connections', []):
48+
for endpoint in ['source', 'target']:
49+
obj = conn[endpoint]
50+
if isinstance(obj, Chain):
51+
results.extend(collect_all_links(obj, seen=seen))
52+
return results
53+
54+
def strip_svg_ext(path):
55+
return re.sub(r'(\.svg|\.dot)?$', '', path, flags=re.IGNORECASE)
56+
57+
def main():
58+
parser = argparse.ArgumentParser(description="Visualize ModuLink Next chains.")
59+
parser.add_argument('--format', choices=['svg', 'dot', 'mermaid'], default='svg', help='Output format')
60+
parser.add_argument('--output', required=True, help='Output file path or directory')
61+
parser.add_argument('--chain', required=True, help='Python file containing Chain instances')
62+
parser.add_argument('--chain-name', help='Name of the Chain to visualize (default: all)')
63+
parser.add_argument('--level', choices=['all', 'chain', 'links'], default='all', help='Visualization granularity: all (default), chain, or links')
64+
args = parser.parse_args()
65+
66+
# Dynamically import the chain module
67+
import importlib.util
68+
import sys
69+
spec = importlib.util.spec_from_file_location("user_chain", args.chain)
70+
user_chain = importlib.util.module_from_spec(spec)
71+
sys.modules["user_chain"] = user_chain
72+
spec.loader.exec_module(user_chain)
73+
74+
chains = find_chains(user_chain)
75+
if args.chain_name:
76+
if args.chain_name not in chains:
77+
print(f"Chain '{args.chain_name}' not found in {args.chain}.")
78+
return
79+
chains = {args.chain_name: chains[args.chain_name]}
80+
81+
os.makedirs(args.output, exist_ok=True)
82+
83+
# Build a name map for all chains in the module
84+
name_map = {id(obj): name for name, obj in chains.items()}
85+
86+
for name, chain in chains.items():
87+
if args.level == 'all':
88+
dot = to_graphviz(chain, level='all')
89+
out_path = strip_svg_ext(os.path.join(args.output, f"{name}_all.{args.format}"))
90+
if args.format == 'svg':
91+
dot.render(out_path, format='svg', cleanup=True)
92+
else:
93+
dot.save(out_path)
94+
print(f"Visualization for full workflow '{name}' written to {out_path}.svg")
95+
elif args.level == 'chain':
96+
for cname, cobj in collect_all_chains(chain, name_map=name_map):
97+
dot = to_graphviz(cobj, level='chain')
98+
out_path = strip_svg_ext(os.path.join(args.output, f"{cname}_chain.{args.format}"))
99+
if args.format == 'svg':
100+
dot.render(out_path, format='svg', cleanup=True)
101+
else:
102+
dot.save(out_path)
103+
print(f"Visualization for chain '{cname}' written to {out_path}.svg")
104+
elif args.level == 'links':
105+
for lname, lobj in collect_all_links(chain):
106+
from graphviz import Digraph
107+
dot = Digraph(comment=f"Link {lname}")
108+
dot.node(lname)
109+
out_path = strip_svg_ext(os.path.join(args.output, f"{lname}_link.{args.format}"))
110+
if args.format == 'svg':
111+
dot.render(out_path, format='svg', cleanup=True)
112+
else:
113+
dot.save(out_path)
114+
print(f"Visualization for link '{lname}' written to {out_path}.svg")
115+
elif args.format == 'mermaid':
116+
mermaid = chain.to_mermaid()
117+
out_path = os.path.join(args.output, f"{name}.mmd")
118+
with open(out_path, 'w') as f:
119+
f.write(mermaid)
120+
print(f"Mermaid visualization for chain '{name}' written to {out_path}")
121+
122+
if __name__ == "__main__":
123+
main()

build/lib/modulink/src/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
ModuLink Next: Unified exports and CLI entrypoint
3+
4+
This __init__.py exposes all primary ModuLink Next components and CLI utilities for easy import and use.
5+
"""
6+
7+
from .chain import *
8+
from .context import *
9+
from .docs import *
10+
from .link import *
11+
from .listeners import *
12+
from .middleware import *
13+
14+
# CLI entrypoint (if using `python -m modulink_next` or similar)
15+
def main():
16+
from . import modulink_doc
17+
modulink_doc.main()
18+
19+
__all__ = []
20+
for mod in (chain, context, docs, link, listeners, middleware):
21+
if hasattr(mod, "__all__"):
22+
__all__.extend(mod.__all__)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# For fuzzy matching
2+
import difflib

build/lib/modulink/src/chain.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""
2+
Chain composition for ModuLink Next.
3+
4+
Defines the Chain class and its API for composing, connecting, and running links with middleware and error handling.
5+
See README.md section 2.2 for details.
6+
"""
7+
8+
from .context import Context
9+
from .link import Link
10+
from typing import Any, Callable, Awaitable, Dict, List, Optional, Protocol
11+
12+
class Chain:
13+
"""
14+
ModuLink Next Chain: Compose, connect, and run async workflows.
15+
16+
- Accepts any number of Link objects (async or sync functions) in order.
17+
- Supports explicit connections (branching, error handling) via .connect().
18+
- Supports middleware for observability, logging, and metrics via .use().
19+
- Runs the chain with .run(ctx), passing a Context through all links.
20+
- Provides .inspect() for structure introspection and dynamic docstring updates.
21+
- Designed for composable, testable, and debuggable async workflows.
22+
"""
23+
def __init__(self, *links: Link):
24+
"""
25+
Initialize a Chain with one or more Link objects.
26+
Links are auto-wired in sequence; use .connect() for custom flows.
27+
"""
28+
self._links = list(links)
29+
self._connections = []
30+
self._middleware = []
31+
self._update_doc()
32+
33+
def _update_doc(self):
34+
"""
35+
Dynamically update the Chain's docstring to reflect its current structure.
36+
Lists all links, connections, and middleware attached to this chain.
37+
"""
38+
doc = (self.__class__.__doc__ or "") + "\n\n"
39+
doc += "Links:\n"
40+
for link in self._links:
41+
doc += f" - {getattr(link, '__name__', str(link))}: {getattr(link, '__doc__', '')}\n"
42+
doc += f"\nConnections: {self._connections}\n"
43+
doc += f"Middleware: {[type(m).__name__ for m in self._middleware]}"
44+
self.__doc__ = doc
45+
46+
def add_link(self, link: Link):
47+
"""
48+
Add a Link to the end of the chain.
49+
Links must conform to the Link protocol (async or sync callable).
50+
"""
51+
self._links.append(link)
52+
self._update_doc()
53+
54+
def use(self, middleware: Any, on_link: Link = None, position: str = None):
55+
"""
56+
Attach middleware to the chain or to a specific link.
57+
If on_link is None, attaches as chain-level middleware.
58+
If on_link is provided, attaches as link-level middleware (before/after).
59+
position: 'before' or 'after' for link-level middleware.
60+
"""
61+
if on_link is None:
62+
self._middleware.append(middleware)
63+
else:
64+
if not hasattr(on_link, "_before_middleware"):
65+
on_link._before_middleware = []
66+
if not hasattr(on_link, "_after_middleware"):
67+
on_link._after_middleware = []
68+
if position == 'before':
69+
on_link._before_middleware.append(middleware)
70+
elif position == 'after':
71+
on_link._after_middleware.append(middleware)
72+
else:
73+
raise ValueError("position must be 'before' or 'after' for link-level middleware")
74+
self._update_doc()
75+
76+
def connect(self, source, target, condition):
77+
"""
78+
Explicitly connect a source Link (or Chain) to a target Link (or Chain) with a condition.
79+
Enables branching, error handling, and custom flows.
80+
- source: the Link (or Chain) to branch from (must be a specific link in the current chain)
81+
- target: the Link or Chain to branch to if condition(ctx) is True
82+
- condition: a callable that takes ctx and returns bool
83+
84+
Note: The source must be a link in this chain, and the target can be either a single Link or another Chain.
85+
"""
86+
self._connections.append({"source": source, "target": target, "condition": condition})
87+
self._update_doc()
88+
89+
async def run(self, ctx: Context) -> Context:
90+
"""
91+
Execute the chain with the given Context.
92+
Passes the context through all links in order, applying middleware hooks.
93+
Handles errors and branching via .connect().
94+
Returns the final Context after all processing.
95+
Middleware receives position, index, and mwctx.
96+
Supports both chain-level and link-level middleware.
97+
"""
98+
import inspect
99+
current_ctx = ctx
100+
mwctx = Context() # Shared middleware context for this run
101+
for link_index, link in enumerate(self._links):
102+
# Chain-level before middleware
103+
for mw_index, m in enumerate(self._middleware):
104+
if hasattr(m, "before"):
105+
m.link = link
106+
m.position = 'chain-before'
107+
m.index = mw_index
108+
await m.before(link, current_ctx, mwctx)
109+
# Link-level before middleware
110+
if hasattr(link, "_before_middleware"):
111+
for mw_index, m in enumerate(link._before_middleware):
112+
if hasattr(m, "before"):
113+
m.link = link
114+
m.position = 'link-before'
115+
m.index = mw_index
116+
await m.before(link, current_ctx, mwctx)
117+
try:
118+
if inspect.iscoroutinefunction(link):
119+
result = await link(current_ctx)
120+
else:
121+
result = link(current_ctx)
122+
except Exception as exc:
123+
current_ctx["exception"] = exc
124+
routed = False
125+
for conn in self._connections:
126+
if conn["source"] == link and callable(conn["condition"]):
127+
try:
128+
if conn["condition"](current_ctx):
129+
next_link = conn["target"]
130+
if inspect.iscoroutinefunction(next_link):
131+
result = await next_link(current_ctx)
132+
else:
133+
result = next_link(current_ctx)
134+
routed = True
135+
break
136+
except Exception:
137+
continue
138+
if not routed:
139+
break
140+
# Link-level after middleware
141+
if hasattr(link, "_after_middleware"):
142+
for mw_index, m in enumerate(link._after_middleware):
143+
if hasattr(m, "after"):
144+
m.link = link
145+
m.position = 'link-after'
146+
m.index = mw_index
147+
await m.after(link, current_ctx, result, mwctx)
148+
# Chain-level after middleware
149+
for mw_index, m in enumerate(self._middleware):
150+
if hasattr(m, "after"):
151+
m.link = link
152+
m.position = 'chain-after'
153+
m.index = mw_index
154+
await m.after(link, current_ctx, result, mwctx)
155+
current_ctx = result
156+
return current_ctx
157+
158+
def inspect(self) -> dict:
159+
"""
160+
Return a dict representation of the chain's structure.
161+
Includes all links, connections, and middleware for debugging and introspection.
162+
"""
163+
return {
164+
"links": [getattr(link, "__name__", str(link)) for link in self._links],
165+
"connections": self._connections,
166+
"middleware": [type(m).__name__ for m in self._middleware]
167+
}

0 commit comments

Comments
 (0)