argosopentech--argos-translate
450 行
15 KiB
Python
450 行
15 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import shutil
|
|
import urllib.request
|
|
import zipfile
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
|
|
import packaging.version
|
|
|
|
from argostranslate import networking, settings
|
|
from argostranslate.tokenizer import BPETokenizer, SentencePieceTokenizer
|
|
from argostranslate.utils import error, info, warning
|
|
|
|
"""
|
|
## `package` module example usage
|
|
```
|
|
from argostranslate import package
|
|
|
|
# Update package definitions from remote
|
|
package.update_package_index()
|
|
|
|
# Load available packages from local package index
|
|
available_packages = package.get_available_packages()
|
|
|
|
# Download and install all available packages
|
|
for available_package in available_packages:
|
|
download_path = available_package.download()
|
|
package.install_from_path(download_path)
|
|
```
|
|
"""
|
|
|
|
# Threading logic
|
|
# Hold lock while installing or uninstalling packages
|
|
package_lock = Lock()
|
|
|
|
|
|
class IPackage:
|
|
"""A package, can be either installed locally or available from a remote package index.
|
|
|
|
Attributes:
|
|
package_path: The path to the installed package. None if not installed.
|
|
|
|
package_version: The version of the package.
|
|
|
|
argos_version: The version of Argos Translate the package is intended for.
|
|
|
|
from_code: The code of the language the package translates from.
|
|
|
|
from_name: Human readable name of the language the package translates from.
|
|
|
|
to_code: The code of the language the package translates to.
|
|
|
|
to_name: Human readable name of the language the package translates to.
|
|
|
|
links: A list of links to download the package
|
|
|
|
|
|
Packages are a zip archive of a directory with metadata.json
|
|
in its root the .argosmodel file extension. By default, an
|
|
OpenNMT CTranslate2 directory named model/ is expected in the root directory
|
|
along with a sentencepiece model named sentencepiece.model or a bpe.model
|
|
for tokenizing and Stanza data for sentence boundary detection.
|
|
Packages may also optionally have a README.md in the root.
|
|
|
|
from_code and to_code should be ISO 639 codes if applicable.
|
|
|
|
Example metadata.json
|
|
{
|
|
"package_version": "1.0",
|
|
"argos_version": "1.0",
|
|
"from_code": "en",
|
|
"from_name": "English",
|
|
"to_code": "es",
|
|
"to_name": "Spanish",
|
|
"links": ["https://example.com/en_es.argosmodel"]
|
|
}
|
|
"""
|
|
|
|
code: str
|
|
package_path: Path
|
|
package_version: str
|
|
argos_version: str
|
|
from_code: str
|
|
from_name: str
|
|
from_codes: list
|
|
to_code: str
|
|
to_codes: list
|
|
to_name: str
|
|
links: list
|
|
type: str
|
|
languages: list
|
|
dependencies: list
|
|
source_languages: list
|
|
target_languages: list
|
|
links: list[str]
|
|
|
|
def load_metadata_from_json(self, metadata):
|
|
"""Loads package metadata from a JSON object.
|
|
|
|
Args:
|
|
metadata: A json object from json.load
|
|
|
|
"""
|
|
self.code = metadata.get("code")
|
|
self.package_version = metadata.get("package_version", "")
|
|
self.argos_version = metadata.get("argos_version", "")
|
|
self.from_code = metadata.get("from_code")
|
|
self.from_name = metadata.get("from_name", "")
|
|
self.from_codes = metadata.get("from_codes", list())
|
|
self.to_code = metadata.get("to_code")
|
|
self.to_codes = metadata.get("to_codes", list())
|
|
self.to_name = metadata.get("to_name", "")
|
|
self.links = metadata.get("links", list())
|
|
self.type = metadata.get("type", "translate")
|
|
self.languages = metadata.get("languages", list())
|
|
self.dependencies = metadata.get("dependencies", list())
|
|
self.source_languages = metadata.get("source_languages", list())
|
|
self.target_languages = metadata.get("target_languages", list())
|
|
self.target_prefix = metadata.get("target_prefix", "")
|
|
|
|
# Add all package source and target languages to
|
|
# source_languages and target_languages
|
|
if self.from_code is not None or self.from_name is not None:
|
|
from_lang = dict()
|
|
if self.from_code is not None:
|
|
from_lang["code"] = self.from_code
|
|
if self.from_name is not None:
|
|
from_lang["name"] = self.from_name
|
|
self.source_languages.append(from_lang)
|
|
if self.to_code is not None or self.to_name is not None:
|
|
to_lang = dict()
|
|
if self.to_code is not None:
|
|
to_lang["code"] = self.to_code
|
|
if self.to_name is not None:
|
|
to_lang["name"] = self.to_name
|
|
self.source_languages.append(to_lang)
|
|
self.source_languages += copy.deepcopy(self.languages)
|
|
self.target_languages += copy.deepcopy(self.languages)
|
|
|
|
def get_readme(self) -> str:
|
|
"""Returns the text of the README.md in this package.
|
|
|
|
Returns:
|
|
The text of the package README.md, None
|
|
if README.md can't be read
|
|
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def get_description(self):
|
|
raise NotImplementedError()
|
|
|
|
def __eq__(self, other):
|
|
return (
|
|
self.package_version == other.package_version
|
|
and self.argos_version == other.argos_version
|
|
and self.from_code == other.from_code
|
|
and self.from_name == other.from_name
|
|
and self.to_code == other.to_code
|
|
and self.to_name == other.to_name
|
|
)
|
|
|
|
def __repr__(self):
|
|
if len(self.from_name) > 0 and len(self.to_name) > 0:
|
|
return "{} -> {}".format(self.from_name, self.to_name)
|
|
elif self.type:
|
|
return self.type
|
|
return ""
|
|
|
|
def __str__(self):
|
|
return repr(self).replace("->", "→")
|
|
|
|
|
|
class Package(IPackage):
|
|
"""An installed package"""
|
|
|
|
def __init__(self, package_path: Path):
|
|
"""Create a new Package from path.
|
|
|
|
Args:
|
|
package_path: Path to installed package directory.
|
|
|
|
"""
|
|
if type(package_path) == str:
|
|
# Convert strings to pathlib.Path objects
|
|
package_path = Path(package_path)
|
|
self.package_path = package_path
|
|
metadata_path = package_path / "metadata.json"
|
|
if not metadata_path.exists():
|
|
raise FileNotFoundError(
|
|
"Error opening package at " + str(metadata_path) + " no metadata.json"
|
|
)
|
|
with open(metadata_path) as metadata_file:
|
|
metadata = json.load(metadata_file)
|
|
self.load_metadata_from_json(metadata)
|
|
|
|
""" As of spacy multilingual support, the sbd package shall depend on the Argos package's content"""
|
|
minisbd_package = package_path / "minisbd"
|
|
stanza_package = package_path / "stanza"
|
|
spacy_package = package_path / "spacy"
|
|
|
|
if minisbd_package.exists(): # MiniSBD model within the package (prioritize over stanza if both are there)
|
|
self.packaged_sbd_path = minisbd_package
|
|
elif stanza_package.exists(): # Stanza tokenizer within the package
|
|
self.packaged_sbd_path = stanza_package
|
|
elif (
|
|
spacy_package.exists()
|
|
): # Explicit/language-specific spacy model within the package
|
|
self.packaged_sbd_path = spacy_package
|
|
else: # None if no sbd package embedded in the argos package (will default to cache)
|
|
self.packaged_sbd_path = None
|
|
|
|
sp_model_path = package_path / "sentencepiece.model"
|
|
bpe_model_path = package_path / "bpe.model"
|
|
|
|
if sp_model_path.exists():
|
|
self.tokenizer = SentencePieceTokenizer(sp_model_path)
|
|
elif bpe_model_path.exists():
|
|
self.tokenizer = BPETokenizer(bpe_model_path, self.from_code, self.to_code)
|
|
|
|
def update(self):
|
|
"""Update the package if a newer version is available."""
|
|
for available_package in get_available_packages():
|
|
if (
|
|
available_package.from_code == self.from_code
|
|
and available_package.to_code == self.to_code
|
|
):
|
|
if packaging.version.parse(
|
|
available_package.package_version
|
|
) > packaging.version.parse(self.package_version):
|
|
new_package_path = available_package.download()
|
|
uninstall(self)
|
|
install_from_path(new_package_path)
|
|
|
|
def get_readme(self) -> str | None:
|
|
"""Returns the text of the README.md in this package.
|
|
|
|
Returns:
|
|
The text of the package README.md, None
|
|
if README.md can't be read
|
|
|
|
"""
|
|
readme_path = self.package_path / "README.md"
|
|
if not readme_path.exists():
|
|
return None
|
|
with open(readme_path, "r") as readme_file:
|
|
return readme_file.read()
|
|
|
|
def get_description(self):
|
|
return self.get_readme()
|
|
|
|
|
|
def install_from_path(path: Path):
|
|
"""Install a package file (zip archive ending in .argosmodel).
|
|
|
|
Args:
|
|
path: The path to the .argosmodel file to install.
|
|
|
|
"""
|
|
with package_lock:
|
|
if not zipfile.is_zipfile(path):
|
|
raise Exception("Not a valid Argos Model (must be a zip archive)")
|
|
with zipfile.ZipFile(path, "r") as zipf:
|
|
zipf.extractall(path=settings.package_data_dir)
|
|
|
|
# Clear language cache after package installation
|
|
from argostranslate.translate import get_installed_languages
|
|
|
|
get_installed_languages.cache_clear()
|
|
|
|
|
|
class AvailablePackage(IPackage):
|
|
"""A package available for download and installation"""
|
|
|
|
def __init__(self, metadata):
|
|
"""Creates a new AvailablePackage from a metadata object"""
|
|
self.load_metadata_from_json(metadata)
|
|
|
|
def download(self) -> Path:
|
|
"""Downloads the AvailablePackage and returns its path"""
|
|
filename = argospm_package_name(self) + ".argosmodel"
|
|
"""
|
|
# Install sbd package if needed
|
|
if self.type == "translate" and not settings.stanza_available:
|
|
if (
|
|
len(list(filter(lambda x: x.type == "sbd", get_installed_packages())))
|
|
== 0
|
|
):
|
|
# No sbd packages are installed, download all available
|
|
sbd_packages = filter(
|
|
lambda x: x.type == "sbd", get_available_packages()
|
|
)
|
|
for sbd_package in sbd_packages:
|
|
download_path = sbd_package.download()
|
|
install_from_path(download_path)
|
|
"""
|
|
filepath = settings.downloads_dir / filename
|
|
if not filepath.exists():
|
|
data = networking.get_from(self.links)
|
|
if data is None:
|
|
raise Exception(f"Download failed for {str(self)}")
|
|
with open(filepath, "wb") as f:
|
|
f.write(data)
|
|
return filepath
|
|
|
|
def install(self):
|
|
download_path = self.download()
|
|
install_from_path(download_path)
|
|
download_path.unlink()
|
|
|
|
def get_description(self):
|
|
return "{} → {}".format(self.from_name, self.to_name)
|
|
|
|
|
|
def uninstall(pkg: Package):
|
|
"""Uninstalls a package.
|
|
|
|
Args:
|
|
pkg: The package to uninstall
|
|
|
|
"""
|
|
with package_lock:
|
|
shutil.rmtree(pkg.package_path)
|
|
|
|
# Clear language cache after package uninstallation
|
|
from argostranslate.translate import get_installed_languages
|
|
|
|
get_installed_languages.cache_clear()
|
|
|
|
|
|
def get_installed_packages(path: Path = None) -> list[Package]:
|
|
"""Return a list of installed Packages
|
|
|
|
Looks for packages in <home>/.argos-translate/local/share/packages by
|
|
default. Will also look in the directory specified
|
|
in the ARGOS_PACKAGE_DIR environment variable
|
|
if it is set.
|
|
|
|
Args:
|
|
path: Path to look for installed package directories in.
|
|
Defaults to the path in settings module.
|
|
|
|
"""
|
|
with package_lock:
|
|
to_return = []
|
|
packages_path = settings.package_dirs if path is None else path
|
|
for directory in packages_path:
|
|
for path in directory.iterdir():
|
|
if path.is_dir():
|
|
to_return.append(Package(path))
|
|
return to_return
|
|
|
|
|
|
def update_package_index():
|
|
"""Downloads remote package index"""
|
|
with package_lock:
|
|
try:
|
|
response = urllib.request.urlopen(settings.remote_package_index)
|
|
except Exception as err:
|
|
error(err)
|
|
return
|
|
data = response.read()
|
|
with open(settings.local_package_index, "wb") as f:
|
|
f.write(data)
|
|
|
|
|
|
def get_available_packages() -> list[AvailablePackage]:
|
|
"""Returns a list of AvailablePackages from the package index."""
|
|
|
|
try:
|
|
with open(settings.local_package_index) as index_file:
|
|
index = json.load(index_file)
|
|
packages = []
|
|
for metadata in index:
|
|
package = AvailablePackage(metadata)
|
|
packages.append(package)
|
|
"""
|
|
# If stanza not available filter for sbd available
|
|
if not settings.stanza_available:
|
|
installed_and_available_packages = packages + get_installed_packages()
|
|
sbd_packages = list(
|
|
filter(lambda x: x.type == "sbd", installed_and_available_packages)
|
|
)
|
|
sbd_available_codes = set()
|
|
for sbd_package in sbd_packages:
|
|
sbd_available_codes = sbd_available_codes.union(
|
|
sbd_package.from_codes
|
|
)
|
|
packages = list(
|
|
filter(lambda x: x.from_code in sbd_available_codes, packages)
|
|
)
|
|
return packages + sbd_packages
|
|
"""
|
|
return packages
|
|
except FileNotFoundError:
|
|
update_package_index()
|
|
return get_available_packages()
|
|
|
|
|
|
def argospm_package_name(pkg: IPackage) -> str:
|
|
"""Gets argospm name of an IPackage.
|
|
|
|
Args:
|
|
The package to get the name of.
|
|
|
|
Returns:
|
|
Package name for argospm
|
|
"""
|
|
to_return = pkg.type
|
|
if pkg.from_code and pkg.to_code:
|
|
to_return += "-" + pkg.from_code + "_" + pkg.to_code
|
|
return to_return
|
|
|
|
|
|
def install_package_for_language_pair(from_code: str, to_code: str) -> bool:
|
|
"""Installs the necessary package to translate between a pair of languages
|
|
|
|
Args:
|
|
from_code (str): The ISO 639 code for the language being translated from
|
|
to_code (str): The ISO 639 code for the language being translated to
|
|
|
|
Returns:
|
|
True if the package was installed successfully,
|
|
False if the installation failed or was not possible
|
|
"""
|
|
available_packages = get_available_packages()
|
|
try:
|
|
package_to_install = next(
|
|
filter(
|
|
lambda x: x.from_code == from_code and x.to_code == to_code,
|
|
available_packages,
|
|
)
|
|
)
|
|
except StopIteration:
|
|
return False
|
|
|
|
install_from_path(package_to_install.download())
|
|
return True
|
|
|
|
|
|
def load_available_packages() -> list[Package]:
|
|
"""Deprecated 1.2, use get_available_packages"""
|
|
info(
|
|
"Using deprecated function load_available_packages, use get_available_packages instead"
|
|
)
|
|
return get_available_packages()
|