pyskills API

Skill discovery, LLM-friendly doc rendering, the allow registry, and pyskill registration
from fastcore.test import test,test_eq,test_is,expect_fail

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 names

These 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:

  1. Call list_pyskills() at startup.
  2. Include the catalogue with each prompt.
  3. Import each selected module and read doc(module).

Finding modules without importing them


source

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'))
es
EntryPoint(name='pyskills.skill', value='pyskills.skill', group='pyskills')

source

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()`.'

source

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.


source

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 httpx

A 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_cache

Decorated 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 None

A 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


source

chk_dest

def chk_dest(
    p, ok_dests
):

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', ())

source

PosAllowPolicy

def PosAllowPolicy(
    pos:int=0, kw:NoneType=None
):

Check positional/keyword path arg is an allowed destination; non-path buffers/handles are skipped


source

AllowPolicy

def AllowPolicy(
    *args, **kwargs
):

Base for allow destination policies

PosAllowPolicy checks a path passed at a selected argument position or keyword. Paths outside ok_dests raise PermissionError.

import io
pp = 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']})

source

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']})

source

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


source

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


source

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.skill

source

xdir

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']

source

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'))
d
def skill_test_func(
    x:int=0, # the input
)->str: # the output"""A test function"""

source

fmt_sig

def fmt_sig(
    f, ps:NoneType=None
):

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_docs
class 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_docs
class 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 OpGroup
class 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,_long
s = 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 pyskills
s = 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.

Shared param groups

Some APIs repeat a set of parameters across many functions. For example, rgapi shares walk filters across fd, ls, rg, and nbrg. Fastcore’s editing functions repeat parameters across variants for different source objects. Showing the same documentation on every overview line wastes space.

Declare __pyskill_params__ on the module as a dictionary from group names to parameter names. An overview can then show **group instead of those parameters. The ## shared params: section documents each group once, using docments from the first matching function.

def _fd(root:str='.', hidden:bool=False # Include hidden files?
    ): ...
def _rg(pat, root:str='.', hidden:bool=False): ...
_w = types.ModuleType('_w')
_w.__all__ = ['fd','rg']
_w.__pyskill_params__ = {'walk_params': ('root','hidden')}
_w.fd,_w.rg = _fd,_rg

Both functions expose the same root and hidden parameters. Their overview signatures reference one shared group with the original parameter docments.

s = doc(_w)
fl = {l.split()[2].split('(')[0]:l for l in s.splitlines() if l.startswith('- def ')}
assert '**walk_params' in fl['fd'] and '**walk_params' in fl['rg']
assert 'root' not in fl['rg'] and 'pat' in fl['rg']
assert '## shared params:' in s and 'Include hidden files?' in s
s
# module _w:


## functions:
- def fd(**walk_params): …  # …
- def rg(pat, **walk_params)

## shared params:
- **walk_params (from `fd`):
    root: str = '.',
    hidden: bool = False, # Include hidden files?

A function must contain every parameter in a group to use its abbreviation. The types and defaults must match those of the first matching function. If a default differs, the overview keeps the parameters inline and emits a warning. That difference can matter: ls uses listing defaults where fd uses finding defaults.

def _ls(root:str='.', hidden:bool=True): ...
_w.ls,_w.__all__ = _ls,['fd','rg','ls']
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter('always')
    s2 = doc(_w)
fl2 = {l.split()[2].split('(')[0]:l for l in s2.splitlines() if l.startswith('- def ')}
assert '**walk_params' not in fl2['ls'] and 'hidden' in fl2['ls']
assert '**walk_params' in fl2['fd']
assert w and 'walk_params' in str(w[0].message)
s2
# module _w:


## functions:
- def fd(**walk_params): …  # …
- def rg(pat, **walk_params)
- def ls(root: str = '.', hidden: bool = True)

## shared params:
- **walk_params (from `fd`):
    root: str = '.',
    hidden: bool = False, # Include hidden files?

A dynamic instance’s overview shows its members and class documentation. Read a method separately for any docments that its overview marks with .

class Puppy(Pet): pass

test_eq(xdir(p, 'sp'), ['speak'])
test_eq(xdir(Puppy('Fido', 'yip'), 'sp'), ['speak'])
rendered = doc(p)
assert all(s in rendered for s in ('A named animal', 'sound_now', 'speak', '…'))
assert 'Number of repetitions' in doc(p.speak)
doc(p)
Instance of type Pet:
A named animal with a sound
- name: str
- sound: str
@property
    def sound_now(self): ...
def speak(times: int = 1): ...  # Make the pet's sound…

Overview only. Read `doc(obj.member)` for entries marked …; inspect properties on the class without evaluating them.

A dynamic namespace can also be callable. Its overview includes both the call signature and the other members:

class PetCall(Pet):
    def __call__(self, times=1): return self.speak(times)

assert 'sound_now' in doc(PetCall('Rex', 'woof'))
assert '__call__' in doc(PetCall('Rex', 'woof'))
doc(PetCall('Rex', 'woof'))
Instance of type PetCall:
A named animal with a sound
def __call__(times=1): ...  # Call self as a function.
- name: str
- sound: str
@property
    def sound_now(self): ...
def speak(times: int = 1): ...  # Make the pet's sound…

Overview only. Read `doc(obj.member)` for entries marked …; inspect properties on the class without evaluating them.

A plain instance without a custom __dir__ uses its class’s names in xdir. You can inspect a client such as Kaggle’s KaggleApi this way without listing its internal state. This rule changes name discovery, not how doc displays an ordinary instance’s repr.

class Client:
    def __init__(self, key): self.key = key
    def fetch(self):
        "Fetch a record"
        return self.key

test_eq(xdir(Client('x'), 'fetch'), ['fetch'])
test_eq(xdir(types.SimpleNamespace(fetch=1, _hidden=2)), ['fetch'])
xdir(Client('x'))
['__init__', 'fetch']

source

docfind

def docfind(
    o:str | object, q:str, n:int=2, _pre:str=''
):

Search doc() recursively through xdir(o), looking at submodules, classes, and functions, to depth n

docfind searches rendered documentation with a case-insensitive regular expression. It follows names from xdir recursively and returns matching paths with a summary line. Set n to limit the depth when exploring a large module.

matches = docfind(pyskills.skill, 'test')
assert any('skill_test_func' in match for match in matches)
matches
[' // # module pyskills.skill:',
 'SkillTestClass // class SkillTestClass(str):',
 'SkillTestClass.f // def f(',
 'SkillTestClass.g // def g()->str:"""A test prop"""',
 'async_skill_test_func // async def async_skill_test_func(',
 'skill_test_func // def skill_test_func(']
import fastcore.tools
doc(fastcore.tools)
# module fastcore.tools:

"""Text and file editing primitives shared by the fastai editing tools

The editors here are string-level: each takes `text` plus edit parameters and returns the new text, raising `ValueError` when an edit can't apply. The file tools below wrap them with path I/O and diff reporting; message-level wrappers live in aidialog. (This module previously held experimental LLM path-editing and command tools, superseded by safecmd, rgapi, and the tools here.) Naming, parameter, and workflow conventions for the whole editing toolkit, this module included, are documented in `fastcore.editskill`, which also re-exports these tools alongside `fastcore.nbio`'s.

`line_hash`, `lnhash`, and `lnhash_at` implement the [exhash](https://answerdotai.github.io/exhash) line-address format in pure Python: `lineno|hash|`, where the hash is 4 hex chars of crc32. They let any tool create lnhash-addressed views of text it holds, without depending on the exhash package.

File tools wrap the primitives with path I/O, returning unified diffs of what changed ("none: No changes." / "error: ..." otherwise). The path is the first argument, e.g:

    view_file('~/a/b.py', 3)
    create_file('~/a/b/c.py', 'content here')
    file_str_replace('myfile.py', 'old_name', 'new_name')
    file_del_lines('myfile.py', 2, 4)
    file_replace_lines('myfile.py', new_content=src)   # no line numbers: replace the entire contents

`file_str_replace`, `file_strs_replace`, and `file_del_lines` support `re_filter` and `invert_filter` for targeting only lines matching (or not matching) a regex, like ex's `g//` and `g!//`, combined with `start_line`/`end_line` to restrict to a region. `ast_replace(text, repls)` and `file_ast_replace(path, repls)` apply ast-grep `(pattern, replacement)` rules with `$VAR` metavariables (requires the optional `remold` package).

Docs: https://fastcore.fast.ai/tools.html.md
"""

## functions:
- def file_insert_line(path: str, insert_line: int, new_str: str): ...  # Insert new_str at specified line number…
- def file_str_replace(path: str, old_str: str, new_str: str, **replace_params): ...  # Replace occurrence(s) of old_str with new_str…
- def file_strs_replace(path: str, old_strs: list[str], new_strs: list[str], **replace_params): ...  # Replace multiple strings simultaneously…
- def file_replace_lines(path: str, start_line: int = None, end_line: int = None, new_content: str = ''): ...  # Replace line range with new content; the defaults replace the entire contents…
- def file_del_lines(path: str, start_line: int, end_line: int, re_filter: str = None, invert_filter: bool = False): ...  # Delete line range; deletion is destructive, so both line numbers must be given explicitly (`1, -1` for all lines)…
- def file_ast_replace(path: str, repls: list): ...  # Apply ast-grep structural pattern replacements to python source…
- def insert_line(text: str, insert_line: int, new_str: str): ...  # Insert new_str at specified line number…
- def str_replace(text: str, old_str: str, new_str: str, **replace_params): ...  # Replace occurrence(s) of old_str with new_str…
- def strs_replace(text: str, old_strs: list[str], new_strs: list[str], **replace_params): ...  # Replace multiple strings simultaneously…
- def replace_lines(text: str, start_line: int = None, end_line: int = None, new_content: str = ''): ...  # Replace line range with new content; the defaults replace the entire contents…
- def del_lines(text: str, start_line: int, end_line: int, re_filter: str = None, invert_filter: bool = False): ...  # Delete line range; deletion is destructive, so both line numbers must be given explicitly (`1, -1` for all lines)…
- def line_hash(line: str) -> str: ...  # 4-char hex hash of `line`…
- def lnhash(lineno: int, line: str) -> str: ...  # `lineno|hash|` exhash address for `line` at `lineno`…
- def lnhash_at(s: str | list | tuple, line: int) -> str: ...  # `lineno|hash|` exhash address of line `line` of `s`…
- def view_file(path: str, start_line: int = 1, end_line: int = None, nums: bool = True, lnhashs: bool = False): ...  # Read file contents, optionally limited to 1-based line range…
- def create_file(path: str, contents: str, overwrite: bool = False): ...  # Create a new file with contents. Error if file exists, unless `overwrite`.…
- def file_edit(f, name=None): ...  # Wrap text editor `f` as a file editing function: `path` addressing, diff-or-error return
- def ast_replace(text: str, repls: list): ...  # Apply ast-grep structural pattern replacements to python source…

## shared params:
- **replace_params (from `file_str_replace`):
    start_line: int = None, # Optional 1-based start line to limit search
    end_line: int = None, # Optional 1-based end line to limit search
    n_matches: int = None, # Max replacements (None=all)
    re_filter: str = None, # If provided, only process lines matching this regex (like g// in ex)
    invert_filter: bool = False, # Invert the filter (like g!// in ex)
    use_regex: bool = False, # Treat old_str as a regex, and new_str as an `re.sub` template?
doc(fastcore.tools.create_file)
def create_file(
    path:str, # Path to create (expands `~` if needed)
    contents:str, # Contents of file to create
    overwrite:bool=False, # Replace the file if it already exists?
):"""Create a new file with contents. Error if file exists, unless `overwrite`."""

Pass multiple objects to doc to read their documentation in one call. Each object has its own section, separated by a blank line. For example, read a module overview alongside a function you plan to call:

d2 = doc(fastcore.tools, 'fastcore.tools.create_file')
test_eq(d2, doc(fastcore.tools) + '\n\n' + doc(fastcore.tools.create_file))

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.


source

ensure_pyskills_dir

def ensure_pyskills_dir():

Create xdg pyskills dir and .pth file if needed


source

pyskills_dir

def pyskills_dir():

Directory for user pyskills

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.


source

clear_mod

def clear_mod(
    prefix
):

Clear modules starting with prefix from python caches

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.


source

register_pyskill

def register_pyskill(
    name, docstr, code:str=''
):

Register a pyskill module name in the xdg pyskills dir


source

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.


source

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.


source

delete_pyskill

def delete_pyskill(
    name
):

Delete pyskill name module files and dist-info

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 TemporaryDirectory

The 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']

source

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 it

Enable 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.'