PEP8 for rest of pylib (#1451)

* PEP8 dbproxy.py

* PEP8 errors.py

* PEP8 httpclient.py

* PEP8 lang.py

* PEP8 latex.py

* Add decorator to deprectate key words

* Make replacement for deprecated attribute optional

* Use new helper `_print_replacement_warning()`

* PEP8 media.py

* PEP8 rsbackend.py

* PEP8 sound.py

* PEP8 stdmodels.py

* PEP8 storage.py

* PEP8 sync.py

* PEP8 tags.py

* PEP8 template.py

* PEP8 types.py

* Fix DeprecatedNamesMixinForModule

The class methods need to be overridden with instance methods, so every
module has its own dicts.

* Use `# pylint: disable=invalid-name` instead of id

* PEP8 utils.py

* Only decorate `__getattr__` with `@no_type_check`

* Fix mypy issue with snakecase

Importing it from `anki._vendor` raises attribute errors.

* Format

* Remove inheritance of DeprecatedNamesMixin

There's almost no shared code now and overriding classmethods with
instance methods raises mypy issues.

* Fix traceback frames of deprecation warnings

* remove fn/TimedLog (dae)

Neither Anki nor add-ons appear to have been using it

* fix some issues with stringcase use (dae)

- the wheel was depending on the PyPI version instead of our vendored
version
- _vendor:stringcase should not have been listed in the anki py_library.
We already include the sources in py_srcs, and need to refer to them
directly. By listing _vendor:stringcase as well, we were making a
top-level stringcase library available, which would have only worked for
distributing because the wheel definition was also incorrect.
- mypy errors are what caused me to mistakenly add the above - they
were because the type: ignore at the top of stringcase.py was causing
mypy to completely ignore the file, so it was not aware of any attributes
it contained.
This commit is contained in:
RumovZ 2021-10-25 06:50:13 +02:00 committed by GitHub
parent 1fab547d46
commit 9dc3cf216a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 502 additions and 377 deletions

View file

@ -57,4 +57,4 @@ good-names =
tr, tr,
db, db,
ok, ok,
ip, ip,

View file

@ -49,7 +49,6 @@ py_library(
requirement("flask"), requirement("flask"),
requirement("waitress"), requirement("waitress"),
requirement("markdown"), requirement("markdown"),
"//pylib/anki/_vendor:stringcase",
] + orjson_if_available(), ] + orjson_if_available(),
) )
@ -84,7 +83,6 @@ py_wheel(
"decorator", "decorator",
"protobuf>=3.17", "protobuf>=3.17",
"markdown", "markdown",
"stringcase",
"orjson", "orjson",
'psutil; sys_platform == "win32"', 'psutil; sys_platform == "win32"',
'distro; sys_platform != "darwin" and sys_platform != "win32"', 'distro; sys_platform != "darwin" and sys_platform != "win32"',

View file

@ -66,7 +66,7 @@ class RustBackend(RustBackendGenerated):
) -> None: ) -> None:
# pick up global defaults if not provided # pick up global defaults if not provided
if langs is None: if langs is None:
langs = [anki.lang.currentLang] langs = [anki.lang.current_lang]
init_msg = backend_pb2.BackendInit( init_msg = backend_pb2.BackendInit(
preferred_langs=langs, preferred_langs=langs,

View file

@ -17,11 +17,12 @@ VariableTarget = tuple[Any, str]
DeprecatedAliasTarget = Union[Callable, VariableTarget] DeprecatedAliasTarget = Union[Callable, VariableTarget]
def _target_to_string(target: DeprecatedAliasTarget) -> str: def _target_to_string(target: DeprecatedAliasTarget | None) -> str:
if target is None:
return ""
if name := getattr(target, "__name__", None): if name := getattr(target, "__name__", None):
return name return name
else: return target[1] # type: ignore
return target[1] # type: ignore
def partial_path(full_path: str, components: int) -> str: def partial_path(full_path: str, components: int) -> str:
@ -29,14 +30,34 @@ def partial_path(full_path: str, components: int) -> str:
return os.path.join(*path.parts[-components:]) return os.path.join(*path.parts[-components:])
def print_deprecation_warning(msg: str, frame: int = 2) -> None: def print_deprecation_warning(msg: str, frame: int = 1) -> None:
path, linenum, _, _ = traceback.extract_stack(limit=5)[frame] # skip one frame to get to caller
# then by default, skip one more frame as caller themself usually wants to
# print their own caller
path, linenum, _, _ = traceback.extract_stack(limit=frame + 2)[0]
path = partial_path(path, components=3) path = partial_path(path, components=3)
print(f"{path}:{linenum}:{msg}") print(f"{path}:{linenum}:{msg}")
def _print_warning(old: str, doc: str, frame: int = 1) -> None: def _print_warning(old: str, doc: str, frame: int = 1) -> None:
return print_deprecation_warning(f"{old} is deprecated: {doc}", frame=frame) return print_deprecation_warning(f"{old} is deprecated: {doc}", frame=frame + 1)
def _print_replacement_warning(old: str, new: str, frame: int = 1) -> None:
doc = f"please use '{new}'" if new else "please implement your own"
_print_warning(old, doc, frame=frame + 1)
def _get_remapped_and_replacement(
mixin: DeprecatedNamesMixin | DeprecatedNamesMixinForModule, name: str
) -> tuple[str, str | None]:
if some_tuple := mixin._deprecated_attributes.get(name):
return some_tuple
remapped = mixin._deprecated_aliases.get(name) or stringcase.snakecase(name)
if remapped == name:
raise AttributeError
return (remapped, remapped)
class DeprecatedNamesMixin: class DeprecatedNamesMixin:
@ -45,7 +66,7 @@ class DeprecatedNamesMixin:
# deprecated name -> new name # deprecated name -> new name
_deprecated_aliases: dict[str, str] = {} _deprecated_aliases: dict[str, str] = {}
# deprecated name -> [new internal name, new name shown to user] # deprecated name -> [new internal name, new name shown to user]
_deprecated_attributes: dict[str, tuple[str, str]] = {} _deprecated_attributes: dict[str, tuple[str, str | None]] = {}
# the @no_type_check lines are required to prevent mypy allowing arbitrary # the @no_type_check lines are required to prevent mypy allowing arbitrary
# attributes on the consuming class # attributes on the consuming class
@ -53,27 +74,16 @@ class DeprecatedNamesMixin:
@no_type_check @no_type_check
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
try: try:
remapped, replacement = self._get_remapped_and_replacement(name) remapped, replacement = _get_remapped_and_replacement(self, name)
out = getattr(self, remapped) out = getattr(self, remapped)
except AttributeError: except AttributeError:
raise AttributeError( raise AttributeError(
f"'{self.__class__.__name__}' object has no attribute '{name}'" f"'{self.__class__.__name__}' object has no attribute '{name}'"
) from None ) from None
_print_warning(f"'{name}'", f"please use '{replacement}'") _print_replacement_warning(name, replacement)
return out return out
@no_type_check
def _get_remapped_and_replacement(self, name: str) -> tuple[str, str]:
if some_tuple := self._deprecated_attributes.get(name):
return some_tuple
remapped = self._deprecated_aliases.get(name) or stringcase.snakecase(name)
if remapped == name:
raise AttributeError
return (remapped, remapped)
@no_type_check
@classmethod @classmethod
def register_deprecated_aliases(cls, **kwargs: DeprecatedAliasTarget) -> None: def register_deprecated_aliases(cls, **kwargs: DeprecatedAliasTarget) -> None:
"""Manually add aliases that are not a simple transform. """Manually add aliases that are not a simple transform.
@ -84,17 +94,16 @@ class DeprecatedNamesMixin:
""" """
cls._deprecated_aliases = {k: _target_to_string(v) for k, v in kwargs.items()} cls._deprecated_aliases = {k: _target_to_string(v) for k, v in kwargs.items()}
@no_type_check
@classmethod @classmethod
def register_deprecated_attributes( def register_deprecated_attributes(
cls, cls,
**kwargs: tuple[DeprecatedAliasTarget, DeprecatedAliasTarget], **kwargs: tuple[DeprecatedAliasTarget, DeprecatedAliasTarget | None],
) -> None: ) -> None:
"""Manually add deprecated attributes without exact substitutes. """Manually add deprecated attributes without exact substitutes.
Pass a tuple of (alias, replacement), where alias is the attribute's new Pass a tuple of (alias, replacement), where alias is the attribute's new
name (by convention: snakecase, prepended with '_legacy_'), and name (by convention: snakecase, prepended with '_legacy_'), and
replacement is any callable to be used instead in new code. replacement is any callable to be used instead in new code or None.
Also note the docstring of `register_deprecated_aliases`. Also note the docstring of `register_deprecated_aliases`.
E.g. given `def oldFunc(args): return new_func(additionalLogic(args))`, E.g. given `def oldFunc(args): return new_func(additionalLogic(args))`,
@ -107,7 +116,7 @@ class DeprecatedNamesMixin:
} }
class DeprecatedNamesMixinForModule(DeprecatedNamesMixin): class DeprecatedNamesMixinForModule:
"""Provides the functionality of DeprecatedNamesMixin for modules. """Provides the functionality of DeprecatedNamesMixin for modules.
It can be invoked like this: It can be invoked like this:
@ -120,23 +129,40 @@ class DeprecatedNamesMixinForModule(DeprecatedNamesMixin):
def __getattr__(name: str) -> Any: def __getattr__(name: str) -> Any:
return _deprecated_names.__getattr__(name) return _deprecated_names.__getattr__(name)
``` ```
See DeprecatedNamesMixin for more documentation.
""" """
def __init__(self, module_globals: dict[str, Any]) -> None: def __init__(self, module_globals: dict[str, Any]) -> None:
self.module_globals = module_globals self.module_globals = module_globals
self._deprecated_aliases: dict[str, str] = {}
self._deprecated_attributes: dict[str, tuple[str, str | None]] = {}
@no_type_check
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
try: try:
remapped, replacement = self._get_remapped_and_replacement(name) remapped, replacement = _get_remapped_and_replacement(self, name)
out = self.module_globals[remapped] out = self.module_globals[remapped]
except (AttributeError, KeyError): except (AttributeError, KeyError):
raise AttributeError( raise AttributeError(
f"Module '{self.module_globals['__name__']}' has no attribute '{name}'" f"Module '{self.module_globals['__name__']}' has no attribute '{name}'"
) from None ) from None
_print_warning(f"'{name}'", f"please use '{replacement}'", frame=0) # skip an additional frame as we are called from the module `__getattr__`
_print_replacement_warning(name, replacement, frame=2)
return out return out
def register_deprecated_aliases(self, **kwargs: DeprecatedAliasTarget) -> None:
self._deprecated_aliases = {k: _target_to_string(v) for k, v in kwargs.items()}
def register_deprecated_attributes(
self,
**kwargs: tuple[DeprecatedAliasTarget, DeprecatedAliasTarget | None],
) -> None:
self._deprecated_attributes = {
k: (_target_to_string(v[0]), _target_to_string(v[1]))
for k, v in kwargs.items()
}
def deprecated(replaced_by: Callable | None = None, info: str = "") -> Callable: def deprecated(replaced_by: Callable | None = None, info: str = "") -> Callable:
"""Print a deprecation warning, telling users to use `replaced_by`, or show `doc`.""" """Print a deprecation warning, telling users to use `replaced_by`, or show `doc`."""
@ -144,15 +170,34 @@ def deprecated(replaced_by: Callable | None = None, info: str = "") -> Callable:
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@functools.wraps(func) @functools.wraps(func)
def decorated_func(*args: Any, **kwargs: Any) -> Any: def decorated_func(*args: Any, **kwargs: Any) -> Any:
if replaced_by: if info:
doc = f"please use {replaced_by.__name__} instead." _print_warning(f"{func.__name__}()", info)
else: else:
doc = info _print_replacement_warning(func.__name__, replaced_by.__name__)
_print_warning(f"{func.__name__}()", doc)
return func(*args, **kwargs) return func(*args, **kwargs)
return decorated_func return decorated_func
return decorator return decorator
def deprecated_keywords(**replaced_keys: str) -> Callable:
"""Pass `oldKey="new_key"` to map the former to the latter, if passed to the
decorated function as a key word, and print a deprecation warning.
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def decorated_func(*args: Any, **kwargs: Any) -> Any:
updated_kwargs = {}
for key, val in kwargs.items():
if replacement := replaced_keys.get(key):
_print_replacement_warning(key, replacement)
updated_kwargs[replacement or key] = val
return func(*args, **updated_kwargs)
return decorated_func
return decorator

View file

@ -1,7 +1,6 @@
# stringcase 1.2.0 with python warning fix applied # stringcase 1.2.0 with python warning fix applied
# MIT: https://github.com/okunishinishi/python-stringcase # MIT: https://github.com/okunishinishi/python-stringcase
# type: ignore
""" """
String convert functions String convert functions

View file

@ -66,9 +66,9 @@ from anki.types import assert_exhaustive
from anki.utils import ( from anki.utils import (
from_json_bytes, from_json_bytes,
ids2str, ids2str,
intTime, int_time,
splitFields, split_fields,
stripHTMLMedia, strip_html_media,
to_json_bytes, to_json_bytes,
) )
@ -292,7 +292,7 @@ class Collection(DeprecatedNamesMixin):
self.db.begin() self.db.begin()
def set_schema_modified(self) -> None: def set_schema_modified(self) -> None:
self.db.execute("update col set scm=?", intTime(1000)) self.db.execute("update col set scm=?", int_time(1000))
self.save() self.save()
def mod_schema(self, check: bool) -> None: def mod_schema(self, check: bool) -> None:
@ -578,12 +578,12 @@ class Collection(DeprecatedNamesMixin):
for nid, mid, flds in self.db.all( for nid, mid, flds in self.db.all(
f"select id, mid, flds from notes where id in {ids2str(nids)}" f"select id, mid, flds from notes where id in {ids2str(nids)}"
): ):
flds = splitFields(flds) flds = split_fields(flds)
ord = ord_for_mid(mid) ord = ord_for_mid(mid)
if ord is None: if ord is None:
continue continue
val = flds[ord] val = flds[ord]
val = stripHTMLMedia(val) val = strip_html_media(val)
# empty does not count as duplicate # empty does not count as duplicate
if not val: if not val:
continue continue
@ -982,7 +982,7 @@ class Collection(DeprecatedNamesMixin):
# restore any siblings # restore any siblings
self.db.execute( self.db.execute(
"update cards set queue=type,mod=?,usn=? where queue=-2 and nid=?", "update cards set queue=type,mod=?,usn=? where queue=-2 and nid=?",
intTime(), int_time(),
self.usn(), self.usn(),
card.nid, card.nid,
) )

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from __future__ import annotations from __future__ import annotations
import re import re
@ -51,9 +53,9 @@ class DBProxy:
**kwargs: ValueForDB, **kwargs: ValueForDB,
) -> list[Row]: ) -> list[Row]:
# mark modified? # mark modified?
s = sql.strip().lower() cananoized = sql.strip().lower()
for stmt in "insert", "update", "delete": for stmt in "insert", "update", "delete":
if s.startswith(stmt): if cananoized.startswith(stmt):
self.modified_in_python = True self.modified_in_python = True
sql, args2 = emulate_named_args(sql, args, kwargs) sql, args2 = emulate_named_args(sql, args, kwargs)
# fetch rows # fetch rows
@ -113,11 +115,11 @@ def emulate_named_args(
args2 = list(args) args2 = list(args)
for key, val in kwargs.items(): for key, val in kwargs.items():
args2.append(val) args2.append(val)
n = len(args2) number = len(args2)
arg_num[key] = n arg_num[key] = number
# update refs # update refs
def repl(m: Match) -> str: def repl(match: Match) -> str:
arg = m.group(1) arg = match.group(1)
return f"?{arg_num[arg]}" return f"?{arg_num[arg]}"
sql = re.sub(":([a-zA-Z_0-9]+)", repl, sql) sql = re.sub(":([a-zA-Z_0-9]+)", repl, sql)

View file

@ -17,7 +17,7 @@ from anki.cards import CardId
from anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId from anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId
from anki.consts import * from anki.consts import *
from anki.errors import NotFoundError from anki.errors import NotFoundError
from anki.utils import from_json_bytes, ids2str, intTime, to_json_bytes from anki.utils import from_json_bytes, ids2str, int_time, to_json_bytes
# public exports # public exports
DeckTreeNode = decks_pb2.DeckTreeNode DeckTreeNode = decks_pb2.DeckTreeNode
@ -544,7 +544,7 @@ class DeckManager(DeprecatedNamesMixin):
f"update cards set did=?,usn=?,mod=? where id in {ids2str(cids)}", f"update cards set did=?,usn=?,mod=? where id in {ids2str(cids)}",
did, did,
self.col.usn(), self.col.usn(),
intTime(), int_time(),
) )
@deprecated(replaced_by=all_names_and_ids) @deprecated(replaced_by=all_names_and_ids)

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum

View file

@ -19,7 +19,7 @@ from anki import hooks
from anki.cards import CardId from anki.cards import CardId
from anki.collection import Collection from anki.collection import Collection
from anki.decks import DeckId from anki.decks import DeckId
from anki.utils import ids2str, namedtmp, splitFields, stripHTML from anki.utils import ids2str, namedtmp, split_fields, strip_html
class Exporter: class Exporter:
@ -78,7 +78,7 @@ class Exporter:
s = text s = text
s = re.sub(r"(?i)<(br ?/?|div|p)>", " ", s) s = re.sub(r"(?i)<(br ?/?|div|p)>", " ", s)
s = re.sub(r"\[sound:[^]]+\]", "", s) s = re.sub(r"\[sound:[^]]+\]", "", s)
s = stripHTML(s) s = strip_html(s)
s = re.sub(r"[ \n\t]+", " ", s) s = re.sub(r"[ \n\t]+", " ", s)
s = s.strip() s = s.strip()
return s return s
@ -161,7 +161,7 @@ where cards.id in %s)"""
if self.includeID: if self.includeID:
row.append(str(id)) row.append(str(id))
# fields # fields
row.extend([self.processText(f) for f in splitFields(flds)]) row.extend([self.processText(f) for f in split_fields(flds)])
# tags # tags
if self.includeTags: if self.includeTags:
row.append(tags.strip()) row.append(tags.strip())
@ -280,7 +280,7 @@ class AnkiExporter(Exporter):
for row in notedata: for row in notedata:
flds = row[6] flds = row[6]
mid = row[2] mid = row[2]
for file in self.src.media.filesInStr(mid, flds): for file in self.src.media.files_in_str(mid, flds):
# skip files in subdirs # skip files in subdirs
if file != os.path.basename(file): if file != os.path.basename(file):
continue continue
@ -310,7 +310,7 @@ class AnkiExporter(Exporter):
pass pass
def removeSystemTags(self, tags: str) -> Any: def removeSystemTags(self, tags: str) -> Any:
return self.src.tags.remFromStr("marked leech", tags) return self.src.tags.rem_from_str("marked leech", tags)
def _modelHasMedia(self, model, fname) -> bool: def _modelHasMedia(self, model, fname) -> bool:
# First check the styling # First check the styling

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
""" """
Wrapper for requests that adds a callback for tracking upload/download progress. Wrapper for requests that adds a callback for tracking upload/download progress.
""" """
@ -14,12 +16,14 @@ from typing import Any, Callable
import requests import requests
from requests import Response from requests import Response
from anki._legacy import DeprecatedNamesMixin
HTTP_BUF_SIZE = 64 * 1024 HTTP_BUF_SIZE = 64 * 1024
ProgressCallback = Callable[[int, int], None] ProgressCallback = Callable[[int, int], None]
class HttpClient: class HttpClient(DeprecatedNamesMixin):
verify = True verify = True
timeout = 60 timeout = 60
@ -45,7 +49,7 @@ class HttpClient:
self.close() self.close()
def post(self, url: str, data: bytes, headers: dict[str, str] | None) -> Response: def post(self, url: str, data: bytes, headers: dict[str, str] | None) -> Response:
headers["User-Agent"] = self._agentName() headers["User-Agent"] = self._agent_name()
return self.session.post( return self.session.post(
url, url,
data=data, data=data,
@ -58,12 +62,12 @@ class HttpClient:
def get(self, url: str, headers: dict[str, str] = None) -> Response: def get(self, url: str, headers: dict[str, str] = None) -> Response:
if headers is None: if headers is None:
headers = {} headers = {}
headers["User-Agent"] = self._agentName() headers["User-Agent"] = self._agent_name()
return self.session.get( return self.session.get(
url, stream=True, headers=headers, timeout=self.timeout, verify=self.verify url, stream=True, headers=headers, timeout=self.timeout, verify=self.verify
) )
def streamContent(self, resp: Response) -> bytes: def stream_content(self, resp: Response) -> bytes:
resp.raise_for_status() resp.raise_for_status()
buf = io.BytesIO() buf = io.BytesIO()
@ -73,7 +77,7 @@ class HttpClient:
buf.write(chunk) buf.write(chunk)
return buf.getvalue() return buf.getvalue()
def _agentName(self) -> str: def _agent_name(self) -> str:
from anki.buildinfo import version from anki.buildinfo import version
return f"Anki {version}" return f"Anki {version}"

View file

@ -14,7 +14,7 @@ from anki.decks import DeckId, DeckManager
from anki.importing.base import Importer from anki.importing.base import Importer
from anki.models import NotetypeId from anki.models import NotetypeId
from anki.notes import NoteId from anki.notes import NoteId
from anki.utils import intTime, joinFields, splitFields, stripHTMLMedia from anki.utils import int_time, join_fields, split_fields, strip_html_media
GUID = 1 GUID = 1
MID = 2 MID = 2
@ -82,7 +82,7 @@ class Anki2Importer(Importer):
def _logNoteRow(self, action: str, noteRow: list[str]) -> None: def _logNoteRow(self, action: str, noteRow: list[str]) -> None:
self.log.append( self.log.append(
"[{}] {}".format(action, stripHTMLMedia(noteRow[6].replace("\x1f", ", "))) "[{}] {}".format(action, strip_html_media(noteRow[6].replace("\x1f", ", ")))
) )
def _importNotes(self) -> None: def _importNotes(self) -> None:
@ -282,7 +282,7 @@ class Anki2Importer(Importer):
# if target is a filtered deck, we'll need a new deck name # if target is a filtered deck, we'll need a new deck name
deck = self.dst.decks.by_name(name) deck = self.dst.decks.by_name(name)
if deck and deck["dyn"]: if deck and deck["dyn"]:
name = "%s %d" % (name, intTime()) name = "%s %d" % (name, int_time())
# create in local # create in local
newid = self.dst.decks.id(name) newid = self.dst.decks.id(name)
# pull conf over # pull conf over
@ -345,7 +345,7 @@ class Anki2Importer(Importer):
# update cid, nid, etc # update cid, nid, etc
card[1] = self._notes[guid][0] card[1] = self._notes[guid][0]
card[2] = self._did(card[2]) card[2] = self._did(card[2])
card[4] = intTime() card[4] = int_time()
card[5] = usn card[5] = usn
# review cards have a due date relative to collection # review cards have a due date relative to collection
if ( if (
@ -434,7 +434,7 @@ insert or ignore into revlog values (?,?,?,?,?,?,?,?,?)""",
pass pass
def _mungeMedia(self, mid: NotetypeId, fieldsStr: str) -> str: def _mungeMedia(self, mid: NotetypeId, fieldsStr: str) -> str:
fields = splitFields(fieldsStr) fields = split_fields(fieldsStr)
def repl(match): def repl(match):
fname = match.group("fname") fname = match.group("fname")
@ -459,8 +459,8 @@ insert or ignore into revlog values (?,?,?,?,?,?,?,?,?)""",
return match.group(0).replace(fname, lname) return match.group(0).replace(fname, lname)
for idx, field in enumerate(fields): for idx, field in enumerate(fields):
fields[idx] = self.dst.media.transformNames(field, repl) fields[idx] = self.dst.media.transform_names(field, repl)
return joinFields(fields) return join_fields(fields)
# Post-import cleanup # Post-import cleanup
###################################################################### ######################################################################

View file

@ -4,7 +4,7 @@
from typing import Any, Optional from typing import Any, Optional
from anki.collection import Collection from anki.collection import Collection
from anki.utils import maxID from anki.utils import max_id
# Base importer # Base importer
########################################################################## ##########################################################################
@ -41,7 +41,7 @@ class Importer:
# need to make sure our starting point is safe. # need to make sure our starting point is safe.
def _prepareTS(self) -> None: def _prepareTS(self) -> None:
self._ts = maxID(self.dst.db) self._ts = max_id(self.dst.db)
def ts(self) -> Any: def ts(self) -> Any:
self._ts += 1 self._ts += 1

View file

@ -9,7 +9,7 @@ from typing import cast
from anki.db import DB from anki.db import DB
from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter
from anki.stdmodels import addBasicModel, addClozeModel from anki.stdmodels import _legacy_add_basic_model, _legacy_add_cloze_model
class MnemosyneImporter(NoteImporter): class MnemosyneImporter(NoteImporter):
@ -132,7 +132,7 @@ acq_reps+ret_reps, lapses, card_type_id from cards"""
data.append(n) data.append(n)
# add a basic model # add a basic model
if not model: if not model:
model = addBasicModel(self.col) model = _legacy_add_basic_model(self.col)
model["name"] = "Mnemosyne-FrontOnly" model["name"] = "Mnemosyne-FrontOnly"
mm = self.col.models mm = self.col.models
mm.save(model) mm.save(model)
@ -144,7 +144,7 @@ acq_reps+ret_reps, lapses, card_type_id from cards"""
self.importNotes(data) self.importNotes(data)
def _addFrontBacks(self, notes): def _addFrontBacks(self, notes):
m = addBasicModel(self.col) m = _legacy_add_basic_model(self.col)
m["name"] = "Mnemosyne-FrontBack" m["name"] = "Mnemosyne-FrontBack"
mm = self.col.models mm = self.col.models
t = mm.new_template("Back") t = mm.new_template("Back")
@ -200,7 +200,7 @@ acq_reps+ret_reps, lapses, card_type_id from cards"""
n.cards = orig.get("cards", {}) n.cards = orig.get("cards", {})
data.append(n) data.append(n)
# add cloze model # add cloze model
model = addClozeModel(self.col) model = _legacy_add_cloze_model(self.col)
model["name"] = "Mnemosyne-Cloze" model["name"] = "Mnemosyne-Cloze"
mm = self.col.models mm = self.col.models
mm.save(model) mm.save(model)

View file

@ -16,12 +16,12 @@ from anki.importing.base import Importer
from anki.models import NotetypeId from anki.models import NotetypeId
from anki.notes import NoteId from anki.notes import NoteId
from anki.utils import ( from anki.utils import (
fieldChecksum, field_checksum,
guid64, guid64,
intTime, int_time,
joinFields, join_fields,
splitFields, split_fields,
timestampID, timestamp_id,
) )
TagMappedUpdate = tuple[int, int, str, str, NoteId, str, str] TagMappedUpdate = tuple[int, int, str, str, NoteId, str, str]
@ -135,7 +135,7 @@ class NoteImporter(Importer):
firsts: dict[str, bool] = {} firsts: dict[str, bool] = {}
fld0idx = self.mapping.index(self.model["flds"][0]["name"]) fld0idx = self.mapping.index(self.model["flds"][0]["name"])
self._fmap = self.col.models.field_map(self.model) self._fmap = self.col.models.field_map(self.model)
self._nextID = NoteId(timestampID(self.col.db, "notes")) self._nextID = NoteId(timestamp_id(self.col.db, "notes"))
# loop through the notes # loop through the notes
updates: list[Updates] = [] updates: list[Updates] = []
updateLog = [] updateLog = []
@ -158,7 +158,7 @@ class NoteImporter(Importer):
self.col.tr.importing_empty_first_field(val=" ".join(n.fields)) self.col.tr.importing_empty_first_field(val=" ".join(n.fields))
) )
continue continue
csum = fieldChecksum(fld0) csum = field_checksum(fld0)
# earlier in import? # earlier in import?
if fld0 in firsts and self.importMode != ADD_MODE: if fld0 in firsts and self.importMode != ADD_MODE:
# duplicates in source file; log and ignore # duplicates in source file; log and ignore
@ -171,7 +171,7 @@ class NoteImporter(Importer):
# csum is not a guarantee; have to check # csum is not a guarantee; have to check
for id in csums[csum]: for id in csums[csum]:
flds = self.col.db.scalar("select flds from notes where id = ?", id) flds = self.col.db.scalar("select flds from notes where id = ?", id)
sflds = splitFields(flds) sflds = split_fields(flds)
if fld0 == sflds[0]: if fld0 == sflds[0]:
# duplicate # duplicate
found = True found = True
@ -246,7 +246,7 @@ class NoteImporter(Importer):
id, id,
guid64(), guid64(),
self.model["id"], self.model["id"],
intTime(), int_time(),
self.col.usn(), self.col.usn(),
self.col.tags.join(n.tags), self.col.tags.join(n.tags),
n.fieldsStr, n.fieldsStr,
@ -273,14 +273,22 @@ class NoteImporter(Importer):
self.processFields(n, sflds) self.processFields(n, sflds)
if self._tagsMapped: if self._tagsMapped:
tags = self.col.tags.join(n.tags) tags = self.col.tags.join(n.tags)
return (intTime(), self.col.usn(), n.fieldsStr, tags, id, n.fieldsStr, tags) return (
int_time(),
self.col.usn(),
n.fieldsStr,
tags,
id,
n.fieldsStr,
tags,
)
elif self.tagModified: elif self.tagModified:
tags = self.col.db.scalar("select tags from notes where id = ?", id) tags = self.col.db.scalar("select tags from notes where id = ?", id)
tagList = self.col.tags.split(tags) + self.tagModified.split() tagList = self.col.tags.split(tags) + self.tagModified.split()
tags = self.col.tags.join(tagList) tags = self.col.tags.join(tagList)
return (intTime(), self.col.usn(), n.fieldsStr, tags, id, n.fieldsStr) return (int_time(), self.col.usn(), n.fieldsStr, tags, id, n.fieldsStr)
else: else:
return (intTime(), self.col.usn(), n.fieldsStr, id, n.fieldsStr) return (int_time(), self.col.usn(), n.fieldsStr, id, n.fieldsStr)
def addUpdates(self, rows: list[Updates]) -> None: def addUpdates(self, rows: list[Updates]) -> None:
changes = self.col.db.scalar("select total_changes()") changes = self.col.db.scalar("select total_changes()")
@ -321,7 +329,7 @@ where id = ? and flds != ?""",
else: else:
sidx = self._fmap[f][0] sidx = self._fmap[f][0]
fields[sidx] = note.fields[c] fields[sidx] = note.fields[c]
note.fieldsStr = joinFields(fields) note.fieldsStr = join_fields(fields)
# temporary fix for the following issue until we can update the code: # temporary fix for the following issue until we can update the code:
# https://forums.ankiweb.net/t/python-checksum-rust-checksum/8195/16 # https://forums.ankiweb.net/t/python-checksum-rust-checksum/8195/16
if self.col.get_config_bool(Config.Bool.NORMALIZE_NOTE_TEXT): if self.col.get_config_bool(Config.Bool.NORMALIZE_NOTE_TEXT):

View file

@ -9,7 +9,7 @@ import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter
from anki.stdmodels import addForwardReverse from anki.stdmodels import _legacy_add_forward_reverse
ONE_DAY = 60 * 60 * 24 ONE_DAY = 60 * 60 * 24
@ -21,7 +21,7 @@ class PaukerImporter(NoteImporter):
allowHTML = True allowHTML = True
def run(self): def run(self):
model = addForwardReverse(self.col) model = _legacy_add_forward_reverse(self.col)
model["name"] = "Pauker" model["name"] = "Pauker"
self.col.models.save(model, updateReqs=False) self.col.models.save(model, updateReqs=False)
self.col.models.set_current(model) self.col.models.set_current(model)

View file

@ -15,7 +15,7 @@ from xml.dom.minidom import Element, Text
from anki.collection import Collection from anki.collection import Collection
from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter
from anki.stdmodels import addBasicModel from anki.stdmodels import _legacy_add_basic_model
class SmartDict(dict): class SmartDict(dict):
@ -87,7 +87,7 @@ class SupermemoXmlImporter(NoteImporter):
"""Initialize internal varables. """Initialize internal varables.
Pameters to be exposed to GUI are stored in self.META""" Pameters to be exposed to GUI are stored in self.META"""
NoteImporter.__init__(self, col, file) NoteImporter.__init__(self, col, file)
m = addBasicModel(self.col) m = _legacy_add_basic_model(self.col)
m["name"] = "Supermemo" m["name"] = "Supermemo"
self.col.models.save(m) self.col.models.save(m)
self.initMapping() self.initMapping()

View file

@ -1,15 +1,19 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from __future__ import annotations from __future__ import annotations
import locale import locale
import re import re
import weakref import weakref
from typing import Any, no_type_check
import anki import anki
import anki._backend import anki._backend
import anki.i18n_pb2 as _pb import anki.i18n_pb2 as _pb
from anki._legacy import DeprecatedNamesMixinForModule
# public exports # public exports
TR = anki._backend.LegacyTranslationEnum TR = anki._backend.LegacyTranslationEnum
@ -138,21 +142,21 @@ def lang_to_disk_lang(lang: str) -> str:
): ):
return lang.replace("_", "-") return lang.replace("_", "-")
# other languages have the region portion stripped # other languages have the region portion stripped
m = re.match("(.*)_", lang) match = re.match("(.*)_", lang)
if m: if match:
return m.group(1) return match.group(1)
else: else:
return lang return lang
# the currently set interface language # the currently set interface language
currentLang = "en" current_lang = "en" # pylint: disable=invalid-name
# the current Fluent translation instance. Code in pylib/ should # the current Fluent translation instance. Code in pylib/ should
# not reference this, and should use col.tr instead. The global # not reference this, and should use col.tr instead. The global
# instance exists for legacy reasons, and as a convenience for the # instance exists for legacy reasons, and as a convenience for the
# Qt code. # Qt code.
current_i18n: anki._backend.RustBackend | None = None current_i18n: anki._backend.RustBackend | None = None # pylint: disable=invalid-name
tr_legacyglobal = anki._backend.Translations(None) tr_legacyglobal = anki._backend.Translations(None)
@ -161,14 +165,14 @@ def _(str: str) -> str:
return str return str
def ngettext(single: str, plural: str, n: int) -> str: def ngettext(single: str, plural: str, num: int) -> str:
print(f"ngettext() is deprecated: {plural}") print(f"ngettext() is deprecated: {plural}")
return plural return plural
def set_lang(lang: str) -> None: def set_lang(lang: str) -> None:
global currentLang, current_i18n global current_lang, current_i18n # pylint: disable=invalid-name
currentLang = lang current_lang = lang
current_i18n = anki._backend.RustBackend(langs=[lang]) current_i18n = anki._backend.RustBackend(langs=[lang])
tr_legacyglobal.backend = weakref.ref(current_i18n) tr_legacyglobal.backend = weakref.ref(current_i18n)
@ -186,19 +190,19 @@ def get_def_lang(lang: str | None = None) -> tuple[int, str]:
user_lang = compatMap[user_lang] user_lang = compatMap[user_lang]
idx = None idx = None
lang = None lang = None
en = None en_idx = None
for l in (user_lang, sys_lang): for preferred_lang in (user_lang, sys_lang):
for c, (name, code) in enumerate(langs): for lang_idx, (name, code) in enumerate(langs):
if code == "en_US": if code == "en_US":
en = c en_idx = lang_idx
if code == l: if code == preferred_lang:
idx = c idx = lang_idx
lang = l lang = preferred_lang
if idx is not None: if idx is not None:
break break
# if the specified language and the system language aren't available, revert to english # if the specified language and the system language aren't available, revert to english
if idx is None: if idx is None:
idx = en idx = en_idx
lang = "en_US" lang = "en_US"
return (idx, lang) return (idx, lang)
@ -209,9 +213,17 @@ def is_rtl(lang: str) -> bool:
# strip off unicode isolation markers from a translated string # strip off unicode isolation markers from a translated string
# for testing purposes # for testing purposes
def without_unicode_isolation(s: str) -> str: def without_unicode_isolation(string: str) -> str:
return s.replace("\u2068", "").replace("\u2069", "") return string.replace("\u2068", "").replace("\u2069", "")
def with_collapsed_whitespace(s: str) -> str: def with_collapsed_whitespace(string: str) -> str:
return re.sub(r"\s+", " ", s) return re.sub(r"\s+", " ", string)
_deprecated_names = DeprecatedNamesMixinForModule(globals())
@no_type_check
def __getattr__(name: str) -> Any:
return _deprecated_names.__getattr__(name)

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from __future__ import annotations from __future__ import annotations
import html import html
@ -25,7 +27,8 @@ svgCommands = [
["dvisvgm", "--no-fonts", "--exact", "-Z", "2", "tmp.dvi", "-o", "tmp.svg"], ["dvisvgm", "--no-fonts", "--exact", "-Z", "2", "tmp.dvi", "-o", "tmp.svg"],
] ]
build = True # if off, use existing media but don't create new # if off, use existing media but don't create new
build = True # pylint: disable=invalid-name
# add standard tex install location to osx # add standard tex install location to osx
if isMac: if isMac:
@ -135,10 +138,10 @@ def _save_latex_image(
# commands to use # commands to use
if svg: if svg:
latexCmds = svgCommands latex_cmds = svgCommands
ext = "svg" ext = "svg"
else: else:
latexCmds = pngCommands latex_cmds = pngCommands
ext = "png" ext = "png"
# write into a temp file # write into a temp file
@ -152,9 +155,9 @@ def _save_latex_image(
try: try:
# generate png/svg # generate png/svg
os.chdir(tmpdir()) os.chdir(tmpdir())
for latexCmd in latexCmds: for latex_cmd in latex_cmds:
if call(latexCmd, stdout=log, stderr=log): if call(latex_cmd, stdout=log, stderr=log):
return _errMsg(col, latexCmd[0], texpath) return _err_msg(col, latex_cmd[0], texpath)
# add to media # add to media
with open(png_or_svg, "rb") as file: with open(png_or_svg, "rb") as file:
data = file.read() data = file.read()
@ -166,12 +169,12 @@ def _save_latex_image(
log.close() log.close()
def _errMsg(col: anki.collection.Collection, type: str, texpath: str) -> Any: def _err_msg(col: anki.collection.Collection, type: str, texpath: str) -> Any:
msg = f"{col.tr.media_error_executing(val=type)}<br>" msg = f"{col.tr.media_error_executing(val=type)}<br>"
msg += f"{col.tr.media_generated_file(val=texpath)}<br>" msg += f"{col.tr.media_generated_file(val=texpath)}<br>"
try: try:
with open(namedtmp("latex_log.txt", rm=False), encoding="utf8") as f: with open(namedtmp("latex_log.txt", remove=False), encoding="utf8") as file:
log = f.read() log = file.read()
if not log: if not log:
raise Exception() raise Exception()
msg += f"<small><pre>{html.escape(log)}</pre></small>" msg += f"<small><pre>{html.escape(log)}</pre></small>"

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from __future__ import annotations from __future__ import annotations
import os import os
@ -11,12 +13,13 @@ import time
from typing import Any, Callable from typing import Any, Callable
from anki import media_pb2 from anki import media_pb2
from anki._legacy import DeprecatedNamesMixin, deprecated_keywords
from anki.consts import * from anki.consts import *
from anki.latex import render_latex, render_latex_returning_errors from anki.latex import render_latex, render_latex_returning_errors
from anki.models import NotetypeId from anki.models import NotetypeId
from anki.sound import SoundOrVideoTag from anki.sound import SoundOrVideoTag
from anki.template import av_tags_to_native from anki.template import av_tags_to_native
from anki.utils import intTime from anki.utils import int_time
def media_paths_from_col_path(col_path: str) -> tuple[str, str]: def media_paths_from_col_path(col_path: str) -> tuple[str, str]:
@ -33,7 +36,7 @@ CheckMediaResponse = media_pb2.CheckMediaResponse
# - and audio handling code # - and audio handling code
class MediaManager: class MediaManager(DeprecatedNamesMixin):
sound_regexps = [r"(?i)(\[sound:(?P<fname>[^]]+)\])"] sound_regexps = [r"(?i)(\[sound:(?P<fname>[^]]+)\])"]
html_media_regexps = [ html_media_regexps = [
@ -68,9 +71,9 @@ class MediaManager:
raise Exception("invalidTempFolder") from exc raise Exception("invalidTempFolder") from exc
def __repr__(self) -> str: def __repr__(self) -> str:
d = dict(self.__dict__) dict_ = dict(self.__dict__)
del d["col"] del dict_["col"]
return f"{super().__repr__()} {pprint.pformat(d, width=300)}" return f"{super().__repr__()} {pprint.pformat(dict_, width=300)}"
def connect(self) -> None: def connect(self) -> None:
if self.col.server: if self.col.server:
@ -122,8 +125,8 @@ class MediaManager:
"""Add basename of path to the media folder, renaming if not unique. """Add basename of path to the media folder, renaming if not unique.
Returns possibly-renamed filename.""" Returns possibly-renamed filename."""
with open(path, "rb") as f: with open(path, "rb") as file:
return self.write_data(os.path.basename(path), f.read()) return self.write_data(os.path.basename(path), file.read())
def write_data(self, desired_fname: str, data: bytes) -> str: def write_data(self, desired_fname: str, data: bytes) -> str:
"""Write the file to the media folder, renaming if not unique. """Write the file to the media folder, renaming if not unique.
@ -155,10 +158,11 @@ class MediaManager:
# String manipulation # String manipulation
########################################################################## ##########################################################################
def filesInStr( @deprecated_keywords(includeRemote="include_remote")
self, mid: NotetypeId, string: str, includeRemote: bool = False def files_in_str(
self, mid: NotetypeId, string: str, include_remote: bool = False
) -> list[str]: ) -> list[str]:
l = [] files = []
model = self.col.models.get(mid) model = self.col.models.get(mid)
# handle latex # handle latex
string = render_latex(string, model, self.col) string = render_latex(string, model, self.col)
@ -166,12 +170,12 @@ class MediaManager:
for reg in self.regexps: for reg in self.regexps:
for match in re.finditer(reg, string): for match in re.finditer(reg, string):
fname = match.group("fname") fname = match.group("fname")
isLocal = not re.match("(https?|ftp)://", fname.lower()) is_local = not re.match("(https?|ftp)://", fname.lower())
if isLocal or includeRemote: if is_local or include_remote:
l.append(fname) files.append(fname)
return l return files
def transformNames(self, txt: str, func: Callable) -> Any: def transform_names(self, txt: str, func: Callable) -> Any:
for reg in self.regexps: for reg in self.regexps:
txt = re.sub(reg, func, txt) txt = re.sub(reg, func, txt)
return txt return txt
@ -182,7 +186,7 @@ class MediaManager:
txt = re.sub(reg, "", txt) txt = re.sub(reg, "", txt)
return txt return txt
def escapeImages(self, string: str, unescape: bool = False) -> str: def escape_images(self, string: str, unescape: bool = False) -> str:
"escape_media_filenames alias for compatibility with add-ons." "escape_media_filenames alias for compatibility with add-ons."
return self.escape_media_filenames(string, unescape) return self.escape_media_filenames(string, unescape)
@ -229,7 +233,7 @@ class MediaManager:
checked += 1 checked += 1
elap = time.time() - last_progress elap = time.time() - last_progress
if elap >= 0.3 and progress_cb is not None: if elap >= 0.3 and progress_cb is not None:
last_progress = intTime() last_progress = int_time()
if not progress_cb(checked): if not progress_cb(checked):
return None return None
@ -240,28 +244,35 @@ class MediaManager:
_illegalCharReg = re.compile(r'[][><:"/?*^\\|\0\r\n]') _illegalCharReg = re.compile(r'[][><:"/?*^\\|\0\r\n]')
def stripIllegal(self, str: str) -> str: def _legacy_strip_illegal(self, str: str) -> str:
# currently used by ankiconnect # currently used by ankiconnect
print("stripIllegal() will go away")
return re.sub(self._illegalCharReg, "", str) return re.sub(self._illegalCharReg, "", str)
def hasIllegal(self, s: str) -> bool: def _legacy_has_illegal(self, string: str) -> bool:
print("hasIllegal() will go away") if re.search(self._illegalCharReg, string):
if re.search(self._illegalCharReg, s):
return True return True
try: try:
s.encode(sys.getfilesystemencoding()) string.encode(sys.getfilesystemencoding())
except UnicodeEncodeError: except UnicodeEncodeError:
return True return True
return False return False
def findChanges(self) -> None: def _legacy_find_changes(self) -> None:
pass pass
addFile = add_file @deprecated_keywords(typeHint="type_hint")
def _legacy_write_data(
def writeData(self, opath: str, data: bytes, typeHint: str | None = None) -> str: self, opath: str, data: bytes, type_hint: str | None = None
) -> str:
fname = os.path.basename(opath) fname = os.path.basename(opath)
if typeHint: if type_hint:
fname = self.add_extension_based_on_mime(fname, typeHint) fname = self.add_extension_based_on_mime(fname, type_hint)
return self.write_data(fname, data) return self.write_data(fname, data)
MediaManager.register_deprecated_attributes(
stripIllegal=(MediaManager._legacy_strip_illegal, None),
hasIllegal=(MediaManager._legacy_has_illegal, None),
findChanges=(MediaManager._legacy_find_changes, None),
writeData=(MediaManager._legacy_write_data, MediaManager.write_data),
)

View file

@ -13,7 +13,7 @@ from anki import hooks, notes_pb2
from anki._legacy import DeprecatedNamesMixin from anki._legacy import DeprecatedNamesMixin
from anki.consts import MODEL_STD from anki.consts import MODEL_STD
from anki.models import NotetypeDict, NotetypeId, TemplateDict from anki.models import NotetypeDict, NotetypeId, TemplateDict
from anki.utils import joinFields from anki.utils import join_fields
DuplicateOrEmptyResult = notes_pb2.NoteFieldsCheckResponse.State DuplicateOrEmptyResult = notes_pb2.NoteFieldsCheckResponse.State
NoteFieldsCheckResult = notes_pb2.NoteFieldsCheckResponse.State NoteFieldsCheckResult = notes_pb2.NoteFieldsCheckResponse.State
@ -84,7 +84,7 @@ class Note(DeprecatedNamesMixin):
) )
def joined_fields(self) -> str: def joined_fields(self) -> str:
return joinFields(self.fields) return join_fields(self.fields)
def ephemeral_card( def ephemeral_card(
self, self,
@ -164,7 +164,7 @@ class Note(DeprecatedNamesMixin):
################################################## ##################################################
def has_tag(self, tag: str) -> bool: def has_tag(self, tag: str) -> bool:
return self.col.tags.inList(tag, self.tags) return self.col.tags.in_list(tag, self.tags)
def remove_tag(self, tag: str) -> None: def remove_tag(self, tag: str) -> None:
rem = [] rem = []
@ -179,7 +179,7 @@ class Note(DeprecatedNamesMixin):
self.tags.append(tag) self.tags.append(tag)
def string_tags(self) -> Any: def string_tags(self) -> Any:
return self.col.tags.join(self.col.tags.canonify(self.tags)) return self.col.tags.join(self.tags)
def set_tags_from_str(self, tags: str) -> None: def set_tags_from_str(self, tags: str) -> None:
self.tags = self.col.tags.split(tags) self.tags = self.col.tags.split(tags)

View file

@ -6,6 +6,7 @@
# future. # future.
# #
# pylint: disable=unused-import # pylint: disable=unused-import
# pylint: enable=invalid-name
from anki.decks import DeckTreeNode from anki.decks import DeckTreeNode
from anki.errors import InvalidInput, NotFoundError from anki.errors import InvalidInput, NotFoundError

View file

@ -25,7 +25,7 @@ from anki.cards import CardId
from anki.consts import CARD_TYPE_NEW, NEW_CARDS_RANDOM, QUEUE_TYPE_NEW, QUEUE_TYPE_REV from anki.consts import CARD_TYPE_NEW, NEW_CARDS_RANDOM, QUEUE_TYPE_NEW, QUEUE_TYPE_REV
from anki.decks import DeckConfigDict, DeckId, DeckTreeNode from anki.decks import DeckConfigDict, DeckId, DeckTreeNode
from anki.notes import NoteId from anki.notes import NoteId
from anki.utils import ids2str, intTime from anki.utils import ids2str, int_time
class SchedulerBase(DeprecatedNamesMixin): class SchedulerBase(DeprecatedNamesMixin):
@ -52,7 +52,7 @@ class SchedulerBase(DeprecatedNamesMixin):
def deck_due_tree(self, top_deck_id: int = 0) -> DeckTreeNode: def deck_due_tree(self, top_deck_id: int = 0) -> DeckTreeNode:
"""Returns a tree of decks with counts. """Returns a tree of decks with counts.
If top_deck_id provided, counts are limited to that node.""" If top_deck_id provided, counts are limited to that node."""
return self.col._backend.deck_tree(top_deck_id=top_deck_id, now=intTime()) return self.col._backend.deck_tree(top_deck_id=top_deck_id, now=int_time())
# Deck finished state & custom study # Deck finished state & custom study
########################################################################## ##########################################################################

View file

@ -14,7 +14,7 @@ from anki import hooks
from anki.cards import Card from anki.cards import Card
from anki.consts import * from anki.consts import *
from anki.decks import DeckId from anki.decks import DeckId
from anki.utils import ids2str, intTime from anki.utils import ids2str, int_time
from .v2 import QueueConfig from .v2 import QueueConfig
from .v2 import Scheduler as V2 from .v2 import Scheduler as V2
@ -88,7 +88,7 @@ class Scheduler(V2):
milliseconds_delta=+card.time_taken(), milliseconds_delta=+card.time_taken(),
) )
card.mod = intTime() card.mod = int_time()
card.usn = self.col.usn() card.usn = self.col.usn()
card.flush() card.flush()
@ -389,7 +389,7 @@ due = odue, queue = {QUEUE_TYPE_REV}, mod = %d, usn = %d, odue = 0
where queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN}) and type = {CARD_TYPE_REV} where queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN}) and type = {CARD_TYPE_REV}
%s %s
""" """
% (intTime(), self.col.usn(), extra) % (int_time(), self.col.usn(), extra)
) )
# new cards in learning # new cards in learning
self.forgetCards( self.forgetCards(
@ -406,7 +406,7 @@ where queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN}) and type = {CAR
select sum(left/1000) from select sum(left/1000) from
(select left from cards where did = ? and queue = {QUEUE_TYPE_LRN} and due < ? limit ?)""", (select left from cards where did = ? and queue = {QUEUE_TYPE_LRN} and due < ? limit ?)""",
did, did,
intTime() + self.col.conf["collapseTime"], int_time() + self.col.conf["collapseTime"],
self.reportLimit, self.reportLimit,
) )
or 0 or 0

View file

@ -17,7 +17,7 @@ from anki.consts import *
from anki.decks import DeckConfigDict, DeckDict, DeckId from anki.decks import DeckConfigDict, DeckDict, DeckId
from anki.lang import FormatTimeSpan from anki.lang import FormatTimeSpan
from anki.scheduler.legacy import SchedulerBaseWithLegacy from anki.scheduler.legacy import SchedulerBaseWithLegacy
from anki.utils import ids2str, intTime from anki.utils import ids2str, int_time
CountsForDeckToday = scheduler_pb2.CountsForDeckTodayResponse CountsForDeckToday = scheduler_pb2.CountsForDeckTodayResponse
SchedTimingToday = scheduler_pb2.SchedTimingTodayResponse SchedTimingToday = scheduler_pb2.SchedTimingTodayResponse
@ -269,7 +269,7 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_NEW} limit ?)"""
# scan for any newly due learning cards every minute # scan for any newly due learning cards every minute
def _updateLrnCutoff(self, force: bool) -> bool: def _updateLrnCutoff(self, force: bool) -> bool:
nextCutoff = intTime() + self.col.conf["collapseTime"] nextCutoff = int_time() + self.col.conf["collapseTime"]
if nextCutoff - self._lrnCutoff > 60 or force: if nextCutoff - self._lrnCutoff > 60 or force:
self._lrnCutoff = nextCutoff self._lrnCutoff = nextCutoff
return True return True
@ -320,7 +320,7 @@ select count() from cards where did in %s and queue = {QUEUE_TYPE_PREVIEW}
return False return False
if self._lrnQueue: if self._lrnQueue:
return True return True
cutoff = intTime() + self.col.conf["collapseTime"] cutoff = int_time() + self.col.conf["collapseTime"]
self._lrnQueue = self.col.db.all( # type: ignore self._lrnQueue = self.col.db.all( # type: ignore
f""" f"""
select due, id from cards where select due, id from cards where
@ -457,7 +457,7 @@ limit ?"""
self._answerCard(card, ease) self._answerCard(card, ease)
card.mod = intTime() card.mod = int_time()
card.usn = self.col.usn() card.usn = self.col.usn()
card.flush() card.flush()
@ -615,7 +615,7 @@ limit ?"""
fuzz = random.randrange(0, max(1, maxExtra)) fuzz = random.randrange(0, max(1, maxExtra))
card.due = min(self.day_cutoff - 1, card.due + fuzz) card.due = min(self.day_cutoff - 1, card.due + fuzz)
card.queue = QUEUE_TYPE_LRN card.queue = QUEUE_TYPE_LRN
if card.due < (intTime() + self.col.conf["collapseTime"]): if card.due < (int_time() + self.col.conf["collapseTime"]):
self.lrnCount += 1 self.lrnCount += 1
# if the queue is not empty and there's nothing else to do, make # if the queue is not empty and there's nothing else to do, make
# sure we don't put it at the head of the queue and end up showing # sure we don't put it at the head of the queue and end up showing
@ -696,7 +696,7 @@ limit ?"""
) -> int: ) -> int:
"The number of steps that can be completed by the day cutoff." "The number of steps that can be completed by the day cutoff."
if not now: if not now:
now = intTime() now = int_time()
delays = delays[-left:] delays = delays[-left:]
ok = 0 ok = 0
for idx, delay in enumerate(delays): for idx, delay in enumerate(delays):
@ -777,7 +777,7 @@ limit ?"""
if ease == BUTTON_ONE: if ease == BUTTON_ONE:
# repeat after delay # repeat after delay
card.queue = QUEUE_TYPE_PREVIEW card.queue = QUEUE_TYPE_PREVIEW
card.due = intTime() + self._previewDelay(card) card.due = int_time() + self._previewDelay(card)
self.lrnCount += 1 self.lrnCount += 1
else: else:
# BUTTON_TWO # BUTTON_TWO

View file

@ -22,7 +22,7 @@ from anki.decks import DeckId
from anki.errors import DBError from anki.errors import DBError
from anki.scheduler.legacy import SchedulerBaseWithLegacy from anki.scheduler.legacy import SchedulerBaseWithLegacy
from anki.types import assert_exhaustive from anki.types import assert_exhaustive
from anki.utils import intTime from anki.utils import int_time
QueuedCards = scheduler_pb2.QueuedCards QueuedCards = scheduler_pb2.QueuedCards
SchedulingState = scheduler_pb2.SchedulingState SchedulingState = scheduler_pb2.SchedulingState
@ -77,7 +77,7 @@ class Scheduler(SchedulerBaseWithLegacy):
current_state=states.current, current_state=states.current,
new_state=new_state, new_state=new_state,
rating=rating, rating=rating,
answered_at_millis=intTime(1000), answered_at_millis=int_time(1000),
milliseconds_taken=card.time_taken(), milliseconds_taken=card.time_taken(),
) )

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
""" """
Sound/TTS references extracted from card text. Sound/TTS references extracted from card text.

View file

@ -1,13 +1,16 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from __future__ import annotations from __future__ import annotations
from typing import Any, Callable from typing import Any, Callable, no_type_check
import anki.collection import anki.collection
import anki.models import anki.models
from anki import notetypes_pb2 from anki import notetypes_pb2
from anki._legacy import DeprecatedNamesMixinForModule
from anki.utils import from_json_bytes from anki.utils import from_json_bytes
# pylint: disable=no-member # pylint: disable=no-member
@ -38,14 +41,14 @@ def get_stock_notetypes(
StockNotetypeKind.BASIC_OPTIONAL_REVERSED, StockNotetypeKind.BASIC_OPTIONAL_REVERSED,
StockNotetypeKind.CLOZE, StockNotetypeKind.CLOZE,
]: ]:
m = from_json_bytes(col._backend.get_stock_notetype_legacy(kind)) note_type = from_json_bytes(col._backend.get_stock_notetype_legacy(kind))
def instance_getter( def instance_getter(
model: Any, model: Any,
) -> Callable[[anki.collection.Collection], anki.models.NotetypeDict]: ) -> Callable[[anki.collection.Collection], anki.models.NotetypeDict]:
return lambda col: model return lambda col: model
out.append((m["name"], instance_getter(m))) out.append((note_type["name"], instance_getter(note_type)))
# add extras from add-ons # add extras from add-ons
for (name_or_func, func) in models: for (name_or_func, func) in models:
if not isinstance(name_or_func, str): if not isinstance(name_or_func, str):
@ -61,33 +64,59 @@ def get_stock_notetypes(
# #
def addBasicModel(col: anki.collection.Collection) -> anki.models.NotetypeDict: def _legacy_add_basic_model(
nt = _get_stock_notetype(col, StockNotetypeKind.BASIC)
col.models.add(nt)
return nt
def addBasicTypingModel(col: anki.collection.Collection) -> anki.models.NotetypeDict:
nt = _get_stock_notetype(col, StockNotetypeKind.BASIC_TYPING)
col.models.add(nt)
return nt
def addForwardReverse(col: anki.collection.Collection) -> anki.models.NotetypeDict:
nt = _get_stock_notetype(col, StockNotetypeKind.BASIC_AND_REVERSED)
col.models.add(nt)
return nt
def addForwardOptionalReverse(
col: anki.collection.Collection, col: anki.collection.Collection,
) -> anki.models.NotetypeDict: ) -> anki.models.NotetypeDict:
nt = _get_stock_notetype(col, StockNotetypeKind.BASIC_OPTIONAL_REVERSED) note_type = _get_stock_notetype(col, StockNotetypeKind.BASIC)
col.models.add(nt) col.models.add(note_type)
return nt return note_type
def addClozeModel(col: anki.collection.Collection) -> anki.models.NotetypeDict: def _legacy_add_basic_typing_model(
nt = _get_stock_notetype(col, StockNotetypeKind.CLOZE) col: anki.collection.Collection,
col.models.add(nt) ) -> anki.models.NotetypeDict:
return nt note_type = _get_stock_notetype(col, StockNotetypeKind.BASIC_TYPING)
col.models.add(note_type)
return note_type
def _legacy_add_forward_reverse(
col: anki.collection.Collection,
) -> anki.models.NotetypeDict:
note_type = _get_stock_notetype(col, StockNotetypeKind.BASIC_AND_REVERSED)
col.models.add(note_type)
return note_type
def _legacy_add_forward_optional_reverse(
col: anki.collection.Collection,
) -> anki.models.NotetypeDict:
note_type = _get_stock_notetype(col, StockNotetypeKind.BASIC_OPTIONAL_REVERSED)
col.models.add(note_type)
return note_type
def _legacy_add_cloze_model(
col: anki.collection.Collection,
) -> anki.models.NotetypeDict:
note_type = _get_stock_notetype(col, StockNotetypeKind.CLOZE)
col.models.add(note_type)
return note_type
_deprecated_names = DeprecatedNamesMixinForModule(globals())
_deprecated_names.register_deprecated_attributes(
addBasicModel=(_legacy_add_basic_model, get_stock_notetypes),
addBasicTypingModel=(_legacy_add_basic_typing_model, get_stock_notetypes),
addForwardReverse=(_legacy_add_forward_reverse, get_stock_notetypes),
addForwardOptionalReverse=(
_legacy_add_forward_optional_reverse,
get_stock_notetypes,
),
addClozeModel=(_legacy_add_cloze_model, get_stock_notetypes),
)
@no_type_check
def __getattr__(name: str) -> Any:
return _deprecated_names.__getattr__(name)

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
# Legacy code expects to find Collection in this module. # Legacy code expects to find Collection in this module.
from anki.collection import Collection from anki.collection import Collection

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from anki import sync_pb2 from anki import sync_pb2
# public exports # public exports

View file

@ -171,7 +171,7 @@ def col_path() -> str:
def serve() -> None: def serve() -> None:
global col # pylint: disable=C0103 global col # pylint: disable=invalid-name
col = Collection(col_path(), server=True) col = Collection(col_path(), server=True)
# don't hold an outer transaction open # don't hold an outer transaction open

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
""" """
Anki maintains a cache of used tags so it can quickly present a list of tags Anki maintains a cache of used tags so it can quickly present a list of tags
for autocomplete and in the browser. For efficiency, deletions are not for autocomplete and in the browser. For efficiency, deletions are not
@ -18,6 +20,7 @@ from typing import Collection, Match, Sequence
import anki # pylint: disable=unused-import import anki # pylint: disable=unused-import
import anki.collection import anki.collection
from anki import tags_pb2 from anki import tags_pb2
from anki._legacy import DeprecatedNamesMixin, deprecated
from anki.collection import OpChanges, OpChangesWithCount from anki.collection import OpChanges, OpChangesWithCount
from anki.decks import DeckId from anki.decks import DeckId
from anki.notes import NoteId from anki.notes import NoteId
@ -28,7 +31,7 @@ TagTreeNode = tags_pb2.TagTreeNode
MARKED_TAG = "marked" MARKED_TAG = "marked"
class TagManager: class TagManager(DeprecatedNamesMixin):
def __init__(self, col: anki.collection.Collection) -> None: def __init__(self, col: anki.collection.Collection) -> None:
self.col = col.weakref() self.col = col.weakref()
@ -37,9 +40,9 @@ class TagManager:
return list(self.col._backend.all_tags()) return list(self.col._backend.all_tags())
def __repr__(self) -> str: def __repr__(self) -> str:
d = dict(self.__dict__) dict_ = dict(self.__dict__)
del d["col"] del dict_["col"]
return f"{super().__repr__()} {pprint.pformat(d, width=300)}" return f"{super().__repr__()} {pprint.pformat(dict_, width=300)}"
def tree(self) -> TagTreeNode: def tree(self) -> TagTreeNode:
return self.col._backend.tag_tree() return self.col._backend.tag_tree()
@ -50,7 +53,7 @@ class TagManager:
def clear_unused_tags(self) -> OpChangesWithCount: def clear_unused_tags(self) -> OpChangesWithCount:
return self.col._backend.clear_unused_tags() return self.col._backend.clear_unused_tags()
def byDeck(self, did: DeckId, children: bool = False) -> list[str]: def by_deck(self, did: DeckId, children: bool = False) -> list[str]:
basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id" basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id"
if not children: if not children:
query = f"{basequery} AND c.did=?" query = f"{basequery} AND c.did=?"
@ -133,48 +136,40 @@ class TagManager:
return "" return ""
return f" {' '.join(tags)} " return f" {' '.join(tags)} "
def addToStr(self, addtags: str, tags: str) -> str: def rem_from_str(self, deltags: str, tags: str) -> str:
"Add tags if they don't exist, and canonify."
currentTags = self.split(tags)
for tag in self.split(addtags):
if not self.inList(tag, currentTags):
currentTags.append(tag)
return self.join(self.canonify(currentTags))
def remFromStr(self, deltags: str, tags: str) -> str:
"Delete tags if they exist." "Delete tags if they exist."
def wildcard(pat: str, repl: str) -> Match: def wildcard(pat: str, repl: str) -> Match:
pat = re.escape(pat).replace("\\*", ".*") pat = re.escape(pat).replace("\\*", ".*")
return re.match(f"^{pat}$", repl, re.IGNORECASE) return re.match(f"^{pat}$", repl, re.IGNORECASE)
currentTags = self.split(tags) current_tags = self.split(tags)
for tag in self.split(deltags): for del_tag in self.split(deltags):
# find tags, ignoring case # find tags, ignoring case
remove = [] remove = []
for tx in currentTags: for cur_tag in current_tags:
if (tag.lower() == tx.lower()) or wildcard(tag, tx): if (del_tag.lower() == cur_tag.lower()) or wildcard(del_tag, cur_tag):
remove.append(tx) remove.append(cur_tag)
# remove them # remove them
for r in remove: for rem in remove:
currentTags.remove(r) current_tags.remove(rem)
return self.join(currentTags) return self.join(current_tags)
# List-based utilities # List-based utilities
########################################################################## ##########################################################################
# this is now a no-op - the tags are canonified when the note is saved @deprecated(info="no-op - tags are now canonified when note is saved")
def canonify(self, tagList: list[str]) -> list[str]: def canonify(self, tag_list: list[str]) -> list[str]:
return tagList return tag_list
def inList(self, tag: str, tags: list[str]) -> bool: def in_list(self, tag: str, tags: list[str]) -> bool:
"True if TAG is in TAGS. Ignore case." "True if TAG is in TAGS. Ignore case."
return tag.lower() in [t.lower() for t in tags] return tag.lower() in [t.lower() for t in tags]
# legacy # legacy
########################################################################## ##########################################################################
def registerNotes(self, nids: list[int] | None = None) -> None: def _legacy_register_notes(self, nids: list[int] | None = None) -> None:
self.clear_unused_tags() self.clear_unused_tags()
def register( def register(
@ -182,12 +177,19 @@ class TagManager:
) -> None: ) -> None:
print("tags.register() is deprecated and no longer works") print("tags.register() is deprecated and no longer works")
def bulkAdd(self, ids: list[NoteId], tags: str, add: bool = True) -> None: def _legacy_bulk_add(self, ids: list[NoteId], tags: str, add: bool = True) -> None:
"Add tags in bulk. TAGS is space-separated." "Add tags in bulk. TAGS is space-separated."
if add: if add:
self.bulk_add(ids, tags) self.bulk_add(ids, tags)
else: else:
self.bulk_remove(ids, tags) self.bulk_remove(ids, tags)
def bulkRem(self, ids: list[NoteId], tags: str) -> None: def _legacy_bulk_rem(self, ids: list[NoteId], tags: str) -> None:
self.bulkAdd(ids, tags, False) self._legacy_bulk_add(ids, tags, False)
TagManager.register_deprecated_attributes(
registerNotes=(TagManager._legacy_register_notes, TagManager.clear_unused_tags),
bulkAdd=(TagManager._legacy_bulk_add, TagManager.bulk_add),
bulkRem=(TagManager._legacy_bulk_rem, TagManager.bulk_remove),
)

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
""" """
This file contains the Python portion of the template rendering code. This file contains the Python portion of the template rendering code.
@ -215,10 +217,10 @@ class TemplateRenderContext:
def render(self) -> TemplateRenderOutput: def render(self) -> TemplateRenderOutput:
try: try:
partial = self._partially_render() partial = self._partially_render()
except TemplateError as e: except TemplateError as error:
return TemplateRenderOutput( return TemplateRenderOutput(
question_text=str(e), question_text=str(error),
answer_text=str(e), answer_text=str(error),
question_av_tags=[], question_av_tags=[],
answer_av_tags=[], answer_av_tags=[],
) )
@ -284,12 +286,12 @@ class TemplateRenderOutput:
def templates_for_card(card: Card, browser: bool) -> tuple[str, str]: def templates_for_card(card: Card, browser: bool) -> tuple[str, str]:
template = card.template() template = card.template()
if browser: if browser:
q, a = template.get("bqfmt"), template.get("bafmt") question, answer = template.get("bqfmt"), template.get("bafmt")
else: else:
q, a = None, None question, answer = None, None
q = q or template.get("qfmt") question = question or template.get("qfmt")
a = a or template.get("afmt") answer = answer or template.get("afmt")
return q, a # type: ignore return question, answer # type: ignore
def apply_custom_filters( def apply_custom_filters(

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from typing import NoReturn from typing import NoReturn

View file

@ -1,6 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from __future__ import annotations from __future__ import annotations
import json as _json import json as _json
@ -14,11 +16,11 @@ import subprocess
import sys import sys
import tempfile import tempfile
import time import time
import traceback
from contextlib import contextmanager from contextlib import contextmanager
from hashlib import sha1 from hashlib import sha1
from typing import Any, Iterable, Iterator from typing import Any, Iterable, Iterator, no_type_check
from anki._legacy import DeprecatedNamesMixinForModule
from anki.dbproxy import DBProxy from anki.dbproxy import DBProxy
_tmpdir: str | None _tmpdir: str | None
@ -35,19 +37,11 @@ except:
from_json_bytes = _json.loads from_json_bytes = _json.loads
def __getattr__(name: str) -> Any:
if name == "json":
traceback.print_stack(file=sys.stdout)
print("add-on should import json directly, not from anki.utils")
return _json
raise AttributeError(f"module {__name__} has no attribute {name}")
# Time handling # Time handling
############################################################################## ##############################################################################
def intTime(scale: int = 1) -> int: def int_time(scale: int = 1) -> int:
"The time in integer seconds. Pass scale=1000 to get milliseconds." "The time in integer seconds. Pass scale=1000 to get milliseconds."
return int(time.time() * scale) return int(time.time() * scale)
@ -56,33 +50,33 @@ def intTime(scale: int = 1) -> int:
############################################################################## ##############################################################################
def stripHTML(s: str) -> str: def strip_html(txt: str) -> str:
import anki.lang import anki.lang
from anki.collection import StripHtmlMode from anki.collection import StripHtmlMode
return anki.lang.current_i18n.strip_html(text=s, mode=StripHtmlMode.NORMAL) return anki.lang.current_i18n.strip_html(text=txt, mode=StripHtmlMode.NORMAL)
def stripHTMLMedia(s: str) -> str: def strip_html_media(txt: str) -> str:
"Strip HTML but keep media filenames" "Strip HTML but keep media filenames"
import anki.lang import anki.lang
from anki.collection import StripHtmlMode from anki.collection import StripHtmlMode
return anki.lang.current_i18n.strip_html( return anki.lang.current_i18n.strip_html(
text=s, mode=StripHtmlMode.PRESERVE_MEDIA_FILENAMES text=txt, mode=StripHtmlMode.PRESERVE_MEDIA_FILENAMES
) )
def htmlToTextLine(s: str) -> str: def html_to_text_line(txt: str) -> str:
s = s.replace("<br>", " ") txt = txt.replace("<br>", " ")
s = s.replace("<br />", " ") txt = txt.replace("<br />", " ")
s = s.replace("<div>", " ") txt = txt.replace("<div>", " ")
s = s.replace("\n", " ") txt = txt.replace("\n", " ")
s = re.sub(r"\[sound:[^]]+\]", "", s) txt = re.sub(r"\[sound:[^]]+\]", "", txt)
s = re.sub(r"\[\[type:[^]]+\]\]", "", s) txt = re.sub(r"\[\[type:[^]]+\]\]", "", txt)
s = stripHTMLMedia(s) txt = strip_html_media(txt)
s = s.strip() txt = txt.strip()
return s return txt
# IDs # IDs
@ -94,19 +88,19 @@ def ids2str(ids: Iterable[int | str]) -> str:
return f"({','.join(str(i) for i in ids)})" return f"({','.join(str(i) for i in ids)})"
def timestampID(db: DBProxy, table: str) -> int: def timestamp_id(db: DBProxy, table: str) -> int:
"Return a non-conflicting timestamp for table." "Return a non-conflicting timestamp for table."
# be careful not to create multiple objects without flushing them, or they # be careful not to create multiple objects without flushing them, or they
# may share an ID. # may share an ID.
t = intTime(1000) timestamp = int_time(1000)
while db.scalar(f"select id from {table} where id = ?", t): while db.scalar(f"select id from {table} where id = ?", timestamp):
t += 1 timestamp += 1
return t return timestamp
def maxID(db: DBProxy) -> int: def max_id(db: DBProxy) -> int:
"Return the first safe ID to use." "Return the first safe ID to use."
now = intTime(1000) now = int_time(1000)
for tbl in "cards", "notes": for tbl in "cards", "notes":
now = max(now, db.scalar(f"select max(id) from {tbl}") or 0) now = max(now, db.scalar(f"select max(id) from {tbl}") or 0)
return now + 1 return now + 1
@ -114,21 +108,20 @@ def maxID(db: DBProxy) -> int:
# used in ankiweb # used in ankiweb
def base62(num: int, extra: str = "") -> str: def base62(num: int, extra: str = "") -> str:
s = string table = string.ascii_letters + string.digits + extra
table = s.ascii_letters + s.digits + extra
buf = "" buf = ""
while num: while num:
num, i = divmod(num, len(table)) num, mod = divmod(num, len(table))
buf = table[i] + buf buf = table[mod] + buf
return buf return buf
_base91_extra_chars = "!#$%&()*+,-./:;<=>?@[]^_`{|}~" _BASE91_EXTRA_CHARS = "!#$%&()*+,-./:;<=>?@[]^_`{|}~"
def base91(num: int) -> str: def base91(num: int) -> str:
# all printable characters minus quotes, backslash and separators # all printable characters minus quotes, backslash and separators
return base62(num, _base91_extra_chars) return base62(num, _BASE91_EXTRA_CHARS)
def guid64() -> str: def guid64() -> str:
@ -140,11 +133,11 @@ def guid64() -> str:
############################################################################## ##############################################################################
def joinFields(list: list[str]) -> str: def join_fields(list: list[str]) -> str:
return "\x1f".join(list) return "\x1f".join(list)
def splitFields(string: str) -> list[str]: def split_fields(string: str) -> list[str]:
return string.split("\x1f") return string.split("\x1f")
@ -158,20 +151,20 @@ def checksum(data: bytes | str) -> str:
return sha1(data).hexdigest() return sha1(data).hexdigest()
def fieldChecksum(data: str) -> int: def field_checksum(data: str) -> int:
# 32 bit unsigned number from first 8 digits of sha1 hash # 32 bit unsigned number from first 8 digits of sha1 hash
return int(checksum(stripHTMLMedia(data).encode("utf-8"))[:8], 16) return int(checksum(strip_html_media(data).encode("utf-8"))[:8], 16)
# Temp files # Temp files
############################################################################## ##############################################################################
_tmpdir = None _tmpdir = None # pylint: disable=invalid-name
def tmpdir() -> str: def tmpdir() -> str:
"A reusable temp folder which we clean out on each program invocation." "A reusable temp folder which we clean out on each program invocation."
global _tmpdir global _tmpdir # pylint: disable=invalid-name
if not _tmpdir: if not _tmpdir:
def cleanup() -> None: def cleanup() -> None:
@ -190,15 +183,15 @@ def tmpdir() -> str:
def tmpfile(prefix: str = "", suffix: str = "") -> str: def tmpfile(prefix: str = "", suffix: str = "") -> str:
(fd, name) = tempfile.mkstemp(dir=tmpdir(), prefix=prefix, suffix=suffix) (descriptor, name) = tempfile.mkstemp(dir=tmpdir(), prefix=prefix, suffix=suffix)
os.close(fd) os.close(descriptor)
return name return name
def namedtmp(name: str, rm: bool = True) -> str: def namedtmp(name: str, remove: bool = True) -> str:
"Return tmpdir+name. Deletes any existing file." "Return tmpdir+name. Deletes any existing file."
path = os.path.join(tmpdir(), name) path = os.path.join(tmpdir(), name)
if rm: if remove:
try: try:
os.unlink(path) os.unlink(path)
except OSError: except OSError:
@ -211,7 +204,7 @@ def namedtmp(name: str, rm: bool = True) -> str:
@contextmanager @contextmanager
def noBundledLibs() -> Iterator[None]: def no_bundled_libs() -> Iterator[None]:
oldlpath = os.environ.pop("LD_LIBRARY_PATH", None) oldlpath = os.environ.pop("LD_LIBRARY_PATH", None)
yield yield
if oldlpath is not None: if oldlpath is not None:
@ -222,18 +215,18 @@ def call(argv: list[str], wait: bool = True, **kwargs: Any) -> int:
"Execute a command. If WAIT, return exit code." "Execute a command. If WAIT, return exit code."
# ensure we don't open a separate window for forking process on windows # ensure we don't open a separate window for forking process on windows
if isWin: if isWin:
si = subprocess.STARTUPINFO() # type: ignore info = subprocess.STARTUPINFO() # type: ignore
try: try:
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore info.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore
except: except:
# pylint: disable=no-member # pylint: disable=no-member
si.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW # type: ignore info.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW # type: ignore
else: else:
si = None info = None
# run # run
try: try:
with noBundledLibs(): with no_bundled_libs():
o = subprocess.Popen(argv, startupinfo=si, **kwargs) process = subprocess.Popen(argv, startupinfo=info, **kwargs)
except OSError: except OSError:
# command not found # command not found
return -1 return -1
@ -241,7 +234,7 @@ def call(argv: list[str], wait: bool = True, **kwargs: Any) -> int:
if wait: if wait:
while 1: while 1:
try: try:
ret = o.wait() ret = process.wait()
except OSError: except OSError:
# interrupted system call # interrupted system call
continue continue
@ -259,13 +252,13 @@ isWin = sys.platform.startswith("win32")
isLin = not isMac and not isWin isLin = not isMac and not isWin
devMode = os.getenv("ANKIDEV", "") devMode = os.getenv("ANKIDEV", "")
invalidFilenameChars = ':*?"<>|' INVALID_FILENAME_CHARS = ':*?"<>|'
def invalidFilename(str: str, dirsep: bool = True) -> str | None: def invalid_filename(str: str, dirsep: bool = True) -> str | None:
for c in invalidFilenameChars: for char in INVALID_FILENAME_CHARS:
if c in str: if char in str:
return c return char
if (dirsep or isWin) and "/" in str: if (dirsep or isWin) and "/" in str:
return "/" return "/"
elif (dirsep or not isWin) and "\\" in str: elif (dirsep or not isWin) and "\\" in str:
@ -275,12 +268,10 @@ def invalidFilename(str: str, dirsep: bool = True) -> str | None:
return None return None
def platDesc() -> str: def plat_desc() -> str:
# we may get an interrupted system call, so try this in a loop # we may get an interrupted system call, so try this in a loop
n = 0
theos = "unknown" theos = "unknown"
while n < 100: for _ in range(100):
n += 1
try: try:
system = platform.system() system = platform.system()
if isMac: if isMac:
@ -301,33 +292,33 @@ def platDesc() -> str:
return theos return theos
# Debugging
##############################################################################
class TimedLog:
def __init__(self) -> None:
self._last = time.time()
def log(self, s: str) -> None:
path, num, fn, y = traceback.extract_stack(limit=2)[0]
sys.stderr.write(
"%5dms: %s(): %s\n" % ((time.time() - self._last) * 1000, fn, s)
)
self._last = time.time()
# Version # Version
############################################################################## ##############################################################################
def versionWithBuild() -> str: def version_with_build() -> str:
from anki.buildinfo import buildhash, version from anki.buildinfo import buildhash, version
return f"{version} ({buildhash})" return f"{version} ({buildhash})"
def pointVersion() -> int: def point_version() -> int:
from anki.buildinfo import version from anki.buildinfo import version
return int(version.split(".")[-1]) return int(version.split(".")[-1])
_deprecated_names = DeprecatedNamesMixinForModule(globals())
_deprecated_names.register_deprecated_aliases(
stripHTML=strip_html,
stripHTMLMedia=strip_html_media,
timestampID=timestamp_id,
maxID=max_id,
invalidFilenameChars=(INVALID_FILENAME_CHARS, "INVALID_FILENAME_CHARS"),
)
_deprecated_names.register_deprecated_attributes(json=((_json, "_json"), None))
@no_type_check
def __getattr__(name: str) -> Any:
return _deprecated_names.__getattr__(name)

View file

@ -39,5 +39,5 @@ ignore_missing_imports = True
ignore_missing_imports = True ignore_missing_imports = True
[mypy-anki._backend.rsbridge] [mypy-anki._backend.rsbridge]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-stringcase] [mypy-anki._vendor.stringcase]
ignore_missing_imports = True disallow_untyped_defs = False

View file

@ -9,7 +9,7 @@ import tempfile
from anki.collection import Collection as aopen from anki.collection import Collection as aopen
from anki.dbproxy import emulate_named_args from anki.dbproxy import emulate_named_args
from anki.lang import TR, without_unicode_isolation from anki.lang import TR, without_unicode_isolation
from anki.stdmodels import addBasicModel, get_stock_notetypes from anki.stdmodels import _legacy_add_basic_model, get_stock_notetypes
from anki.utils import isWin from anki.utils import isWin
from tests.shared import assertException, getEmptyCol from tests.shared import assertException, getEmptyCol
@ -123,7 +123,7 @@ def test_timestamps():
col = getEmptyCol() col = getEmptyCol()
assert len(col.models.all_names_and_ids()) == len(get_stock_notetypes(col)) assert len(col.models.all_names_and_ids()) == len(get_stock_notetypes(col))
for i in range(100): for i in range(100):
addBasicModel(col) _legacy_add_basic_model(col)
assert len(col.models.all_names_and_ids()) == 100 + len(get_stock_notetypes(col)) assert len(col.models.all_names_and_ids()) == 100 + len(get_stock_notetypes(col))

View file

@ -66,7 +66,7 @@ def test_find_cards():
assert len(col.find_cards("-tag:sheep")) == 4 assert len(col.find_cards("-tag:sheep")) == 4
col.tags.bulk_add(col.db.list("select id from notes"), "foo bar") col.tags.bulk_add(col.db.list("select id from notes"), "foo bar")
assert len(col.find_cards("tag:foo")) == len(col.find_cards("tag:bar")) == 5 assert len(col.find_cards("tag:foo")) == len(col.find_cards("tag:bar")) == 5
col.tags.bulkRem(col.db.list("select id from notes"), "foo") col.tags.bulk_remove(col.db.list("select id from notes"), "foo")
assert len(col.find_cards("tag:foo")) == 0 assert len(col.find_cards("tag:foo")) == 0
assert len(col.find_cards("tag:bar")) == 5 assert len(col.find_cards("tag:bar")) == 5
# text searches # text searches

View file

@ -17,18 +17,20 @@ def test_add():
with open(path, "w") as note: with open(path, "w") as note:
note.write("hello") note.write("hello")
# new file, should preserve name # new file, should preserve name
assert col.media.addFile(path) == "foo.jpg" assert col.media.add_file(path) == "foo.jpg"
# adding the same file again should not create a duplicate # adding the same file again should not create a duplicate
assert col.media.addFile(path) == "foo.jpg" assert col.media.add_file(path) == "foo.jpg"
# but if it has a different sha1, it should # but if it has a different sha1, it should
with open(path, "w") as note: with open(path, "w") as note:
note.write("world") note.write("world")
assert col.media.addFile(path) == "foo-7c211433f02071597741e6ff5a8ea34789abbf43.jpg" assert (
col.media.add_file(path) == "foo-7c211433f02071597741e6ff5a8ea34789abbf43.jpg"
)
def test_strings(): def test_strings():
col = getEmptyCol() col = getEmptyCol()
mf = col.media.filesInStr mf = col.media.files_in_str
mid = col.models.current()["id"] mid = col.models.current()["id"]
assert mf(mid, "aoeu") == [] assert mf(mid, "aoeu") == []
assert mf(mid, "aoeu<img src='foo.jpg'>ao") == ["foo.jpg"] assert mf(mid, "aoeu<img src='foo.jpg'>ao") == ["foo.jpg"]
@ -61,7 +63,7 @@ def test_deckIntegration():
col.media.dir() col.media.dir()
# put a file into it # put a file into it
file = str(os.path.join(testDir, "support", "fake.png")) file = str(os.path.join(testDir, "support", "fake.png"))
col.media.addFile(file) col.media.add_file(file)
# add a note which references it # add a note which references it
note = col.newNote() note = col.newNote()
note["Front"] = "one" note["Front"] = "one"

View file

@ -6,7 +6,7 @@ import time
from anki.consts import MODEL_CLOZE from anki.consts import MODEL_CLOZE
from anki.errors import NotFoundError from anki.errors import NotFoundError
from anki.utils import isWin, stripHTML from anki.utils import isWin, strip_html
from tests.shared import getEmptyCol from tests.shared import getEmptyCol
@ -114,7 +114,7 @@ def test_templates():
# and should have updated the other cards' ordinals # and should have updated the other cards' ordinals
c = note.cards()[0] c = note.cards()[0]
assert c.ord == 0 assert c.ord == 0
assert stripHTML(c.question()) == "1" assert strip_html(c.question()) == "1"
# it shouldn't be possible to orphan notes by removing templates # it shouldn't be possible to orphan notes by removing templates
t = mm.new_template("template name") t = mm.new_template("template name")
t["qfmt"] = "{{Front}}2" t["qfmt"] = "{{Front}}2"

View file

@ -7,7 +7,7 @@ import time
from anki.collection import Collection from anki.collection import Collection
from anki.consts import * from anki.consts import *
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.utils import intTime from anki.utils import int_time
from tests.shared import getEmptyCol as getEmptyColOrig from tests.shared import getEmptyCol as getEmptyColOrig
@ -21,7 +21,7 @@ def getEmptyCol() -> Collection:
def test_clock(): def test_clock():
col = getEmptyCol() col = getEmptyCol()
if (col.sched.day_cutoff - intTime()) < 10 * 60: if (col.sched.day_cutoff - int_time()) < 10 * 60:
raise Exception("Unit tests will fail around the day rollover.") raise Exception("Unit tests will fail around the day rollover.")
@ -53,7 +53,7 @@ def test_new():
assert c.queue == QUEUE_TYPE_NEW assert c.queue == QUEUE_TYPE_NEW
assert c.type == CARD_TYPE_NEW assert c.type == CARD_TYPE_NEW
# if we answer it, it should become a learn card # if we answer it, it should become a learn card
t = intTime() t = int_time()
col.sched.answerCard(c, 1) col.sched.answerCard(c, 1)
assert c.queue == QUEUE_TYPE_LRN assert c.queue == QUEUE_TYPE_LRN
assert c.type == CARD_TYPE_LRN assert c.type == CARD_TYPE_LRN

View file

@ -12,7 +12,7 @@ from anki import hooks
from anki.consts import * from anki.consts import *
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.scheduler import UnburyDeck from anki.scheduler import UnburyDeck
from anki.utils import intTime from anki.utils import int_time
from tests.shared import getEmptyCol as getEmptyColOrig from tests.shared import getEmptyCol as getEmptyColOrig
@ -33,7 +33,7 @@ def getEmptyCol():
def test_clock(): def test_clock():
col = getEmptyCol() col = getEmptyCol()
if (col.sched.day_cutoff - intTime()) < 10 * 60: if (col.sched.day_cutoff - int_time()) < 10 * 60:
raise Exception("Unit tests will fail around the day rollover.") raise Exception("Unit tests will fail around the day rollover.")
@ -65,7 +65,7 @@ def test_new():
assert c.queue == QUEUE_TYPE_NEW assert c.queue == QUEUE_TYPE_NEW
assert c.type == CARD_TYPE_NEW assert c.type == CARD_TYPE_NEW
# if we answer it, it should become a learn card # if we answer it, it should become a learn card
t = intTime() t = int_time()
col.sched.answerCard(c, 1) col.sched.answerCard(c, 1)
assert c.queue == QUEUE_TYPE_LRN assert c.queue == QUEUE_TYPE_LRN
assert c.type == CARD_TYPE_LRN assert c.type == CARD_TYPE_LRN
@ -804,14 +804,14 @@ def test_filt_keep_lrn_state():
# should be able to advance learning steps # should be able to advance learning steps
col.sched.answerCard(c, 3) col.sched.answerCard(c, 3)
# should be due at least an hour in the future # should be due at least an hour in the future
assert c.due - intTime() > 60 * 60 assert c.due - int_time() > 60 * 60
# emptying the deck preserves learning state # emptying the deck preserves learning state
col.sched.empty_filtered_deck(did) col.sched.empty_filtered_deck(did)
c.load() c.load()
assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN
assert c.left % 1000 == 1 assert c.left % 1000 == 1
assert c.due - intTime() > 60 * 60 assert c.due - int_time() > 60 * 60
def test_preview(): def test_preview():
@ -1034,7 +1034,7 @@ def test_timing():
c2 = col.sched.getCard() c2 = col.sched.getCard()
assert c2.queue == QUEUE_TYPE_REV assert c2.queue == QUEUE_TYPE_REV
# if the failed card becomes due, it should show first # if the failed card becomes due, it should show first
c.due = intTime() - 1 c.due = int_time() - 1
c.flush() c.flush()
col.reset() col.reset()
c = col.sched.getCard() c = col.sched.getCard()
@ -1061,7 +1061,7 @@ def test_collapse():
col.sched.answerCard(c2, 1) col.sched.answerCard(c2, 1)
# first should become available again, despite it being due in the future # first should become available again, despite it being due in the future
c3 = col.sched.getCard() c3 = col.sched.getCard()
assert c3.due > intTime() assert c3.due > int_time()
col.sched.answerCard(c3, 4) col.sched.answerCard(c3, 4)
# answer other # answer other
c4 = col.sched.getCard() c4 = col.sched.getCard()

View file

@ -6,7 +6,7 @@ import time
import aqt.forms import aqt.forms
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.utils import versionWithBuild from anki.utils import version_with_build
from aqt.addons import AddonManager, AddonMeta from aqt.addons import AddonManager, AddonMeta
from aqt.qt import * from aqt.qt import *
from aqt.utils import disable_help_button, supportText, tooltip, tr from aqt.utils import disable_help_button, supportText, tooltip, tr
@ -96,7 +96,7 @@ def show(mw: aqt.AnkiQt) -> QDialog:
abouttext = "<center><img src='/_anki/imgs/anki-logo-thin.png'></center>" abouttext = "<center><img src='/_anki/imgs/anki-logo-thin.png'></center>"
abouttext += f"<p>{tr.about_anki_is_a_friendly_intelligent_spaced()}" abouttext += f"<p>{tr.about_anki_is_a_friendly_intelligent_spaced()}"
abouttext += f"<p>{tr.about_anki_is_licensed_under_the_agpl3()}" abouttext += f"<p>{tr.about_anki_is_licensed_under_the_agpl3()}"
abouttext += f"<p>{tr.about_version(val=versionWithBuild())}<br>" abouttext += f"<p>{tr.about_version(val=version_with_build())}<br>"
abouttext += ("Python %s Qt %s PyQt %s<br>") % ( abouttext += ("Python %s Qt %s PyQt %s<br>") % (
platform.python_version(), platform.python_version(),
QT_VERSION_STR, QT_VERSION_STR,

View file

@ -10,7 +10,7 @@ from anki.collection import OpChanges, SearchNode
from anki.decks import DeckId from anki.decks import DeckId
from anki.models import NotetypeId from anki.models import NotetypeId
from anki.notes import Note, NoteFieldsCheckResult, NoteId from anki.notes import Note, NoteFieldsCheckResult, NoteId
from anki.utils import htmlToTextLine, isMac from anki.utils import html_to_text_line, isMac
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.deckchooser import DeckChooser from aqt.deckchooser import DeckChooser
from aqt.notetypechooser import NotetypeChooser from aqt.notetypechooser import NotetypeChooser
@ -197,7 +197,7 @@ class AddCards(QDialog):
if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))): if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))):
note = self.col.get_note(nid) note = self.col.get_note(nid)
fields = note.fields fields = note.fields
txt = htmlToTextLine(", ".join(fields)) txt = html_to_text_line(", ".join(fields))
if len(txt) > 30: if len(txt) > 30:
txt = f"{txt[:30]}..." txt = f"{txt[:30]}..."
line = tr.adding_edit(val=txt) line = tr.adding_edit(val=txt)

View file

@ -95,7 +95,7 @@ class UpdateInfo:
ANKIWEB_ID_RE = re.compile(r"^\d+$") ANKIWEB_ID_RE = re.compile(r"^\d+$")
current_point_version = anki.utils.pointVersion() current_point_version = anki.utils.point_version()
@dataclass @dataclass
@ -983,7 +983,7 @@ def download_addon(client: HttpClient, id: int) -> DownloadOk | DownloadError:
if resp.status_code != 200: if resp.status_code != 200:
return DownloadError(status_code=resp.status_code) return DownloadError(status_code=resp.status_code)
data = client.streamContent(resp) data = client.stream_content(resp)
fname = re.match( fname = re.match(
"attachment; filename=(.+)", resp.headers["content-disposition"] "attachment; filename=(.+)", resp.headers["content-disposition"]

View file

@ -484,7 +484,7 @@ noteEditorPromise.then(noteEditor => noteEditor.toolbar.then((toolbar) => toolba
json.dumps(focusTo), json.dumps(focusTo),
json.dumps(self.note.id), json.dumps(self.note.id),
json.dumps([text_color, highlight_color]), json.dumps([text_color, highlight_color]),
json.dumps(self.mw.col.tags.canonify(self.note.tags)), json.dumps(self.note.tags),
) )
if self.addMode: if self.addMode:
@ -674,12 +674,12 @@ noteEditorPromise.then(noteEditor => noteEditor.toolbar.then((toolbar) => toolba
def _addMedia(self, path: str, canDelete: bool = False) -> str: def _addMedia(self, path: str, canDelete: bool = False) -> str:
"""Add to media folder and return local img or sound tag.""" """Add to media folder and return local img or sound tag."""
# copy to media folder # copy to media folder
fname = self.mw.col.media.addFile(path) fname = self.mw.col.media.add_file(path)
# return a local html link # return a local html link
return self.fnameToLink(fname) return self.fnameToLink(fname)
def _addMediaFromData(self, fname: str, data: bytes) -> str: def _addMediaFromData(self, fname: str, data: bytes) -> str:
return self.mw.col.media.writeData(fname, data) return self.mw.col.media._legacy_write_data(fname, data)
def onRecSound(self) -> None: def onRecSound(self) -> None:
aqt.sound.record_audio( aqt.sound.record_audio(

View file

@ -31,7 +31,7 @@ from anki.decks import DeckDict, DeckId
from anki.hooks import runHook from anki.hooks import runHook
from anki.notes import NoteId from anki.notes import NoteId
from anki.sound import AVTag, SoundOrVideoTag from anki.sound import AVTag, SoundOrVideoTag
from anki.utils import devMode, ids2str, intTime, isMac, isWin, splitFields from anki.utils import devMode, ids2str, int_time, isMac, isWin, split_fields
from aqt import gui_hooks from aqt import gui_hooks
from aqt.addons import DownloadLogEntry, check_and_prompt_for_updates, show_log_to_user from aqt.addons import DownloadLogEntry, check_and_prompt_for_updates, show_log_to_user
from aqt.dbcheck import check_db from aqt.dbcheck import check_db
@ -633,11 +633,11 @@ class AnkiQt(QMainWindow):
def maybeOptimize(self) -> None: def maybeOptimize(self) -> None:
# have two weeks passed? # have two weeks passed?
if (intTime() - self.pm.profile["lastOptimize"]) < 86400 * 14: if (int_time() - self.pm.profile["lastOptimize"]) < 86400 * 14:
return return
self.progress.start(label=tr.qt_misc_optimizing()) self.progress.start(label=tr.qt_misc_optimizing())
self.col.optimize() self.col.optimize()
self.pm.profile["lastOptimize"] = intTime() self.pm.profile["lastOptimize"] = int_time()
self.pm.save() self.pm.save()
self.progress.finish() self.progress.finish()
@ -877,7 +877,7 @@ title="{}" {}>{}</button>""".format(
def maybe_check_for_addon_updates(self) -> None: def maybe_check_for_addon_updates(self) -> None:
last_check = self.pm.last_addon_update_check() last_check = self.pm.last_addon_update_check()
elap = intTime() - last_check elap = int_time() - last_check
if elap > 86_400: if elap > 86_400:
check_and_prompt_for_updates( check_and_prompt_for_updates(
@ -886,7 +886,7 @@ title="{}" {}>{}</button>""".format(
self.on_updates_installed, self.on_updates_installed,
requested_by_user=False, requested_by_user=False,
) )
self.pm.set_last_addon_update_check(intTime()) self.pm.set_last_addon_update_check(int_time())
def on_updates_installed(self, log: list[DownloadLogEntry]) -> None: def on_updates_installed(self, log: list[DownloadLogEntry]) -> None:
if log: if log:
@ -1321,7 +1321,7 @@ title="{}" {}>{}</button>""".format(
for id, mid, flds in col.db.execute( for id, mid, flds in col.db.execute(
f"select id, mid, flds from notes where id in {ids2str(nids)}" f"select id, mid, flds from notes where id in {ids2str(nids)}"
): ):
fields = splitFields(flds) fields = split_fields(flds)
f.write(("\t".join([str(id), str(mid)] + fields)).encode("utf8")) f.write(("\t".join([str(id), str(mid)] + fields)).encode("utf8"))
f.write(b"\n") f.write(b"\n")

View file

@ -12,7 +12,7 @@ import aqt
from anki.collection import Progress from anki.collection import Progress
from anki.errors import Interrupted, NetworkError from anki.errors import Interrupted, NetworkError
from anki.types import assert_exhaustive from anki.types import assert_exhaustive
from anki.utils import intTime from anki.utils import int_time
from aqt import gui_hooks from aqt import gui_hooks
from aqt.qt import QDialog, QDialogButtonBox, QPushButton, QTextCursor, QTimer, qconnect from aqt.qt import QDialog, QDialogButtonBox, QPushButton, QTextCursor, QTimer, qconnect
from aqt.utils import disable_help_button, showWarning, tr from aqt.utils import disable_help_button, showWarning, tr
@ -67,7 +67,7 @@ class MediaSyncer:
self.mw.taskman.run_in_background(run, self._on_finished) self.mw.taskman.run_in_background(run, self._on_finished)
def _log_and_notify(self, entry: LogEntry) -> None: def _log_and_notify(self, entry: LogEntry) -> None:
entry_with_time = LogEntryWithTime(time=intTime(), entry=entry) entry_with_time = LogEntryWithTime(time=int_time(), entry=entry)
self._log.append(entry_with_time) self._log.append(entry_with_time)
self.mw.taskman.run_on_main( self.mw.taskman.run_on_main(
lambda: gui_hooks.media_sync_did_progress(entry_with_time) lambda: gui_hooks.media_sync_did_progress(entry_with_time)
@ -142,7 +142,7 @@ class MediaSyncer:
last = self._log[-1].time last = self._log[-1].time
else: else:
last = 0 last = 0
return intTime() - last return int_time() - last
class MediaSyncDialog(QDialog): class MediaSyncDialog(QDialog):

View file

@ -244,7 +244,7 @@ class Preferences(QDialog):
def current_lang_index(self) -> int: def current_lang_index(self) -> int:
codes = [x[1] for x in anki.lang.langs] codes = [x[1] for x in anki.lang.langs]
lang = anki.lang.currentLang lang = anki.lang.current_lang
if lang in anki.lang.compatMap: if lang in anki.lang.compatMap:
lang = anki.lang.compatMap[lang] lang = anki.lang.compatMap[lang]
else: else:

View file

@ -20,7 +20,7 @@ from anki.collection import Collection
from anki.db import DB from anki.db import DB
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.sync import SyncAuth from anki.sync import SyncAuth
from anki.utils import intTime, isMac, isWin from anki.utils import int_time, isMac, isWin
from aqt import appHelpSite from aqt import appHelpSite
from aqt.qt import * from aqt.qt import *
from aqt.utils import disable_help_button, showWarning, tr from aqt.utils import disable_help_button, showWarning, tr
@ -68,7 +68,7 @@ class VideoDriver(Enum):
metaConf = dict( metaConf = dict(
ver=0, ver=0,
updates=True, updates=True,
created=intTime(), created=int_time(),
id=random.randrange(0, 2 ** 63), id=random.randrange(0, 2 ** 63),
lastMsg=-1, lastMsg=-1,
suppressUpdate=False, suppressUpdate=False,
@ -81,7 +81,7 @@ profileConf: dict[str, Any] = dict(
mainWindowGeom=None, mainWindowGeom=None,
mainWindowState=None, mainWindowState=None,
numBackups=50, numBackups=50,
lastOptimize=intTime(), lastOptimize=int_time(),
# editing # editing
searchHistory=[], searchHistory=[],
lastTextColor="#00f", lastTextColor="#00f",

View file

@ -19,7 +19,7 @@ from anki.collection import Config, OpChanges, OpChangesWithCount
from anki.scheduler.v3 import CardAnswer, NextStates, QueuedCards from anki.scheduler.v3 import CardAnswer, NextStates, QueuedCards
from anki.scheduler.v3 import Scheduler as V3Scheduler from anki.scheduler.v3 import Scheduler as V3Scheduler
from anki.tags import MARKED_TAG from anki.tags import MARKED_TAG
from anki.utils import stripHTML from anki.utils import strip_html
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.browser.card_info import PreviousReviewerCardInfo, ReviewerCardInfo from aqt.browser.card_info import PreviousReviewerCardInfo, ReviewerCardInfo
from aqt.deckoptions import confirm_deck_then_display_options from aqt.deckoptions import confirm_deck_then_display_options
@ -573,7 +573,7 @@ class Reviewer:
# munge correct value # munge correct value
cor = self.mw.col.media.strip(self.typeCorrect) cor = self.mw.col.media.strip(self.typeCorrect)
cor = re.sub("(\n|<br ?/?>|</?div>)+", " ", cor) cor = re.sub("(\n|<br ?/?>|</?div>)+", " ", cor)
cor = stripHTML(cor) cor = strip_html(cor)
# ensure we don't chomp multiple whitespace # ensure we don't chomp multiple whitespace
cor = cor.replace(" ", "&nbsp;") cor = cor.replace(" ", "&nbsp;")
cor = html.unescape(cor) cor = html.unescape(cor)

View file

@ -12,7 +12,7 @@ import aqt
from anki.errors import Interrupted, SyncError, SyncErrorKind from anki.errors import Interrupted, SyncError, SyncErrorKind
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.sync import SyncOutput, SyncStatus from anki.sync import SyncOutput, SyncStatus
from anki.utils import platDesc from anki.utils import plat_desc
from aqt.qt import ( from aqt.qt import (
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
@ -331,4 +331,4 @@ def get_id_and_pass_from_user(
# export platform version to syncing code # export platform version to syncing code
os.environ["PLATFORM"] = platDesc() os.environ["PLATFORM"] = plat_desc()

View file

@ -39,7 +39,7 @@ class TagLimit(QDialog):
self.exec() self.exec()
def rebuildTagList(self) -> None: def rebuildTagList(self) -> None:
usertags = self.mw.col.tags.byDeck(self.deck["id"], True) usertags = self.mw.col.tags.by_deck(self.deck["id"], True)
yes = self.deck.get("activeTags", []) yes = self.deck.get("activeTags", [])
no = self.deck.get("inactiveTags", []) no = self.deck.get("inactiveTags", [])
yesHash = {} yesHash = {}

View file

@ -7,7 +7,7 @@ from typing import Any
import requests import requests
import aqt import aqt
from anki.utils import platDesc, versionWithBuild from anki.utils import plat_desc, version_with_build
from aqt.main import AnkiQt from aqt.main import AnkiQt
from aqt.qt import * from aqt.qt import *
from aqt.utils import openLink, showText, tr from aqt.utils import openLink, showText, tr
@ -26,8 +26,8 @@ class LatestVersionFinder(QThread):
def _data(self) -> dict[str, Any]: def _data(self) -> dict[str, Any]:
return { return {
"ver": versionWithBuild(), "ver": version_with_build(),
"os": platDesc(), "os": plat_desc(),
"id": self.config["id"], "id": self.config["id"],
"lm": self.config["lastMsg"], "lm": self.config["lastMsg"],
"crt": self.config["created"], "crt": self.config["created"],

View file

@ -12,7 +12,13 @@ from typing import TYPE_CHECKING, Any, Literal, Sequence
import aqt import aqt
from anki.collection import Collection, HelpPage from anki.collection import Collection, HelpPage
from anki.lang import TR, tr_legacyglobal # pylint: disable=unused-import from anki.lang import TR, tr_legacyglobal # pylint: disable=unused-import
from anki.utils import invalidFilename, isMac, isWin, noBundledLibs, versionWithBuild from anki.utils import (
invalid_filename,
isMac,
isWin,
no_bundled_libs,
version_with_build,
)
from aqt.qt import * from aqt.qt import *
from aqt.theme import theme_manager from aqt.theme import theme_manager
@ -57,7 +63,7 @@ def openHelp(section: HelpPageArgument) -> None:
def openLink(link: str | QUrl) -> None: def openLink(link: str | QUrl) -> None:
tooltip(tr.qt_misc_loading(), period=1000) tooltip(tr.qt_misc_loading(), period=1000)
with noBundledLibs(): with no_bundled_libs():
QDesktopServices.openUrl(QUrl(link)) QDesktopServices.openUrl(QUrl(link))
@ -658,7 +664,7 @@ def openFolder(path: str) -> None:
if isWin: if isWin:
subprocess.run(["explorer", f"file://{path}"], check=False) subprocess.run(["explorer", f"file://{path}"], check=False)
else: else:
with noBundledLibs(): with no_bundled_libs():
QDesktopServices.openUrl(QUrl(f"file://{path}")) QDesktopServices.openUrl(QUrl(f"file://{path}"))
@ -762,7 +768,7 @@ def closeTooltip() -> None:
# true if invalid; print warning # true if invalid; print warning
def checkInvalidFilename(str: str, dirsep: bool = True) -> bool: def checkInvalidFilename(str: str, dirsep: bool = True) -> bool:
bad = invalidFilename(str, dirsep) bad = invalid_filename(str, dirsep)
if bad: if bad:
showWarning(tr.qt_misc_the_following_character_can_not_be(val=bad)) showWarning(tr.qt_misc_the_following_character_can_not_be(val=bad))
return True return True
@ -874,7 +880,7 @@ Platform: {}
Flags: frz={} ao={} sv={} Flags: frz={} ao={} sv={}
Add-ons, last update check: {} Add-ons, last update check: {}
""".format( """.format(
versionWithBuild(), version_with_build(),
platform.python_version(), platform.python_version(),
QT_VERSION_STR, QT_VERSION_STR,
PYQT_VERSION_STR, PYQT_VERSION_STR,

View file

@ -439,7 +439,7 @@ div[contenteditable="true"]:focus {{
window_bg_night = self.get_window_bg_color(True).name() window_bg_night = self.get_window_bg_color(True).name()
body_bg = window_bg_night if theme_manager.night_mode else window_bg_day body_bg = window_bg_night if theme_manager.night_mode else window_bg_day
if is_rtl(anki.lang.currentLang): if is_rtl(anki.lang.current_lang):
lang_dir = "rtl" lang_dir = "rtl"
else: else:
lang_dir = "ltr" lang_dir = "ltr"