obsilink
obsilink is a small, deterministic Python library for extracting and replacing links in Markdown text.
It supports:
- Obsidian wikilinks like
[[Note]],[[Note|Alias]], and![[Embedded]] - Markdown links like
[Docs](https://example.com)and embeds like - Plain URLs like
https://example.com,ftp://files.example.com, andmailto:user@example.com
The parser returns structured Link objects in encounter order and preserves duplicates.
Links can also be replaced in-place using structured (old, new) pairs.
Features
- Deterministic parsing with stable ordering
- Two public functions:
extract_links(...)andreplace_links(...) - Accepts either raw
stror file-like input with.read() - Preserves duplicates instead of deduplicating
- Strict, typed API compatible with
mypystrict mode - Links are split into structured fields (
target,alias,heading,blockid) — heading (#) and block-id (^) fragments are extracted separately and never included intarget
Installation
With uv (recommended)
uv add obsilink
With pip
pip install obsilink
From source
git clone https://github.com/chgroeling/obsilink.git
cd obsilink
uv sync
If the package is not yet published to your package index, use the source install method.
Quick start
from obsilink import extract_links
text = """
See [[Project/Plan#Milestones|Roadmap]],
,
and https://example.com.
"""
links = extract_links(text)
for link in links:
print(link.type.value, link.target, link.alias)
Replacing links
from obsilink import Link, LinkType, replace_links
text = "See [[OldNote]] for details."
old = Link(type=LinkType.WIKILINK, target="OldNote", alias=None, heading=None, blockid=None)
new = Link(type=LinkType.WIKILINK, target="NewNote", alias=None, heading=None, blockid=None)
result_text, applied = replace_links(text, [(old, new)])
print(result_text) # See [[NewNote]] for details.
print(applied) # [True]
More complex Obsidian wikilink example
from obsilink import extract_links
text = """
[[Project/Plan#Milestones|Roadmap]]
![[Assets/Diagrams/System Overview#v2]]
[[People/Ada Lovelace#Biography^early-life|Ada]]
[[Project/Plan#Milestones|Roadmap]]
[[Malformed
"""
links = extract_links(text)
for link in links:
print(
link.type.value,
link.target,
link.alias,
link.heading,
link.blockid,
)
# Encounter order is preserved, duplicates are kept, and malformed
# wikilinks (like "[[Malformed") are ignored.
API
extract_links(source)
Extracts links from:
str- text file-like object with
.read()returningstr
Returns:
list[Link]in encounter order
Raises:
TypeErrorfor unsupported source typesTypeErrorif.read()returns non-string data- Any exception raised by
.read()is propagated
replace_links(source, replacements)
Replaces links in text based on a list of (old_link, new_link) pairs.
Accepts:
source:stror text file-like object with.read()returningstrreplacements:list[tuple[Link, Link]]
Returns:
tuple[str, list[bool]]— the modified text and a boolean per pair indicating whether the replacement was applied
Replacements are applied sequentially; each pair replaces only the first occurrence.
Raises:
TypeErrorfor unsupported source typesTypeErrorif.read()returns non-string data- Any exception raised by
.read()is propagated
Link
Link is a frozen dataclass with these fields:
type: LinkTypetarget: stralias: str | Noneheading: str | Noneblockid: str | None
Convenience properties:
link.is_url->Truefor URL targets (http,https,ftp,file,mailto)link.as_path->pathlib.Pathfor non-URL targets (raisesValueErrorfor URLs)
Supported link types
LinkType.WIKILINKLinkType.WIKILINK_EMBEDLinkType.MARKDOWN_LINKLinkType.MARKDOWN_EMBEDLinkType.PLAIN_URL
How links are split
Each link is split into its constituent parts. The target field
contains only the note, path, or URL — heading (#) and block-id (^) fragments are
extracted into their own fields and are not included in target.
Obsidian wikilinks
| Input | target |
alias |
heading |
blockid |
|---|---|---|---|---|
[[Note]] |
Note |
None |
None |
None |
[[Note|Alias]] |
Note |
Alias |
None |
None |
[[Folder/Note#Heading]] |
Folder/Note |
None |
Heading |
None |
[[Note^abc123]] |
Note |
None |
None |
abc123 |
[[Note#Section^block|Display]] |
Note |
Display |
Section |
block |
![[Embedded]] |
Embedded |
None |
None |
None |
Markdown links
For Markdown links, heading and blockid are always None. The target is the URL or path inside parentheses, and alias is the link text inside brackets.
| Input | target |
alias |
heading |
blockid |
|---|---|---|---|---|
[Docs](https://example.com) |
https://example.com |
Docs |
None |
None |
 |
assets/logo.png |
Logo |
None |
None |
[](relative/path.md) |
relative/path.md |
None |
None |
None |
Development
This project uses uv, ruff, mypy, and pytest.
uv sync
uv run ruff format src/ tests/
uv run ruff check src/ tests/
uv run mypy src/
uv run pytest
License
This project is licensed under the MIT License. See LICENSE.
Release files for obsilink 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| obsilink-0.4.0.tar.gz | 28.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| obsilink-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 37.5 kB
Release files / obsilink-0.4.0.tar.gz
| Download URL | obsilink-0.4.0.tar.gz |
|---|---|
| Size | 28.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
78b811176f070871c9cdb739f5094a88b529dfa73258d61843ca575f35f25be7
|
|
BLAKE2b-256 checksum How to use checksums |
2d436610e0ddafbe480fadfba80eb460a89c3b382ef3c9126877cb768e620f0a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Apr 17, 2026.
Transparency logRelease files / obsilink-0.4.0-py3-none-any.whl
| Download URL | obsilink-0.4.0-py3-none-any.whl |
|---|---|
| Size | 9.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bd6c71ffef55bb0dc9f44490044ca5f253464c49c393015967fd3a40030886d8
|
|
BLAKE2b-256 checksum How to use checksums |
cdb5540a426dc09b9800b4579231234a03afb72b7135200810fcd1967cf63f77
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Apr 17, 2026.
Transparency log