from fastcore.test import test,test_eq,test_is,expect_failpyskills API
Overview
A pyskill is a Python module that gives an LLM instructions or functions to use. Packages register these modules through Python entry points. A host such as Solveit first shows the LLM a catalogue of short descriptions. It reads those descriptions from source without loading the skill. The LLM then chooses which modules to import and read in full.
Entry Point Convention
Register a module in the pyskills entry-point group:
[project.entry-points.pyskills]
my_skill = "mypackage.skill"Use the module path as the value. You don’t need a :attribute suffix.
Skill Module Contract
Give the module a docstring. Its first paragraph describes when to choose the skill. The rest tells the LLM how to use it. An optional __all__ controls which symbols the module exposes.
Discovery API
list_pyskills() returns a dictionary of module paths and descriptions. It locates each source file with find_spec_noimport and reads its docstring with Python’s AST parser.
After choosing a skill, import it and read its documentation:
import mypackage.skill
doc(mypackage.skill) # Overview of classes, functions, and submodules
doc(SomeClass) # Bases, constructor, methods, and properties
doc(some_func) # Full signature and parameter docments
xdir(mypackage.skill) # Public namesThese inspection functions work on any Python module, not just pyskills.
Host Integration
A host such as Solveit, Claude Code, or Codex can use this sequence:
- Call
list_pyskills()at startup. - Include the catalogue with each prompt.
- Import each selected module and read
doc(module).
Finding modules without importing them
find_spec_noimport
def find_spec_noimport(
name:str
):Find the spec for an absolute module name without importing its parents
importlib.util.find_spec('email.mime.text') imports the parent packages to find their search paths. For a catalogue, we want to locate a module before deciding whether to load it. find_spec_noimport follows package search paths without running their __init__.py files. It returns a ModuleSpec, or None if the module cannot be found.
Here we locate a standard-library module and check that lookup leaves the imported modules unchanged:
loaded = set(sys.modules)
spec = find_spec_noimport('email.mime.text')
test_eq(spec.name, 'email.mime.text')
test_eq(set(sys.modules), loaded)
Path(spec.origin).name'text.py'
Ordinary packages and namespace packages both work. Child namespace search locations are a snapshot of the paths found during lookup. A package that changes __path__ in its initialization code cannot supply those extra paths without running that code. Child modules supplied only by custom meta-path finders are outside this lookup’s scope.
Listing pyskills
ep = entry_points()
es = first(ep.select(group='pyskills', name='pyskills.skill'))
esEntryPoint(name='pyskills.skill', value='pyskills.skill', group='pyskills')
ep_desc
def ep_desc(
ep
):First paragraph of docstring for entry point ep, without importing it
ep_desc reads the module’s first docstring paragraph from its source. The description is available before the skill is imported.
description = ep_desc(es)
assert description and '\n\n' not in description
description'Pyskills are tool modules that Python packages register so you can find them without importing everything. `list_pyskills()` names the ones installed, with a one-line description each, and needs no imports. Load one with a normal import, then read its docs with `doc()`.'
list_pyskills
def list_pyskills():Returns {module: description} for all pyskills. To load a module, use import {module} then view `doc({module}). NB: pyskills are THE critical way to extend functionality. ALWAYS check for pyskills to help with tasks. If unsure whether a particular pyskill might help, load it and grabs its docs to see!
The catalogue maps importable module names to their descriptions. Use a name from this listing to choose which module to import.
skills = list_pyskills()
test_eq(skills['pyskills.skill'], description)
{'pyskills.skill': skills['pyskills.skill']}{'pyskills.skill': 'Pyskills are tool modules that Python packages register so you can find them without importing everything. `list_pyskills()` names the ones installed, with a one-line description each, and needs no imports. Load one with a normal import, then read its docs with `doc()`.'}
allow
Use allow to register trusted functions, class methods, or callable instances in __pytools__. A dictionary such as {cls: ['method']} selects methods by name. Use ... to select all public methods. A group object can return its operations from __allow__.
Registration is a permission request, not sandbox enforcement. allow emits a pyskills.allow audit event before making changes. A sandbox must reject that event if untrusted code tries to expand its permissions. Without an audit handler that rejects the event, registration proceeds.
allow
def allow(
*c, allow_policy:NoneType=None, # Callable that raises if call not allowed
):Add all items in c to __pytools__, optionally constrained by allow_policy
__pytools__ maps each module, class, or callable instance to a set of allowed names. A function belongs to its module. A method belongs to its class. A callable instance has an entry for '__call__' under the instance itself.
Set entries are names, ... for all public methods, or (name, policy) tuples. A policy checks the arguments of a permitted call. You can supply it through allow_policy or in a dictionary.
def _test_fn(): pass
_test_fn.__module__ = '__main__'
_test_fn.__name__ = 'my_test_func'
allow(_test_fn)
assert 'my_test_func' in __pytools__[sys.modules['__main__']]
allow({str: ['zfill']})
assert 'zfill' in __pytools__[str]
allow({list: ...})
assert ... in __pytools__[list]
__pytools__[sys.modules['__main__']].discard('my_test_func')A method passed directly is registered against its owning class.
allow(collections.Counter.most_common)
assert 'most_common' in __pytools__[collections.Counter]
__pytools__[collections.Counter].discard('most_common')import httpxA policy can restrict a registered function’s arguments. This policy accepts HTTPS URLs.
def chk_url(url, *args, **kwargs):
if not url.startswith('https://'): raise PermissionError()
allow({httpx.get: chk_url, httpx.post: chk_url})
assert ('get', chk_url) in __pytools__[httpx._api]
assert ('post', chk_url) in __pytools__[httpx._api]from fastcore.xtras import timed_cacheDecorated functions retain their original module registration.
@timed_cache(60)
def wrapped_tool(): return "ok"
allow(wrapped_tool)
assert 'wrapped_tool' in __pytools__[sys.modules['__main__']]Spec-driven clients often create callable operation objects at runtime. These objects don’t have a function’s useful __qualname__. allow registers each object itself and wraps its class’s __call__ for call tracking:
class _CallableTool:
async def __call__(self, x): return x
tool = _CallableTool()
allow(tool)
assert '__call__' in __pytools__[tool]
assert getattr(_CallableTool.__call__, '_fastaudit_orig', None) is not NoneA group can expose its operations through __allow__. Registration applies to those operations rather than the group itself.
class _ToolGroup:
def __init__(self, *ops): self.ops = list(ops)
def __allow__(self): return self.ops
tool2 = _CallableTool()
allow(_ToolGroup(tool2))
assert '__call__' in __pytools__[tool2]Safepyrun rejects pyskills.allow inside its sandbox audit context. Code in that context cannot register new permissions for itself. Outside that context, an audit hook can observe the event without rejecting it:
seen = []
sys.addaudithook(lambda ev,args: seen.append(args) if ev=='pyskills.allow' else None)
allow(_test_fn)
test_eq(seen[-1], ((_test_fn,),))
__pytools__[sys.modules['__main__']].discard('my_test_func')Allow policies
chk_dest expands ~ and resolves the path. It accepts an allowed destination or a path inside it. Otherwise it raises PermissionError. Pass None to allow every destination, or () to allow none. The destination policies below use this check.
chk_dest('/tmp/foo.txt', ['/tmp'])
chk_dest('~/tmp/foo.txt', [Path.home()/'tmp'])
with expect_fail(PermissionError): chk_dest('/etc/passwd', ['/tmp'])
chk_dest('/etc/passwd', None)
with expect_fail(PermissionError): chk_dest('/tmp/foo.txt', ())PosAllowPolicy
def PosAllowPolicy(
pos:int=0, kw:NoneType=None
):Check positional/keyword path arg is an allowed destination; non-path buffers/handles are skipped
PosAllowPolicy checks a path passed at a selected argument position or keyword. Paths outside ok_dests raise PermissionError.
import iopp = PosAllowPolicy(1, 'dst')
pp(None, ['src', '/tmp/ok'], {}, {'ok_dests': ['/tmp']})
with expect_fail(PermissionError): pp(None, ['src', '/root/bad'], {}, {'ok_dests': ['/tmp']})PosAllowPolicy decodes byte paths before checking them. It leaves buffers and open file handles alone. Check the destination when opening a handle, before passing it to another operation.
pp(None, ['src', io.BytesIO()], {}, {'ok_dests': ['/tmp']})
with open('/dev/null', 'wb') as handle: pp(None, ['src', handle], {}, {'ok_dests': ['/tmp']})
pp(None, ['src', b'/tmp/ok'], {}, {'ok_dests': ['/tmp']})
with expect_fail(PermissionError): pp(None, ['src', b'/root/bad'], {}, {'ok_dests': ['/tmp']})PathWritePolicy
def PathWritePolicy(
target_pos:NoneType=None, target_kw:NoneType=None
):Check resolved Path self, optionally also target args
PathWritePolicy checks the Path instance itself. It can also check a destination argument for operations such as rename.
pwp = PathWritePolicy()
pwp(Path('/tmp/f.txt'), [], {}, {'ok_dests': ['/tmp']})
with expect_fail(PermissionError): pwp(Path('/etc/f.txt'), [], {}, {'ok_dests': ['/tmp']})OpenWritePolicy
def OpenWritePolicy(
*args, **kwargs
):Check open() only when mode is writable
OpenWritePolicy restricts writable modes. Reading a file outside ok_dests is allowed.
owp = OpenWritePolicy()
owp(None, ['/tmp/f.txt', 'w'], {}, {'ok_dests': ['/tmp']})
owp(None, ['/etc/passwd', 'r'], {}, {'ok_dests': ['/tmp']})
with expect_fail(PermissionError): owp(None, ['/root/f.txt', 'w'], {}, {'ok_dests': ['/tmp']})Allow policies are stored as (name, AllowPolicy) tuples directly inside __pytools__ sets.
doc / xdir
resolve
def resolve(
sym_nm:str, # Dotted symbol path, with optional [n] indexing, e.g. "module.attr.subattr[1]"
):Resolve a dotted symbol string to its Python object, with optional [n] indexing
SymbolNotFound
def SymbolNotFound(
*args, **kwargs
):Common base class for all non-exit exceptions.
resolve looks up a name in the notebook namespace or builtins. A dotted path can include nonnegative indexes, such as module.items[1]. An unknown root name raises SymbolNotFound.
with expect_fail(SymbolNotFound): resolve('abc')
test_is(resolve('len'), len)
test_is(resolve('resolve'), resolve)
resolve('resolve'), resolve('len')(<function __main__.resolve(sym_nm: str)>, <function len(obj, /)>)
import pyskills.skillxdir
def xdir(
sym:str | object, # Module, class, or instance to inspect
q:str=None, # Optional case-insensitive regex over names
):Public names without evaluating instance properties; a plain instance lists its class’s names, and a SimpleNamespace its own
Use xdir to find public names before asking for their documentation. For a module, it uses __all__ when present. Otherwise it lists the module’s own definitions and sibling submodules, excluding imports from unrelated packages. It also includes explicitly imported sibling submodules in either case.
For a class, it lists public members and a non-default constructor. A plain instance uses its class’s listing. An instance with a custom __dir__ supplies its dynamic names instead. Pass q to filter the list with a case-insensitive regular expression.
x = xdir(pyskills.skill)
test_eq(x, xdir('pyskills.skill'))
assert 'skill_test_func' in x and 'SkillTestClass' in x
assert 'inspect' not in x
x['SkillTestClass',
'async_skill_test_func',
'skill_test_func',
'pyskills.createskill']
A dynamic namespace can expose a property that performs work when read. This example raises if inspection evaluates sound_now.
class Pet:
"A named animal with a sound"
def __init__(self, name, sound): self.name,self.sound = name,sound
def speak(self,
times:int=1, # Number of repetitions
):
"Make the pet's sound"
return ' '.join([self.sound]*times)
@property
def sound_now(self): raise RuntimeError('This property performs work')
def __dir__(self): return ['name', 'sound', 'speak', 'sound_now', 'speak']_xdir retains property descriptors instead of evaluating them. Ordinary methods remain bound to the instance.
p = Pet('Rex', 'woof')
members = dict(_xdir(p))
test_is(members['sound_now'], Pet.sound_now)
test_eq(members['speak'](2), 'woof woof')
xdir(p)['name', 'sound', 'sound_now', 'speak']
doc
def doc(
sym:str | object, # Object (or dotted name) to document; use the instance for a generated or bound API
*syms:str | object, # More objects: each doc appended as its own blank-line-separated section
all:bool=False, # Show symbol listings elided by `__pyskill_sigs__=False`?
)->str:Full callable documentation or a module/class/namespace overview; custom Markdown displays are preserved
Editing a source file can leave a live kernel with functions whose recorded line numbers are out of date. If fastcore cannot read a function’s source, its renderer shows the signature without parameter docments and warns with the symbol’s name. If rendering itself fails, doc returns the docstring and an error note. Restart the kernel to load the current source.
def _brk(): pass
_brk.__doc__ = 'A stale function.'
_brk.__code__ = _brk.__code__.replace(co_firstlineno=99999)
with warnings.catch_warnings():
warnings.simplefilter('ignore')
res = str(doc(_brk))
assert 'A stale function.' in res
res'_brk()"""A stale function."""'
For a function, doc renders the docstring and full signature with docments (parameter comments):
d = doc(pyskills.skill.skill_test_func)
test_eq(d, doc('pyskills.skill.skill_test_func'))
ddef skill_test_func(
x:int=0, # the input
)->str: # the output"""A test function"""
fmt_sig omits module prefixes from annotations. It retains generic arguments and unions in both parameters and return annotations.
def f(paths:list[Path|str])->Path|str: ...
sig = fmt_sig(f)
assert 'list[Path | str]' in sig and '-> Path | str' in sig
assert 'pathlib.' not in sig
sig'(paths: list[Path | str]) -> Path | str'
Some functions use Parameter.empty as a default value. fasthtml.core.add_sig_param is one example. Plain inspect.signature treats that sentinel as a missing default, which can make a rebuilt signature invalid. fmt_sig uses signature_ex to keep the declared default:
def f(a, b=1, default=inspect.Parameter.empty): ...
test_eq(fmt_sig(f), '(a, b=1, default=Parameter.empty)')For a class, doc shows the inheritance hierarchy, class docstring, and full constructor documentation. It lists public methods and properties in an overview. Read doc(Class.member) for a member’s parameter docments.
class_docs = doc(pyskills.skill.SkillTestClass)
assert 'Some class.' in class_docs
assert object.__init__.__doc__ not in class_docs
class_docsclass SkillTestClass(str):
"""Some class.
More info about it."""
def __init__():
def f(self, x: int = 0) -> str: ... # A test method…
@property
def g(self) -> str: ... # A test prop
Inherited members: `doc(str)`.
Overview only. Read `doc(Class.member)` for entries marked …; use an instance for dynamically bound members.
An inherited constructor retains its signature and parameter docments. Its prose appears once, beside that signature.
class Named:
def __init__(self,
name:str, # Name to display
):
"Create a named object."
self.name = name
class NamedChild(Named): pass
inherited_docs = doc(NamedChild)
assert 'Name to display' in inherited_docs
test_eq(inherited_docs.count('Create a named object.'), 1)
inherited_docsclass NamedChild(Named):
def __init__(
name:str, # Name to display
):"""Create a named object."""
Inherited members: `doc(Named)`.
Overview only. Read `doc(Class.member)` for entries marked …; use an instance for dynamically bound members.
Patched classmethods appear in the class overview. Read the method separately for its parameter docments.
from fastcore.apisurface import OpGroupclass Factory: pass
@patch(cls_method=True)
async def create(cls:Factory,
name:str, # Name of the new object
): return cls()
assert 'async def create' in doc(Factory)
assert 'Name of the new object' in doc(Factory.create)
assert 'doc render failed' not in doc(OpGroup)
assert object.__init__.__doc__ not in doc(Factory)
doc(Factory)class Factory:
@classmethod
async def create(name: str): ...…
Overview only. Read `doc(Class.member)` for entries marked …; use an instance for dynamically bound members.
A module overview lists public classes, functions, and submodules. Each function entry includes its signature and first docstring line. Compare these three functions: one has no documentation, one has a parameter docment, and one has a longer docstring.
An overview line ending in … has more documentation than it shows. Read doc(callable) before using that operation to see its parameter docments and remaining docstring.
Class and namespace overviews inspect properties without evaluating them. An object’s custom _repr_markdown_ supplies its own display, including generated API documentation or a data-oriented result view.
def _plain(x): return x
def _rich(x:int=0 # some param
): return str(x)
def _long(x):
"""Summary
More detail."""
_m = types.ModuleType('_m')
_m.__all__ = ['plain','rich','long']
_m.plain,_m.rich,_m.long = _plain,_rich,_longs = doc(_m)
fl = {l.split()[2].split('(')[0]:l for l in s.splitlines() if l.startswith('- def ')}
test_eq([fl[k].endswith('…') for k in ('plain','rich','long')], [False,True,True])
s# module _m:
## functions:
- def plain(x)
- def rich(x: int = 0): … # …
- def long(x): ... # Summary…
Set __pyskill_sigs__ = False when a module’s type and function listings are too large for its overview. doc() keeps the docstring and reports how many entries it omitted. Pass all=True to include those entries.
A package docstring can explain which submodules a reader needs, as nbdev’s generated package documentation does. When the cleaned docstring has more than three lines, doc() hides the automatic submodule list. A default summary and documentation link do not meet that threshold. Pass all=True to show the list.
import pyskillss = doc(pyskills)
assert '## submodules:' not in s
assert '## submodules:' in doc(pyskills, all=True)
s# module pyskills:
"""Python-native skills system
pyskills is a plugin system that lets Python packages register "skills" (units of LLM-usable functionality) via standard [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). An LLM harness (e.g. solveit) discovers available pyskills without importing them, reads lightweight descriptions via AST inspection, and selectively loads chosen pyskills into context using standard imports.
It includes `list_pyskills()` for discovery, `doc()` for rendering module/class/function documentation in LLM-friendly format, `xdir()` for listing a module or class's public symbols, and an `allow()` system for registering safe callable access in sandboxed environments. Skills can be installed as regular packages with entry points, or dropped into an XDG data directory for quick local use.
Modules:
- `pyskills.core`: Skill discovery, LLM-friendly doc rendering, the allow registry, and pyskill registration
- `pyskills.createskill`: How to create a pyskills pyskill module.
- `pyskills.skill`: Pyskills are tool modules that Python packages register so you can find them without importing everything. `list_pyskills()` names the ones installed, with a one-line description each, and needs no imports. Load one with a normal import, then read its docs with `doc()`.
"""
## elided: 3 submodules. `doc('pyskills', all=True)` lists them.
Skills registration
You don’t need to build a package to add a personal pyskill. Put its module in the user pyskills directory under XDG data home. Pyskills adds that directory to sys.path. Package entry points remain an option for skills you distribute with a library.
ensure_pyskills_dir
def ensure_pyskills_dir():Create xdg pyskills dir and .pth file if needed
pyskills_dir() gives you the user skill directory. ensure_pyskills_dir() creates it and adds it to the current sys.path. It also writes pyskills.pth in a writable site-packages directory for later Python sessions.
Virtual environments can share the same XDG skill directory. Each environment needs the path setup to import those skills.
clear_mod removes entries whose names start with the given prefix from sys.modules. It also invalidates import caches. Enabling or disabling a skill uses this to make the next import look it up again. Existing references to imported objects remain valid.
register_pyskill
def register_pyskill(
name, docstr, code:str=''
):Register a pyskill module name in the xdg pyskills dir
enable_pyskill
def enable_pyskill(
name
):Enable pyskill name by creating its dist-info entry point
For an existing module, enable_pyskill writes a minimal dist-info directory with its entry point. To create the module too, call register_pyskill with its docstring and code. It writes the module in the user skill directory and creates __init__.py files for any nested packages.
disable_pyskill
def disable_pyskill(
name
):Disable pyskill name by removing its dist-info entry point
disable_pyskill removes the skill’s dist-info directory from the user skill directory and clears its import-cache entries. The module file remains. delete_pyskill removes that file as well.
Folder-local skills
Call enable_local_skills(folder) when opening a dialog to find skills near that folder. It searches _pyskills/ in the opening folder and each ancestor. Public top-level .py files and packages supply skill entry points. A package uses its __init__.py as the skill module. Its other modules remain ordinary implementation modules. Names starting with an underscore are private.
The nearest folder wins when local folders contain the same name. A name that conflicts with an import outside those folders raises an error instead of replacing that import.
The opening folder stays fixed after chdir. Discovery rescans those locations for new files without executing skill code or writing metadata files. Imports still use Python’s module cache. Repeating activation for the same folder returns the existing finder. Use a fresh kernel to activate a different folder.
from tempfile import TemporaryDirectoryThe finder supplies entry-point metadata in memory. It lists source files without running them and uses PathFinder to load a selected module. Its search roots come from the opening folder, not the current working directory.
with TemporaryDirectory() as tmp:
folder = Path(tmp)
(folder/'_pyskills').mkdir()
source = folder/'_pyskills'/'orchard_tools.py'
source.write_text('raise RuntimeError("Discovery must not execute this module")')
local_finder = _LocalSkills(folder)
test_eq(local_finder.sources()['orchard_tools'], source)
display(list(local_finder.sources()))['orchard_tools']
enable_local_skills
def enable_local_skills(
folder:str | pathlib.Path, # Opening directory whose ancestor _pyskills folders supply skills; expands ~ and resolves relative paths
)->importlib.metadata.DistributionFinder: # The installed finder; repeated activation for the same directory returns itEnable folder-local skill imports and entry points for this interpreter.
Call once at host startup. Later cwd changes do not alter the scope. A different folder after activation raises ValueError; use a new kernel. Local skill names conflicting with other importable modules also raise ValueError. No skill code is executed during activation or discovery, and no metadata files are written.
Local skills appear in the same catalogue as installed skills. This example checks that discovery reads the description without importing the module. It removes the temporary finder afterwards.
with TemporaryDirectory() as tmp:
folder = Path(tmp)
(folder/'_pyskills').mkdir()
(folder/'_pyskills'/'orchard_tools.py').write_text('"""Project orchard tools."""')
finder = enable_local_skills(folder)
try:
description = list_pyskills()['orchard_tools']
test_eq(description, 'Project orchard tools.')
assert 'orchard_tools' not in sys.modules
display(description)
finally: sys.meta_path.remove(finder)'Project orchard tools.'