Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
786b099
Generate SBOM for wheel
bact Aug 20, 2026
795b080
Fix SonarCloud issues
bact Aug 20, 2026
31abae7
Fix SonarCloud issues
bact Aug 20, 2026
9dd6188
Bump budoux from 0.7.0 to 0.9.0
dependabot[bot] Aug 20, 2026
211d3b7
Bump transformers from 5.14.1 to 5.15.0
dependabot[bot] Aug 20, 2026
9fc3882
Bump github/codeql-action from 4.37.6 to 4.37.7
dependabot[bot] Aug 20, 2026
4fdfa90
Bump EndBug/add-and-commit from 10.0.0 to 11.0.0
dependabot[bot] Aug 20, 2026
75691ab
Add SBOM creator info
bact Aug 20, 2026
a7721ef
Merge pull request #1489 from PyThaiNLP/dependabot/github_actions/End…
bact Aug 20, 2026
94d9443
Merge pull request #1486 from PyThaiNLP/dependabot/pip/budoux-0.9.0
bact Aug 20, 2026
7cff885
Merge pull request #1488 from PyThaiNLP/dependabot/github_actions/git…
bact Aug 20, 2026
2fff2fd
Merge pull request #1487 from PyThaiNLP/dependabot/pip/transformers-5…
bact Aug 20, 2026
2f2c5cb
Bump epitran from 1.26.0 to 1.35.2
dependabot[bot] Aug 20, 2026
73966cf
Merge pull request #1467 from PyThaiNLP/dependabot/pip/epitran-1.35.2
bact Aug 20, 2026
4f812cc
Bump transformers from 5.15.0 to 5.15.1
dependabot[bot] Aug 27, 2026
127db37
Bump EndBug/add-and-commit from 11.0.0 to 11.1.1
dependabot[bot] Aug 27, 2026
8ebff6d
Merge pull request #1494 from PyThaiNLP/dependabot/github_actions/End…
bact Aug 27, 2026
455fb91
Merge pull request #1491 from PyThaiNLP/dependabot/pip/transformers-5…
bact Aug 27, 2026
1f40d78
Bump transformers from 5.15.1 to 5.16.1
dependabot[bot] Sep 3, 2026
b0f3581
Bump github/codeql-action from 4.37.7 to 4.37.9
dependabot[bot] Sep 3, 2026
4185da5
Merge pull request #1497 from PyThaiNLP/dependabot/github_actions/git…
bact Sep 3, 2026
4f424df
Merge pull request #1496 from PyThaiNLP/dependabot/pip/transformers-5…
bact Sep 3, 2026
19732d0
Bump budoux from 0.9.0 to 0.9.1
dependabot[bot] Sep 10, 2026
f18cc11
fix(transliterate): stop runaway repetition in thai2rom romanize
kamthorn Sep 13, 2026
53a1be0
fix(transliterate): stop runaway repetition in thaig2p g2p decoding
kamthorn Sep 13, 2026
e4c4802
refactor(text_to_num): repeatly call _check_is_thai_num over and over
Palmkonde Sep 16, 2026
1413565
fix(wordtonum): add "ศูนย์" handle as a special case
Palmkonde Sep 16, 2026
7e244ca
fix(wordtonum): add more condition for flush to list
Palmkonde Sep 16, 2026
8fe2622
refactor(wordtonum): refactor due to black
Palmkonde Sep 16, 2026
61faccc
add(test_util): add testcase for 0 digit handle in text_to_num
Palmkonde Sep 16, 2026
5631605
Merge branch 'PyThaiNLP:main' into generate-sbom
bact Sep 16, 2026
2a43510
Update Pitloom==0.18.0; build==1.6.1
bact Sep 16, 2026
b72ccfe
Merge pull request #1498 from PyThaiNLP/dependabot/pip/budoux-0.9.1
bact Sep 16, 2026
63a28a4
refactor(wordtonum): replace tokens/index with next_word per review
Palmkonde Sep 16, 2026
bab3b43
Merge pull request #1503 from Palmkonde/fix/text-to-num-zero-handling
bact Sep 16, 2026
ddb6fcc
Merge pull request #1485 from bact/generate-sbom
bact Sep 16, 2026
801c526
fix(transliterate): use assertIsNone per review on PR #1500
kamthorn Sep 17, 2026
dea41b1
Merge branch 'fix/thai2rom-repetition-loop' into fix/thaig2p-repetiti…
kamthorn Sep 17, 2026
a4b2575
chore(deps-dev): bump transformers from 5.16.1 to 5.17.0
dependabot[bot] Sep 17, 2026
fbc4654
chore(deps): bump github/codeql-action from 4.37.9 to 4.38.0
dependabot[bot] Sep 17, 2026
fea84b4
Merge pull request #1506 from PyThaiNLP/dependabot/github_actions/git…
bact Sep 18, 2026
a25c1e0
Merge pull request #1505 from PyThaiNLP/dependabot/pip/transformers-5…
bact Sep 18, 2026
5a985d1
Update Pitloom to 0.18.1
bact Sep 18, 2026
fe695d4
Update pitloom-version [cd build]
bact Sep 18, 2026
d0f8a41
Merge pull request #1507 from bact/update-pitloom
bact Sep 18, 2026
534e9e6
Merge pull request #1501 from kamthorn/fix/thaig2p-repetition-loop
bact Sep 20, 2026
f8924e2
Merge branch 'dev' into fix-1513
wannaphong Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/codemeta2cff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
uses: dieghernan/cff-validator@d8f85828214016ce6976e740b6e0504c0fdc4dbb #v5.1.1

- name: Commit and push updated CITATION.cff
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 #v10.0.0
uses: EndBug/add-and-commit@cc9c08ba6c8df3b93a8f2db63e89b98368ae2ae8 #v11.1.1
with:
message: "Update CITATION.cff from codemeta.json"
add: "CITATION.cff"
6 changes: 3 additions & 3 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.37.6
uses: github/codeql-action/init@v4.38.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
Expand All @@ -77,7 +77,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v4.37.6
uses: github/codeql-action/autobuild@v4.38.0

# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
Expand All @@ -91,4 +91,4 @@ jobs:
# make release

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.37.6
uses: github/codeql-action/analyze@v4.38.0
137 changes: 127 additions & 10 deletions .github/workflows/pypi-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
echo_github_env:
name: Echo env variables
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- run: |
echo "github.event.action : ${{ github.event.action }}"
Expand All @@ -48,11 +50,13 @@ jobs:
runs-on: ubuntu-latest
# Not for forks
if: github.repository == 'pythainlp/pythainlp'
permissions:
contents: read
outputs:
build: ${{ steps.check_build_trigger.outputs.build }}
steps:
- name: Checkout source code
uses: actions/checkout@v7.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- id: check_build_trigger
Expand All @@ -67,47 +71,160 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12"]
python-version: ["3.13"]
permissions:
contents: read
outputs:
sbom-path: ${{ steps.pitloom.outputs.sbom-path }}

steps:
- name: Checkout
uses: actions/checkout@v7.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml

- name: Install dependencies
run: |
pip install --upgrade build pip twine
pip install --only-binary :all: "pip==26.2.1"
pip install --only-binary :all: "build==1.6.1" "spdx3-validate==0.0.7" "twine==7.0.0"

- name: Build source distribution and wheels
run: python -m build

- name: Check distributions
run: twine check dist/*

- name: Generate SBOM and embed into the wheel
id: pitloom
uses: bact/pitloom@6a5473c42efcd3727d2a1ff94745ba93ab46722f # v0.18.1
with:
pitloom-version: "0.18.1"
python-version: ${{ matrix.python-version }}
embed-wheel: "dist/*.whl"
project-path: "."
upload-artifact: "true"
artifact-name: "sbom"

- name: Check SBOM is at the PEP 770 location
id: locate-sbom
env:
SBOM_PATH: ${{ steps.pitloom.outputs.sbom-path }}
run: |
set -euo pipefail
shopt -s nullglob
wheels=(dist/*.whl)
if [ "${#wheels[@]}" -ne 1 ]; then
echo "::error::expected exactly one wheel in dist/, found" \
"${#wheels[@]}: ${wheels[*]}"
exit 1
fi
whl="${wheels[0]}"
sbom_basename=$(basename "${SBOM_PATH}")

matched=""
while IFS= read -r entry; do
case "${entry}" in
*.dist-info/sboms/"${sbom_basename}")
matched="${entry}"
break
;;
esac
done < <(unzip -Z1 "${whl}")

if [ -z "${matched}" ]; then
echo "::error::${sbom_basename} not found under" \
"*.dist-info/sboms/ in ${whl}"
exit 1
fi
echo "Found: ${matched}"
echo "wheel=${whl}" >> "${GITHUB_OUTPUT}"
echo "entry=${matched}" >> "${GITHUB_OUTPUT}"

- name: Validate SBOM extracted from the wheel
env:
WHEEL: ${{ steps.locate-sbom.outputs.wheel }}
ENTRY: ${{ steps.locate-sbom.outputs.entry }}
run: |
set -euo pipefail
extracted="$(mktemp -d)/temp-sbom.json"
unzip -p "${WHEEL}" "${ENTRY}" > "${extracted}"
spdx3-validate --json "${extracted}"

- name: Store distributions
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
path: dist

publish_pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
needs: [build]
if: github.event_name == 'release' && github.event.action == 'published'
# Only runs if "build" (including all SBOM checks) succeeded, so a
# missing, misplaced, or invalid SBOM stops publishing entirely.
if: needs.build.result == 'success' && github.event_name == 'release' && github.event.action == 'published'
permissions:
contents: read
steps:
- name: Retrieve distributions
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: artifact
path: dist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
if: github.event_name == 'release' && github.event.action == 'published'
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}

attach_release_sbom:
name: Attach SBOM to GitHub Release
needs: [build]
if: needs.build.result == 'success' && needs.build.outputs.sbom-path != '' && github.event_name == 'release' && github.event.action == 'published'
runs-on: ubuntu-latest
permissions:
contents: write # gh release upload, for the standalone SBOM asset
steps:
- name: Download SBOM artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: sbom
path: sbom
- name: Attach SBOM to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
SBOM_BASENAME: ${{ needs.build.outputs.sbom-path }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh release upload "${RELEASE_TAG}" "sbom/${SBOM_BASENAME}" \
--clobber --repo "${REPO}"

attach_release_dist:
name: Attach distribution to GitHub Release
needs: [build, publish_pypi]
if: needs.build.result == 'success' && needs.publish_pypi.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write # gh release upload, for the sdist/wheel assets
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: artifact
path: dist
- name: Attach sdist and wheel to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh release upload "${RELEASE_TAG}" dist/*.whl dist/*.tar.gz \
--clobber --repo "${REPO}"
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,31 @@
- Full release notes: <https://github.com/PyThaiNLP/pythainlp/releases>
- Commit history: <https://github.com/PyThaiNLP/pythainlp/compare/v5.3.3...v5.3.4>

## [Unreleased]

Check failure on line 20 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / Run Markdown lint

Headings should be surrounded by blank lines

CHANGELOG.md:20 MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "## [Unreleased]"] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md022.md
### Fixed

Check failure on line 21 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / Run Markdown lint

Headings should be surrounded by blank lines

CHANGELOG.md:21 MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Above] [Context: "### Fixed"] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md022.md

- `pythainlp.transliterate.romanize` with the `thai2rom` and `thai2rom_onnx`
engines: greedy decoding could get stuck in a cycle and run to the hard
100-character length cap, returning strings like
`krungtheppaaaa...aaaa` for inputs such as `กรุงเทพฯ`, `ฯลฯ`, or long
Pali/Sanskrit-derived compounds. Decoding now stops as soon as a short
output cycle repeats 3 times in a row. (issue #1403)
- `pythainlp.transliterate.thaig2p.transliterate`: found while fixing
issue #1403, the same greedy-decoding `Seq2Seq` loop as `thai2rom`
could get stuck in a repetition cycle and run to the hard 100-character
cap for the same class of inputs (e.g. `สตรีเศรษฐบุตรบำเพ็ญ`, and long
unsegmented multi-word phrases). Fixed with the same cycle-detection
guard used for `thai2rom`.

### Added

Check failure on line 36 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / Run Markdown lint

Headings should be surrounded by blank lines

CHANGELOG.md:36 MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "### Added"] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md022.md
- Add word tokenization evaluation metrics (`evaluate_word_tokenization`,

Check failure on line 37 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / Run Markdown lint

Lists should be surrounded by blank lines

CHANGELOG.md:37 MD032/blanks-around-lists Lists should be surrounded by blank lines [Context: "- Add word tokenization evalua..."] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md032.md
`word_eval_function`, `char_eval_function`,
`TokenizationScore`) from SEFR CUT (`sefr_cut.evaluation`)
to `pythainlp.benchmarks`.
- Thai G2P v4 model via Hugging Face Hub using ONNX Runtime (`thaig2p_v4`), available in `pythainlp.transliterate`.

Check failure on line 41 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / Run Markdown lint

Line length

CHANGELOG.md:41:81 MD013/line-length Line length [Expected: 80; Actual: 115] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md013.md

## Changed

Check failure on line 43 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / Run Markdown lint

Headings should be surrounded by blank lines

CHANGELOG.md:43 MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "## Changed"] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md022.md
- `pythainlp.transliterate.fastthaig2p`: Native FastThaiG2P grapheme-to-phoneme

Check failure on line 44 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / Run Markdown lint

Lists should be surrounded by blank lines

CHANGELOG.md:44 MD032/blanks-around-lists Lists should be surrounded by blank lines [Context: "- `pythainlp.transliterate.fas..."] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md032.md
conversion engine without external package dependencies. Supports text
normalization (numbers, dates, times, phone numbers, symbols, abbreviations,
maiyamok), 62k IPA dictionary lookup, and rule-based fallback. Accessible via
Expand Down
13 changes: 9 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ dependencies = [
dev = [
"bandit>=1.9.4",
"black>=25.11.0",
"build>=1.0.0",
"build>=1.6.1",
"bump-my-version>=1.2.6",
"coverage>=7.10.7",
"flake8>=7.0.0",
Expand Down Expand Up @@ -246,8 +246,8 @@ noauto-network = [
full = [
"attaparse==1.0.0",
"bpemb>=0.3.6,<0.4",
"budoux==0.7.0",
"epitran==1.26.0",
"budoux==0.9.1",
"epitran==1.35.2",
"esupar>=1.3.9,<2",
'fairseq>=0.10.0,<0.13;python_version<"3.11"',
'fairseq-fixed==0.12.3.1,<0.13;python_version>="3.11"',
Expand Down Expand Up @@ -277,7 +277,7 @@ full = [
"thai-nner==0.3",
"tltk>=1.10,<2",
"torch>=1.13.1,<3",
"transformers==5.14.1",
"transformers==5.17.0",
"ufal.chu-liu-edmonds==1.0.3",
"word2word>=1.0.0,<2",
"wtpsplit==1.3.0",
Expand Down Expand Up @@ -363,6 +363,11 @@ values = ["dev", "beta", "prod"]
[tool.coverage.run]
source = ["pythainlp"]

[[tool.pitloom.creator]]
name = "Wannaphong Phatthiyaphaibun"
email = "wannaphong@pythainlp.org"
type = "person"

[tool.ruff]
indent-width = 4
line-length = 79
Expand Down
57 changes: 57 additions & 0 deletions pythainlp/transliterate/_repetition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
"""Cycle detection for greedy seq2seq decoding.

Greedy decoding (picking the highest-probability token at each step,
with no repetition penalty or n-gram blocking) can get the decoder
stuck in a loop: the attention mechanism repeatedly attends to the
same input position and keeps emitting the same short token sequence
until the hard length limit is hit. This module detects such a cycle
as it forms so a caller can stop decoding early instead of returning
an unbounded run of repeated characters.

See: https://github.com/PyThaiNLP/pythainlp/issues/1403
"""

from __future__ import annotations

from typing import List, Optional

__all__: List[str] = ["find_trailing_repeat_period"]


def find_trailing_repeat_period(
tokens: List[int],
min_period: int = 1,
max_period: int = 12,
min_repeats: int = 3,
) -> Optional[int]:
"""Detect a short cycle repeating at the end of a token sequence.

Checks period lengths from ``min_period`` to ``max_period``
(smallest first) and returns the first period whose last
``min_repeats`` copies at the end of ``tokens`` are identical.

:param tokens: sequence of decoded token ids, in generation order
:param min_period: shortest cycle length to check, in tokens
:param max_period: longest cycle length to check, in tokens
:param min_repeats: number of consecutive copies of the cycle
required at the end of ``tokens`` to count as a repetition loop
:return: the period of the detected cycle, or None if no trailing
cycle of at least ``min_repeats`` copies is found
:rtype: Optional[int]
"""
n = len(tokens)
for period in range(min_period, max_period + 1):
window = period * min_repeats
if n < window:
continue
segment = tokens[-window:]
first = segment[:period]
if all(
segment[i * period : (i + 1) * period] == first
for i in range(1, min_repeats)
):
return period
return None
Loading
Loading