Skip to content

Commit f6ae14f

Browse files
committed
feat: Initial commit
0 parents  commit f6ae14f

27 files changed

Lines changed: 1216 additions & 0 deletions

.github/workflows/main.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# This is a basic workflow to help you get started with Actions
2+
3+
name: CI
4+
5+
# Controls when the workflow will run
6+
on:
7+
# Triggers the workflow on push or pull request events but only for the "master" branch
8+
push:
9+
branches: [ "master" ]
10+
pull_request:
11+
branches: [ "master" ]
12+
13+
# Allows you to run this workflow manually from the Actions tab
14+
workflow_dispatch:
15+
16+
jobs:
17+
run-tests:
18+
runs-on: ubuntu-latest
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
python-version: ["3.10", "3.11", "3.12", "3.13"]
23+
24+
steps:
25+
- uses: actions/checkout@v5
26+
27+
- name: Install uv and set the python version
28+
uses: astral-sh/setup-uv@v6
29+
with:
30+
python-version: ${{ matrix.python-version }}
31+
32+
- name: Install the project
33+
run: |
34+
uv sync --all-extras --dev
35+
uv pip install -e .
36+
37+
- name: Typecheck with mypy
38+
run: uv run mypy
39+
40+
- name: Check with ruff
41+
run: uv run ruff check
42+
43+
- name: Test with pytest
44+
run: uv run pytest

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/.idea/
2+
/temp/
3+
4+
*.py[co]

LICENSE.txt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2025 Tomas Karabela
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is
8+
furnished to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in
11+
all copies or substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
THE SOFTWARE.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
regex-automata
2+
==============
3+

pyproject.toml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
[project]
2+
name = "regex-automata"
3+
description = "Library for regular expressions using finite automata"
4+
readme = "README.md"
5+
authors = [{name = "Tomas Karabela", email = "tkarabela@seznam.cz"}]
6+
license = "MIT"
7+
license-files = ["LICENSE.txt"]
8+
requires-python = ">=3.10"
9+
dependencies = [
10+
"graphviz~=0.21",
11+
]
12+
dynamic = ["version"]
13+
keywords = ["regular expressions", "regex", "finite automata"]
14+
classifiers = [
15+
"Development Status :: 3 - Alpha",
16+
"License :: OSI Approved :: MIT License",
17+
"Operating System :: OS Independent",
18+
"Programming Language :: Python :: 3",
19+
"Programming Language :: Python :: 3.10",
20+
"Programming Language :: Python :: 3.11",
21+
"Programming Language :: Python :: 3.12",
22+
"Programming Language :: Python :: 3.13",
23+
"Topic :: Text Processing",
24+
"Typing :: Typed",
25+
]
26+
27+
[project.urls]
28+
Homepage = "https://github.com/tkarabela/regex-automata"
29+
Repository = "https://github.com/tkarabela/regex-automata.git"
30+
Issues = "https://github.com/tkarabela/regex-automata/issues"
31+
32+
[dependency-groups]
33+
dev = [
34+
"mypy~=1.18",
35+
"pytest~=8.4",
36+
"pytest-cov~=7.0",
37+
"ruff~=0.13",
38+
]
39+
40+
[build-system]
41+
requires = ["hatchling"]
42+
build-backend = "hatchling.build"
43+
44+
[tool.hatch.version]
45+
path = "src/regex_automata/__init__.py"
46+
47+
[tool.hatch.build.targets.sdist]
48+
exclude = [
49+
"/.github",
50+
]
51+
52+
[tool.mypy]
53+
strict = "True"
54+
mypy_path = "$MYPY_CONFIG_FILE_DIR/src"
55+
files = "src/**/*.py, tests/*.py"
56+
57+
[[tool.mypy.overrides]]
58+
module = "tests.*"
59+
disallow_incomplete_defs = "False"
60+
disallow_untyped_defs = "False"

src/regex_automata/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from .pattern import Pattern
2+
3+
__version__ = "0.1.0"
4+
5+
6+
def fullmatch(pattern: str, s: str) -> bool:
7+
return Pattern(pattern).fullmatch(s)
8+
9+
10+
def match(pattern: str, s: str) -> bool:
11+
return Pattern(pattern).match(s)
12+
13+
14+
def search(pattern: str, s: str) -> bool:
15+
return Pattern(pattern).search(s)
16+
17+
18+
def compile(pattern: str) -> Pattern:
19+
return Pattern(pattern)

src/regex_automata/automata/__init__.py

Whitespace-only changes.

src/regex_automata/automata/nfa.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from dataclasses import dataclass
2+
from typing import Self, Any, Iterable
3+
import json
4+
from abc import ABC, abstractmethod
5+
from itertools import count
6+
7+
from ..common import PathOrStr
8+
9+
10+
class FiniteAutomaton(ABC):
11+
"""Base class for finite automata"""
12+
@abstractmethod
13+
def accepts(self, s: str) -> bool:
14+
raise NotImplementedError
15+
16+
@classmethod
17+
def from_file(cls, path: PathOrStr) -> Self:
18+
with open(path) as fp:
19+
data = json.load(fp)
20+
return cls.from_dict(data)
21+
22+
@classmethod
23+
@abstractmethod
24+
def from_dict(cls, data: dict[str, Any]) -> Self:
25+
raise NotImplementedError
26+
27+
def to_file(self, path: PathOrStr) -> None:
28+
data = self.to_dict()
29+
with open(path, "w") as fp:
30+
json.dump(data, fp, indent=4)
31+
32+
@abstractmethod
33+
def to_dict(self) -> dict[str, Any]:
34+
raise NotImplementedError
35+
36+
37+
@dataclass
38+
class NFA(FiniteAutomaton):
39+
"""
40+
Non-deterministic finite automaton
41+
42+
Bonus I: support epsilon transitions (transition that "reads empty string") - DONE
43+
"""
44+
states: list[int]
45+
initial_state: int
46+
final_states: list[int]
47+
transitions: dict[int, dict[str, set[int]]]
48+
49+
class Evaluator:
50+
def __init__(self, nfa: "NFA") -> None:
51+
self.nfa = nfa
52+
self.states: set[int] = self.nfa.epsilon_closure({nfa.initial_state})
53+
54+
def step(self, c: str) -> None:
55+
new_states = set()
56+
for u in self.states:
57+
new_states.update(self.nfa.transitions.get(u, {}).get(c, set()))
58+
59+
self.states = self.nfa.epsilon_closure(new_states)
60+
61+
def accepts(self, s: str) -> bool:
62+
evaluator = self.Evaluator(self)
63+
for c in s:
64+
evaluator.step(c)
65+
return len(evaluator.states.intersection(self.final_states)) > 0
66+
67+
@classmethod
68+
def from_dict(cls, data: dict[str, Any]) -> Self:
69+
transitions: dict[int, dict[str, set[int]]] = {}
70+
for u, c, v in data["transitions"]:
71+
transitions.setdefault(u, {}).setdefault(c, set()).add(v)
72+
73+
return cls(
74+
states=data["states"],
75+
initial_state=data["initial_state"],
76+
final_states=data["final_states"],
77+
transitions=transitions,
78+
)
79+
80+
def to_dict(self) -> dict[str, Any]:
81+
transitions = []
82+
for u, tmp in self.transitions.items():
83+
for c, vs in tmp.items():
84+
for v in vs:
85+
transitions.append([u, c, v])
86+
87+
return {
88+
"states": self.states,
89+
"initial_state": self.initial_state,
90+
"final_states": self.final_states,
91+
"transitions": transitions,
92+
}
93+
94+
def copy(self) -> "NFA":
95+
return self.from_dict(self.to_dict())
96+
97+
def renumber_states(self, x0: int) -> "NFA":
98+
f = dict(zip(self.states, count(x0)))
99+
return NFA(
100+
states=[f[x] for x in self.states],
101+
initial_state=f[self.initial_state],
102+
final_states=[f[x] for x in self.final_states],
103+
transitions={
104+
f[x]: {c: {f[y] for y in ys} for c, ys in d.items()}
105+
for x, d in self.transitions.items()
106+
},
107+
)
108+
109+
def epsilon_closure(self, states: Iterable[int]) -> set[int]:
110+
closure = set(states)
111+
while True:
112+
new_closure = closure
113+
for u in closure:
114+
new_closure = new_closure | self.transitions.get(u, {}).get("", set())
115+
if len(closure) == len(new_closure):
116+
break
117+
closure = new_closure
118+
return closure
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import graphviz # type: ignore[import-untyped]
2+
3+
from .nfa import NFA
4+
5+
6+
class NFAVisualizer:
7+
def __init__(self, nfa: NFA) -> None:
8+
self.nfa = nfa
9+
10+
def get_digraph_dot(self) -> graphviz.Digraph:
11+
g = graphviz.Digraph()
12+
g.attr(rankdir='LR')
13+
g.node("", shape="none")
14+
for u in self.nfa.states:
15+
g.node(str(u), shape="doublecircle" if u in self.nfa.final_states else "circle")
16+
g.edge("", str(self.nfa.initial_state))
17+
for u, d in self.nfa.transitions.items():
18+
for c, vs in d.items():
19+
c = c or "ε"
20+
for v in vs:
21+
g.edge(str(u), str(v), label=c)
22+
return g
23+
24+
def to_png(self, output: str = "nfa.png") -> None:
25+
dot = self.get_digraph_dot()
26+
dot.render(output, format="png")

src/regex_automata/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import os
2+
from typing import Any
3+
4+
PathOrStr = str | os.PathLike[Any]

0 commit comments

Comments
 (0)