PK ! nr r CHANGELOG.md# Version 0.2
## Features
* Add X11, Windows, OS X, SVG target support
* Add CLDR convertor (both directions)
## Changes
* Android env var becomes `ANDROID_HOME`
* Keyboard layout mode format is no longer a list.
This:
> default: [
> A B C,
> D E F
> ]
Becomes:
> default: |
> A B C
> D E F
# Version 0.1
* Initial release, supporting iOS and Android.
PK ! '*( *( LICENSEApache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, "control" means (i) the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
"Object" form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made
available under the License, as indicated by a copyright notice that is included
in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
"submitted" means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide, non-
exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce,
prepare Derivative Works of, publicly display, publicly perform, sublicense, and
distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License,
each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-
charge, royalty-free, irrevocable (except as stated in this section) patent
license to make, have made, use, offer to sell, sell, import, and otherwise
transfer the Work, where such license applies only to those patent claims
licensable by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s) with the Work
to which such Contribution(s) was submitted. If You institute patent litigation
against any entity (including a cross-claim or counterclaim in a lawsuit)
alleging that the Work or a Contribution incorporated within the Work
constitutes direct or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate as of the date
such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or
Derivative Works thereof in any medium, with or without modifications, and in
Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a
copy of this License; and
(b) You must cause any modified files to carry prominent notices stating
that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices from the
Source form of the Work, excluding those notices that do not pertain to any part
of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution,
then any Derivative Works that You distribute must include a readable copy of
the attribution notices contained within such NOTICE file, excluding those
notices that do not pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and may
provide additional or different license terms and conditions for use,
reproduction, or distribution of Your modifications, or for any such Derivative
Works as a whole, provided Your use, reproduction, and distribution of the Work
otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any
Contribution intentionally submitted for inclusion in the Work by You to the
Licensor shall be under the terms and conditions of this License, without any
additional terms or conditions. Notwithstanding the above, nothing herein shall
supersede or modify the terms of any separate license agreement you may have
executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names,
trademarks, service marks, or product names of the Licensor, except as required
for reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
writing, Licensor provides the Work (and each Contributor provides its
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied, including, without limitation, any warranties
or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any risks
associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in
tort (including negligence), contract, or otherwise, unless required by
applicable law (such as deliberate and grossly negligent acts) or agreed to in
writing, shall any Contributor be liable to You for damages, including any
direct, indirect, special, incidental, or consequential damages of any character
arising as a result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill, work stoppage,
computer failure or malfunction, or any and all other commercial damages or
losses), even if such Contributor has been advised of the possibility of such
damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a fee for,
acceptance of support, warranty, indemnity, or other liability obligations
and/or rights consistent with this License. However, in accepting such
obligations, You may act only on Your own behalf and on Your sole
responsibility, not on behalf of any other Contributor, and only if You agree to
indemnify, defend, and hold each Contributor harmless for any liability incurred
by, or claims asserted against, such Contributor by reason of your accepting any
such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets "[]" replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on
the same "printed page" as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
PK ! 3F kbdgen/__init__.py__version__ = "1.0.1"
PK ! qy# # kbdgen/__main__.pyimport sys
def main():
if sys.version_info.major < 3:
print("kbdgen only supports Python 3.")
sys.exit(1)
try:
from .cli import run_cli
sys.exit(run_cli())
except KeyboardInterrupt:
sys.exit(255)
if __name__ == "__main__":
main()
PK ! ^17 7 kbdgen/base.pyimport logging
import os
import os.path
import re
import sys
import itertools
import unicodedata
from collections import OrderedDict, namedtuple
from . import orderedyaml, log
class KbdgenException(Exception):
pass
class UserException(Exception):
pass
log.monkey_patch_trace_logging()
def get_logger(path):
return logging.getLogger(os.path.basename(os.path.splitext(path)[0]))
log.enable_pretty_logging(
fmt="%(color)s[%(levelname)1.1s %(module)s:%(lineno)d]%(end_color)s"
+ " %(message)s"
)
logger = logging.getLogger()
Action = namedtuple("Action", ["row", "position", "width"])
ProjectLocaleData = namedtuple("ProjectLocaleData", ["name", "description"])
VALID_ID_RE = re.compile(r"^[a-z][0-9a-z-_]+$")
ISO_KEYS = (
"E00",
"E01",
"E02",
"E03",
"E04",
"E05",
"E06",
"E07",
"E08",
"E09",
"E10",
"E11",
"E12",
"D01",
"D02",
"D03",
"D04",
"D05",
"D06",
"D07",
"D08",
"D09",
"D10",
"D11",
"D12",
"C01",
"C02",
"C03",
"C04",
"C05",
"C06", # TODO fix the D13 special case.
"C07",
"C08",
"C09",
"C10",
"C11",
"D13", # C12 -> D13
"B00",
"B01",
"B02",
"B03",
"B04",
"B05",
"B06",
"B07",
"B08",
"B09",
"B10",
)
MODE_LIST_ERROR = """\
'%s' must be defined as a string using block string format, not a list.
For example, if your keyboard.yaml looks like:
```
modes:
default: [
q w e r t y u i o p å,
a s d f g h j k l ö æ,
z x c v b n m ï
]
```
Convert that to:
```
modes:
default: |
q w e r t y u i o p å
a s d f g h j k l ö æ
z x c v b n m ï
```
"""
def parse_layout(data, length_check=True):
if isinstance(data, dict):
o = OrderedDict()
for key in ISO_KEYS:
v = data.get(key, None)
o[key] = str(v) if v is not None else None
return o
elif isinstance(data, str):
data = re.sub(r"[\r\n\s]+", " ", data.strip()).split(" ")
if length_check and len(data) != len(ISO_KEYS):
raise Exception(len(data))
o = OrderedDict(zip(ISO_KEYS, data))
# Remove nulls
for k in ISO_KEYS:
if o[k] == r"\u{0}":
o[k] = None
return o
def parse_touch_layout(data):
return [re.split(r"\s+", x.strip()) for x in data.strip().split("\n")]
class Project:
def __init__(self, tree):
self._tree = tree
def relpath(self, end):
return os.path.abspath(os.path.join(self.path, end))
@property
def path(self):
return self._tree["_path"]
@property
def locales(self):
return self._tree["locales"]
@property
def author(self):
return self._tree["author"]
@property
def email(self):
return self._tree["email"]
@property
def layouts(self):
return self._tree["layouts"]
@property
def targets(self):
return self._tree["targets"]
@property
def internal_name(self):
return self._tree["internalName"]
@property
def app_strings(self):
return self._tree["appStrings"]
@property
def version(self):
return str(self._tree["version"])
@property
def build(self):
return str(self._tree["build"])
@property
def copyright(self):
return self._tree.get("copyright", "")
@property
def organisation(self):
return self._tree.get("organisation", "")
def locale(self, tag):
val = self.locales.get(tag, None)
if val is None:
return None
return ProjectLocaleData(val["name"], val["description"])
@property
def names(self):
x = {}
for tag, o in self.locales.items():
x[tag] = o["name"]
return x
@property
def descriptions(self):
x = {}
for tag, o in self.locales.items():
x[tag] = o["description"]
return x
def first_locale(self):
tag = next(iter(self.locales.keys()))
return self.locale(tag)
def target(self, target):
return self._tree["targets"].get(target, {}) or {}
def icon(self, target, size=None):
val = self.target(target).get("icon", None)
if val is None:
return None
if isinstance(val, str):
return self.relpath(val)
if size is None:
# Find largest
m = -1
for k in val:
if k > m:
m = k
return self.relpath(val[m])
else:
lrg = -1
m = sys.maxsize
for k in val:
if k > lrg:
lrg = k
if k >= size and k < m:
m = k
if m == sys.maxsize:
return self.relpath(val[lrg])
return self.relpath(val[m])
class Keyboard:
def __init__(self, tree):
self._tree = tree
@property
def internal_name(self):
return self._tree["internalName"]
@property
def native_display_name(self):
return self.display_names[self.locale]
@property
def display_names(self):
return self._tree["displayNames"]
@property
def locale(self):
return self._tree["locale"]
@property
def special(self):
return self._tree.get("special", {})
@property
def decimal(self):
return self._tree.get("decimal", None)
@property
def dead_keys(self):
return self._tree.get("deadKeys", {})
@property
def derive(self):
return self._tree.get("derive", {})
@property
def transforms(self):
return self._tree.get("transforms", {})
@property
def modifiers(self):
return self._tree["modifiers"]
@property
def modes(self):
return self._tree["modes"]
@property
def strings(self):
return self._tree.get("strings", {})
@property
def styles(self):
return self._tree["styles"]
def target(self, target):
return self._tree.get("targets", {}).get(target, {}) or {}
def get_actions(self, style):
return self.styles[style]["actions"]
def get_action(self, style, key):
return self.styles[style]["actions"].get(key, None)
@property
def longpress(self):
return self._tree["longpress"]
def get_longpress(self, key):
return self._tree["longpress"].get(key, None)
@property
def supported_targets(self):
return self._tree.get("supportedTargets", None)
def supported_target(self, target):
targets = self.supported_targets
if targets is None:
return True
return target in targets
class Parser:
def __init__(self):
pass
def _overrides(self, project, cfg_pairs):
def resolve_path(path, v):
chunks = path.split(".")
last = chunks.pop()
node = project
for chunk in chunks:
if node.get(chunk, None) is None:
node[chunk] = OrderedDict()
node = node[chunk]
node[last] = v
for path, v in cfg_pairs:
resolve_path(path, v)
def _parse_cfg_pairs(self, str_list):
try:
return [x.split("=", 1) for x in str_list]
except Exception:
raise Exception("Error: invalid key-value pair provided.")
def _parse_global(self, cfg_file=None):
if cfg_file is None:
cfg_file = open(
os.path.join(os.path.dirname(__file__), "global.yaml"), encoding="utf-8"
)
return orderedyaml.load(cfg_file)
@classmethod
def _parse_keyboard_descriptor(cls, tree):
for key in ["locale", "displayNames", "internalName", "modes"]:
if key not in tree:
raise Exception("%s key missing from file." % key)
# TODO move this to android and ios generators
# if 'mobile-default' not in tree['modes']:
# raise Exception("No default mode supplied in file.")
if "modifiers" not in tree or tree.get("modifiers", None) is None:
tree["modifiers"] = []
if "longpress" not in tree or tree.get("longpress", None) is None:
tree["longpress"] = OrderedDict()
for mode in list(tree["modes"].keys()):
if isinstance(tree["modes"][mode], list):
raise Exception(MODE_LIST_ERROR % mode)
try:
# Soft layouts are special cased.
if mode in ["mobile-default", "mobile-shift"]:
tree["modes"][mode] = parse_touch_layout(tree["modes"][mode])
else:
tree["modes"][mode] = parse_layout(tree["modes"][mode])
except Exception as e:
raise Exception(
("'%s' is the wrong length. " + "Got %s, expected %s.")
% (mode, str(e), len(ISO_KEYS))
)
for longpress, strings in tree["longpress"].items():
tree["longpress"][longpress] = re.split(r"\s+", strings.strip())
for style, styles in tree.get("styles", {}).items():
for action, info in styles["actions"].items():
styles["actions"][action] = Action(info[0], info[1], info[2])
return Keyboard(tree)
def _parse_project(self, tree):
for key in ["locales", "author", "email", "layouts", "targets"]:
if key not in tree:
raise Exception("%s key missing from file." % key)
tree_path = tree["_path"]
layouts = OrderedDict()
known_ids = set()
for layout in tree["layouts"]:
try:
fn = "%s.yaml" % layout
with open(os.path.join(tree_path, fn), encoding="utf-8") as f:
try:
data = unicodedata.normalize("NFC", f.read())
kbdtree = orderedyaml.loads(data)
rl = self._parse_keyboard_descriptor(kbdtree)
dt = rl.derive.get("transforms", False)
if dt is not False:
derive_transforms(rl, True if dt == "all" else False)
if rl.internal_name is None:
raise UserException(
"'%s' has no internalName field" % f.name
)
if not VALID_ID_RE.match(rl.internal_name):
raise UserException(
(
"Internal name '%s' in file '%s' not valid. Must "
+ "begin with a-z, and after contain only a-z, "
+ "0-9, dashes (-) and underscores (_)."
)
% (rl.internal_name, fn)
)
if rl.internal_name in known_ids:
raise UserException(
"A duplicate internal name was found '%s' in file '%s'"
% (rl.internal_name, fn)
)
known_ids.add(rl.internal_name)
layouts[rl.internal_name] = rl
except Exception as e:
logger.error("There was an error for file '%s.yaml':" % layout)
raise e
except FileNotFoundError:
logger.error("Layout '%s' listed in project, but not found." % layout)
return None
tree["layouts"] = layouts
return Project(tree)
def parse(self, f, cfg_pairs=None, cfg_file=None):
tree = self._parse_global(cfg_file)
# Compose all decomposed unicode codepoints
data = unicodedata.normalize("NFC", f.read())
tree.update(orderedyaml.loads(data))
tree["_path"] = os.path.dirname(os.path.abspath(f.name))
project = self._parse_project(tree)
if project is None:
return None
if cfg_pairs is not None:
logger.trace("cfg_pairs: %r", cfg_pairs)
self._overrides(project._tree, self._parse_cfg_pairs(cfg_pairs))
return project
def decompose(ch):
x = unicodedata.normalize("NFKD", ch).replace(" ", "")
if x == ch:
try:
c = "COMBINING %s" % unicodedata.name(ch).replace("MODIFIER LETTER ", "")
return unicodedata.lookup(c)
except Exception:
pass
return x
def derive_transforms(layout, allow_glyphbombs=False):
if layout._tree.get("transforms", None) is None:
layout._tree["transforms"] = {}
dead_keys = sorted(set(itertools.chain.from_iterable(layout.dead_keys.values())))
logger.trace("Dead keys: %r" % dead_keys)
# Get all letter category input chars
def char_filter(ch):
if ch is None:
return False
if len(ch) != 1:
return False
return unicodedata.category(ch).startswith("L")
input_chars = sorted(
set(
filter(
char_filter,
set(
itertools.chain.from_iterable(
(x.values() for x in layout.modes.values())
)
),
)
)
)
logger.trace("Input chars: %r" % input_chars)
# Generate inputtable transforms
for d in dead_keys:
if layout.transforms.get(d, None) is None:
layout.transforms[d] = {" ": d}
dc = decompose(d)
for ch in input_chars:
composed = "%s%s" % (ch, dc)
normalised = unicodedata.normalize("NFKC", composed)
# Check if when composed the codepoint is not the same as decomposed
if not allow_glyphbombs and composed == normalised:
logger.trace("Skipping %s%s" % (d, ch))
continue
logger.trace("Adding transform: %s%s -> %s" % (d, ch, normalised))
layout.transforms[d][ch] = normalised
PK ! 3 kbdgen/boolmap.pyimport array
class BoolMap:
def __init__(self, bytedata=None, default=False):
self._default = bool(default)
self._data = array.array("B")
if bytedata is not None:
self._data.frombytes(bytedata)
def __getitem__(self, k):
if not isinstance(k, int):
raise KeyError(k)
pos = k // 8
off = k % 8
if pos >= len(self._data):
return self._default
return bool(self._data[pos] & 1 << off)
def __setitem__(self, k, v):
if not isinstance(k, int):
raise KeyError(k)
if not isinstance(v, bool):
raise ValueError(v)
pos = k // 8
off = k % 8
if pos >= len(self._data):
self._data.fromlist(
[0 if self._default is False else 0xFF] * (pos - len(self._data) + 1)
)
chunk = self._data[pos]
if v:
self._data[pos] = chunk | 1 << off
else:
self._data[pos] = ~(~chunk & 0xFF | (1 << off)) & 0xFF
return v
def __iter__(self):
for v in self._data:
for i in range(8):
yield bool(v & (1 << i))
def to_bytes(self):
return self._data.tobytes()
def parse_range_data(data):
range_data = [x.split("-", 1) for x in data.split(",")]
new_ranges = []
for chunk in range_data:
ln = len(chunk)
if 0 > ln > 2:
raise Exception()
if len(chunk) == 1:
chunk.append(chunk[0])
new_ranges.append(range(int(chunk[0], 16), int(chunk[1], 16) + 1))
return new_ranges
def apply_ranges_to_boolmap(data):
boolmap = BoolMap()
for iterator in data:
for i in iterator:
boolmap[i] = True
return boolmap
if __name__ == "__main__":
import sys
with open(sys.argv[1]) as f:
data = f.read()
ranges = parse_range_data(data)
bm = apply_ranges_to_boolmap(ranges)
with open(sys.argv[1] + ".bin", "wb") as f:
f.write(bm.to_bytes())
PK ! LPN9 N9 kbdgen/cldr.pyimport datetime
import itertools
import os.path
import re
import unicodedata
import lxml.etree
from lxml.etree import SubElement
from io import StringIO
from collections import OrderedDict, namedtuple
from .base import get_logger
logger = get_logger(__file__)
CP_REGEX = re.compile(r"\\u{(.+?)}")
ENTITY_REGEX = re.compile(r"(\d+);")
BAD_UNICODE_CATS = ("C", "Z", "M")
def cldr_sub(value, repl, ignore_space=False):
def r(x):
c = unicodedata.category(x)[0]
if (ignore_space and x == " ") or c not in BAD_UNICODE_CATS:
return x
else:
return repl(x, c)
return "".join([r(x) for x in value])
def decode_u(v, newlines=True):
def chk(x):
vv = chr(int(x.group(1), 16))
if not newlines and vv in ("\n", "\r"):
return x.group(0)
return vv
if v is None:
v = ""
return CP_REGEX.sub(chk, str(v))
def encode_u(v):
return cldr_sub(v, lambda x, c: r"\u{%X}" % ord(x))
def key_cmp(x):
ch, n = parse_cell(x[0])
return -int(ch, 16) * 16 + n
def process_value(*args):
pv = lambda v: encode_u(decode_u(v)) # noqa: E731
return tuple(pv(i) for i in args) if len(args) > 1 else pv(args[0])
def cell_range(ch, from_, to_):
for i in range(from_, to_ + 1):
yield "%s%02d" % (ch, i)
def parse_cell(x):
a = x[0]
b = int(x[1:])
return a, b
def is_relevant_cell(x):
ch, n = parse_cell(x)
if ch == "E":
return 0 <= n <= 12
if ch == "D":
return 1 <= n <= 12
if ch == "C":
return 1 <= n <= 12
if ch == "B":
return 0 <= n <= 10
return False
class UnknownNgramException(Exception):
pass
def split_for_set(s, string):
o = []
i = len(string)
while len(string) > 0:
in_set = string[:i] in s
if in_set:
o.append(string[:i])
string = string[i:]
i = len(string)
elif i == 1:
raise UnknownNgramException("'%s' not found in set." % string)
else:
i -= 1
return tuple(o)
def keyboard_range():
return itertools.chain(
cell_range("E", 0, 12),
cell_range("D", 1, 12),
cell_range("C", 1, 12),
cell_range("B", 0, 10),
)
def is_full_layout(o):
"""Strictly not accurate, as D13 is considered C12 for convenience."""
chain = keyboard_range()
for v in chain:
if v not in o:
return False
return True
def filtered(v):
if v == '"':
return "'\"'"
if v == "\\":
# TODO check if this is necessary in practice
return r'"\\"'
if v in r" |-?:,[]{}#&*!>'%@`~=":
return '"%s"' % v
return encode_u(v)
def to_xml(yaml_tree):
tree = lxml.etree.fromstring(
"""""" % yaml_tree["internalName"]
)
# TODO generate both these validly
SubElement(tree, "version")
SubElement(tree, "generation")
names = SubElement(tree, "names")
SubElement(names, "name", value=yaml_tree["displayNames"][yaml_tree["locale"]])
for mode, key_map in yaml_tree["modes"].items():
if not mode.startswith("iso-"):
continue
mod = mode[4:]
if mod == "default":
node = SubElement(tree, "keyMap")
else:
node = SubElement(tree, "keyMap", modifiers=mod)
deadkey_set = {
x for x in itertools.chain.from_iterable(yaml_tree["deadKeys"].values())
}
if isinstance(key_map, dict):
for k, v in sorted(key_map.items(), key=key_cmp):
key_node = SubElement(node, "map", iso=str(k), to=str(v))
# TODO make this more optimal, chaining all lists and only
# assigning when it makes sense to do so
if v in deadkey_set and v not in yaml_tree["deadKeys"].get(mode, {}):
key_node.attrib["transform"] = "no"
else:
chain = keyboard_range()
for iso, to in zip(chain, re.split(r"[\s\n]+", key_map)):
# Ignore nulls
if to == r"\u{0}":
continue
key_node = SubElement(node, "map", iso=iso, to=to)
if to in deadkey_set and to not in yaml_tree["deadKeys"].get(mode, {}):
key_node.attrib["transform"] = "no"
# Space special case!
space = yaml_tree.get("special", {}).get("space", {}).get(mode, " ")
SubElement(node, "map", iso="A03", to=space)
transforms = SubElement(tree, "transforms", type="simple")
for base, o in yaml_tree["transforms"].items():
for tr_from, tr_to in o.items():
n = SubElement(transforms, "transform")
n.attrib["from"] = "%s%s" % (base, tr_from)
n.attrib["to"] = tr_to
out = lxml.etree.tostring(
tree, xml_declaration=True, encoding="utf-8", pretty_print=True
).decode()
return ENTITY_REGEX.sub(lambda x: "\\u{%s}" % hex(int(x.group(1)))[2:].upper(), out)
class CLDRKeyboard:
@classmethod
def from_file(cls, f):
return cls(f.read(), filename=os.path.basename(f.name))
def __init__(self, data, filename=None):
self._filename = filename
self._modes = OrderedDict()
# Actual transforms themselves
self._transforms = OrderedDict()
# Key set
self._key_set = set()
# Known deadkey glyphs
self._deadkey_set = set()
# Deadkey layouts
self._deadkeys = OrderedDict()
self._comments = {}
self._space = OrderedDict()
tree = lxml.etree.fromstring(data)
self._internal_name = tree.attrib["locale"]
self._locale = self._internal_name.split("-")[0]
self._name = tree.xpath("names/name")[0].attrib["value"]
self._generate_keyset(tree)
self._parse_transforms(tree)
self._parse_keymaps(tree)
def _generate_keyset(self, tree):
self._key_set = {decode_u(n.attrib["to"]) for n in tree.xpath("keyMap/map")}
self._key_set.add(" ")
def _parse_keymaps(self, tree):
is_osx = self._internal_name.endswith("osx")
for keymap in tree.xpath("keyMap"):
mode = CLDRMode(keymap.attrib.get("modifiers", "default"))
new_mode = mode.kbdgen[0]
self._comments[new_mode] = mode.cldr
o = {}
for key in keymap.xpath("map"):
iso_key = key.attrib["iso"]
# Special case for layout differences.
if iso_key == "D13":
iso_key = "C12"
elif not is_relevant_cell(iso_key):
if iso_key == "A03" and key.attrib["to"] != " ":
self._space[new_mode] = process_value(key.attrib["to"])
continue
o[iso_key] = process_value(key.attrib["to"])
if (
o[iso_key] in self._deadkey_set
and key.attrib.get("transform", None) != "no"
):
self._deadkeys.setdefault(new_mode, set()).add(o[iso_key])
# OS X definitions are in a pseudo-ANSI format that inverts
# the E00 and B00 keys to prioritise B00 to the E00 position if
# an ISO language keyboard layout is used on an ANSI keyboard.
if is_osx and "B00" in o and "E00" in o:
tmp = o["E00"]
o["E00"] = o["B00"]
o["B00"] = tmp
if "B00" not in o and "E00" in o:
o["B00"] = o["E00"]
logger.warning(
(
"B00 has been duplicated from E00 in '%s'; "
+ "ANSI keyboard definition?"
)
% new_mode
)
# Force ANSI-style keys into the ISO world.
if "D13" in o:
o["C12"] = o["D13"]
del o["D13"]
self._modes[new_mode] = OrderedDict(sorted(o.items(), key=key_cmp))
def _parse_transforms(self, tree):
o = OrderedDict()
def o_add(ng, v):
last = o
for k in ng[:-1]:
last.setdefault(k, OrderedDict())
last = last[k]
last[ng[-1]] = v
for transform in tree.xpath("transforms[@type='simple']/transform"):
ngrams = split_for_set(self._key_set, decode_u(transform.attrib["from"]))
self._deadkey_set.add(ngrams[0])
o_add(ngrams, decode_u(transform.attrib["to"]))
self._transforms = o
def keys(self, mode):
return self._modes[mode]
def _as_yaml_transforms(self, x):
x.write("\ntransforms:\n")
def mm(n, it):
for k, v in it.items():
if isinstance(v, str):
x.write("%s%s: %s\n" % (" " * n, filtered(k), filtered(v)))
elif isinstance(v, dict):
x.write("%s%s:\n" % (" " * n, filtered(k)))
mm(n + 2, v)
mm(2, self._transforms)
def as_yaml(self):
x = StringIO()
x.write(
"### Generated from %s on %s.\n\n"
% (
self._filename or "string data",
datetime.datetime.utcnow().strftime("%Y-%m-%d at %H:%M"),
)
)
x.write("internalName: %s\n\n" % self._internal_name)
x.write("displayNames:\n %s: %s\n\n" % (self._locale, self._name))
x.write("locale: %s\n\n" % self._locale)
x.write("modes:\n")
for mode, o in self._modes.items():
if mode in self._comments:
x.write(" # %s\n" % self._comments[mode])
x.write(" %s: |" % mode)
cur = None
for iso_key in keyboard_range():
if cur != iso_key[0]:
x.write("\n ")
cur = iso_key[0]
x.write(" ")
x.write(o.get(iso_key, r"\u{0}"))
x.write("\n")
if len(self._deadkeys) > 0:
x.write("\ndeadKeys:\n")
for mode, keys in self._deadkeys.items():
output = list(sorted(keys))
x.write((" %s: %r\n" % (mode, output)).replace("\\", r"\\"))
if len(self._transforms) > 0:
self._as_yaml_transforms(x)
if len(self._space) > 0:
x.write("\nspecial:\n space:\n")
for mode, v in self._space.items():
x.write(" %s: %s\n" % (mode, filtered(v)))
return x.getvalue()
Left = "left"
Right = "right"
Both = "both"
Shift = "shift"
Alt = "alt"
Caps = "caps"
Ctrl = "ctrl"
OSXCommand = "cmd"
TOKENS = {
"shift": Shift,
"alt": Alt,
"opt": Alt,
"caps": Caps,
"ctrl": Ctrl,
"cmd": OSXCommand,
}
ModeToken = namedtuple("CLDRMode", ["name", "direction", "required"])
class CLDRMode:
def __init__(self, data):
self._raw = data
self._kbdgen = None
def _parse_tokens(self, tokens):
def pt(tok):
is_required = not tok.endswith("?")
if not is_required:
tok = tok[:-1]
is_l = tok.endswith("L")
is_r = tok.endswith("R")
if is_l or is_r:
tok = tok[:-1]
known_token = TOKENS.get(tok, None)
if known_token is None:
return ModeToken(known_token, None, None)
direction = Both
if is_l:
direction = Left
if is_r:
direction = Right
return ModeToken(known_token, direction, is_required)
return tuple(pt(x) for x in tokens)
def _init_kbdgen(self):
out = []
phrases = self._raw.split(" ")
for ph in phrases:
# Special case: "default"
if ph == "default":
if "iso-default" not in out:
out.append("iso-default")
continue
tokens = self._parse_tokens(ph.split("+"))
clean = tuple(tok[0] for tok in tokens if tok.required is True)
prefix = "iso"
if OSXCommand in clean:
prefix = "osx"
def mm(x):
if x == "cmd":
return 0
if x == "caps":
return 5
if x == "ctrl":
return 10
if x == "alt":
return 20
if x == "shift":
return 40
return 99
v = "%s-%s" % (prefix, "+".join(sorted(clean, key=mm)))
if v not in out:
out.append(v)
# Sorted to bring shortest priority to the front
# eg ('iso-caps+alt', 'iso-alt', 'osx-cmd+alt') from cs is wrong.
return tuple(sorted(out, key=lambda x: x.count("+")))
@property
def kbdgen(self):
if self._kbdgen is None:
self._kbdgen = self._init_kbdgen()
return self._kbdgen
@property
def cldr(self):
return self._raw
def cldr2kbdgen_main():
import argparse
import sys
p = argparse.ArgumentParser(prog="cldr2kbdgen")
p.add_argument(
"--osx",
action="store_true",
help="Force detection of XML file as an OS X keyboard.",
)
p.add_argument("cldr_xml", type=argparse.FileType("rb"), default=sys.stdin)
p.add_argument("kbdgen_yaml", type=argparse.FileType("w"), default=sys.stdout)
args = p.parse_args()
args.kbdgen_yaml.write(CLDRKeyboard.from_file(args.cldr_xml).as_yaml())
def kbdgen2cldr_main():
import argparse
import sys
from . import orderedyaml
p = argparse.ArgumentParser(prog="kbdgen2cldr")
# p.add_argument('--osx', action='store_true',
# help="Force detection of XML file as an OS X keyboard.")
p.add_argument("kbdgen_yaml", type=argparse.FileType("r"), default=sys.stdout)
p.add_argument("cldr_xml", type=argparse.FileType("w"), default=sys.stdin)
args = p.parse_args()
parsed = orderedyaml.load(args.kbdgen_yaml)
args.cldr_xml.write(to_xml(parsed))
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("cldr2kbdgen or kbdgen2cldr required as first param.")
sys.exit(1)
app = sys.argv.pop(1)
if app == "cldr2kbdgen":
cldr2kbdgen_main()
elif app == "kbdgen2cldr":
kbdgen2cldr_main()
else:
print("cldr2kbdgen or kbdgen2cldr required as first param.")
sys.exit(1)
PK ! X$
kbdgen/cli.pyimport argparse
import yaml
import sys
from . import __version__, gen
from .base import KbdgenException, Parser, logger, UserException
def parse_args():
def logging_type(string):
n = {
"critical": 50,
"error": 40,
"warning": 30,
"info": 20,
"debug": 10,
"trace": 5,
}.get(string, None)
if n is None:
raise argparse.ArgumentTypeError("Invalid logging level.")
return n
p = argparse.ArgumentParser(prog="kbdgen")
p.add_argument("--version", action="version", version="%(prog)s " + __version__)
p.add_argument("--logging", type=logging_type, default=20, help="Logging level")
p.add_argument(
"-K",
"--key",
nargs="*",
dest="cfg_pairs",
help="Key-value overrides (eg -K target.thing.foo=42)",
)
p.add_argument(
"-D",
"--dry-run",
action="store_true",
help="Don't build, just do sanity checks.",
)
p.add_argument(
"-R",
"--release",
action="store_true",
help="Compile in 'release' mode (where necessary).",
)
p.add_argument(
"-G",
"--global",
type=argparse.FileType("r"),
help="Override the global.yaml file",
)
p.add_argument("-r", "--repo", help="Git repo to generate output from")
p.add_argument(
"-b", "--branch", default="master", help="Git branch (default: master)"
)
p.add_argument(
"-t",
"--target",
required=True,
choices=gen.generators.keys(),
help="Target output.",
)
p.add_argument(
"-o",
"--output",
default=".",
help="Output directory (default: current working directory)",
)
p.add_argument(
"project",
help="Keyboard generation project (yaml)",
type=argparse.FileType("r", encoding="utf-8"),
default=sys.stdin,
)
p.add_argument(
"-f",
"--flag",
nargs="*",
dest="flags",
help="Generator-specific flags (for debugging)",
default=[],
)
p.add_argument("--github-username", help="GitHub username for source getting")
p.add_argument("--github-token", help="GitHub token for source getting")
p.add_argument("-c", "--command", help="Command to run for a given generators")
p.add_argument("--ci", action="store_true", help="Continuous integration build")
return p.parse_args()
def run_cli():
args = parse_args()
logger.setLevel(args.logging)
try:
project = Parser().parse(args.project, args.cfg_pairs)
if project is None:
raise Exception("Project parser returned empty project.")
except yaml.scanner.ScannerError as e:
logger.critical(
"Error parsing project:\n%s %s"
% (str(e.problem).strip(), str(e.problem_mark).strip())
)
return 1
except Exception as e:
if logger.getEffectiveLevel() < 10:
raise e
logger.critical(e)
# Short-circuit for user-caused exceptions
if isinstance(e, UserException):
return 1
logger.critical(
"You should not be seeing this error. Please report this as a bug."
)
logger.critical("URL: https://github.com/divvun/kbdgen/issues/")
return 1
generator = gen.generators.get(args.target, None)
if generator is None:
print("Error: '%s' is not a valid target." % args.target, file=sys.stderr)
print("Valid targets: %s" % ", ".join(gen.generators.keys()), file=sys.stderr)
return 1
x = generator(project, dict(args._get_kwargs()))
try:
x.generate(x.output_dir)
except KbdgenException as e:
logger.error(e)
PK ! z kbdgen/filecache/__init__.pyimport sys
import os
import hashlib
import requests
from requests.auth import HTTPBasicAuth
import tempfile
import shutil
from urllib.parse import urlparse
from pathlib import Path
from ..base import get_logger
from .downloader import stream_download
logger = get_logger(__file__)
if sys.platform.startswith("win"):
default_cache_dir = Path(os.getenv("LOCALAPPDATA")) / "kbdgen" / "cache"
elif sys.platform.startswith("darwin"):
default_cache_dir = Path(os.getenv("HOME")) / "Library" / "Caches" / "kbdgen"
else:
default_cache_dir = Path(os.getenv("HOME")) / ".cache" / "kbdgen"
class FileCache:
def __init__(self, cache_dir=default_cache_dir):
self.cache_dir = Path(cache_dir)
self.ensure_cache_exists()
def ensure_cache_exists(self):
if not self.cache_dir.exists():
os.makedirs(str(self.cache_dir), exist_ok=True)
def is_cached_valid(self, filename: str, sha256sum: str) -> bool:
candidate = self.cache_dir / filename
if not candidate.exists():
return False
if sha256sum is None:
return True
m = hashlib.sha256()
with candidate.open("rb") as f:
m.update(f.read())
new_sum = m.hexdigest()
logger.debug("SHA256: %s", new_sum)
return new_sum == sha256sum
def save_directory_tree(self, id: str, basepath: str, tree: str):
logger.debug(
"Inject directory tree - id: %s, base: %s, tree: %s" % (id, basepath, tree)
)
target = self.cache_dir / id / os.path.relpath(tree, basepath)
logger.debug("src: %s, dst: %s" % (tree, target))
target.mkdir(parents=True, exist_ok=True)
shutil.rmtree(target, ignore_errors=True)
shutil.copytree(tree, target)
def inject_directory_tree(self, id: str, tree: str, base_target: str) -> bool:
logger.debug(
"Inject directory tree: id: %s, tree: %s, base_target: %s"
% (id, tree, base_target)
)
tree_path = os.path.relpath(tree, base_target)
src = self.cache_dir / id / tree_path
target = Path(base_target) / Path(tree_path)
logger.debug("src: %s, target: %s" % (src, target))
# TODO: this does not check if the directory has even a single file in it...
if not src.exists():
return False
os.makedirs(str(target), exist_ok=True)
shutil.rmtree(str(target), ignore_errors=True)
logger.debug("Copying '%s' to '%s'" % (src, target))
shutil.copytree(str(src), str(target))
return True
def download(self, raw_url: str, sha256sum: str) -> str:
url = urlparse(raw_url)
filename = Path(url.path).name
candidate = str(self.cache_dir / filename)
if self.is_cached_valid(filename, sha256sum):
return candidate
logger.info("Downloading '%s'…" % filename)
stream_download(raw_url, filename, candidate)
if not self.is_cached_valid(filename, sha256sum):
raise Exception("Cached file '%s' has failed integrity checks." % filename)
return candidate
def download_latest_from_github(
self,
repo: str,
branch: str = "master",
username: str = None,
password: str = None,
) -> str:
url = "https://api.github.com/repos/{repo}/commits/{branch}".format(
repo=repo, branch=branch
)
if username is not None and password is not None:
repo_meta = requests.get(url, auth=HTTPBasicAuth(username, password)).json()
else:
repo_meta = requests.get(url).json()
sha = repo_meta.get("sha", None)
if sha is None:
raise Exception("No sha found in response: %r" % sha)
filename = "%s-%s.tgz" % (repo.replace("/", "-"), sha)
candidate = str(self.cache_dir / filename)
if self.is_cached_valid(filename, None):
return candidate
download_url = "https://api.github.com/repos/{repo}/tarball/{branch}".format(
repo=repo, branch=branch
)
logger.debug("Download URL: %s" % download_url)
with tempfile.TemporaryDirectory() as tmpdir:
fp = os.path.join(tmpdir, filename)
stream_download(download_url, filename, fp)
shutil.move(fp, candidate)
return candidate
PK ! 4!(
(
kbdgen/filecache/downloader.pyimport humanize
import requests
import sys
import shutil
clr_line = "%c[2K\r" % 27
blocks = " ▏▎▍▌▋▊▉█"
def stream_download(url: str, fn: str, output_file: str):
r = requests.get(url, stream=True)
with open(output_file, "wb") as f:
i = 0
block_size = 1024
content_len = None
for block in r.iter_content(block_size):
if not block:
continue
f.write(block)
if content_len is None:
content_len = r.headers.get("content-length", None)
if content_len is not None:
max_size_raw = int(content_len)
i = min(max_size_raw, i + block_size)
write_download_progress(fn, i, max_size_raw)
print()
def generate_prog_bar(width: int, cur_sz: int, max_sz: int):
if width < 1:
return ""
units = width
t = cur_sz / max_sz * units
if t == 0:
bars = "▏"
else:
bars = "█" * int(t)
et = t - int(t)
if et > 0:
extra = int(len(blocks) * et)
bars += blocks[extra]
return ("{:<%d}" % units).format(bars)
def truncate_middle(text: str, sz: int) -> str:
if len(text) <= sz:
return text
split = sz // 2
left = split
right = split
if sz % 2 == 0:
left -= 1
return "%s…%s" % (text[:left], text[-right:])
def write_download_progress(fn: str, cur_sz: int, max_sz: int):
if cur_sz < 1000:
c = "%s B" % cur_sz
else:
c = humanize.naturalsize(min(cur_sz, max_sz))
m = humanize.naturalsize(max_sz)
pc = "%.0f" % min(cur_sz / max_sz * 100.0, 100.0)
w = min(shutil.get_terminal_size().columns, 80)
max_fn_len = w // 3
fn = truncate_middle(fn, max_fn_len)
pc_len = 4
space_punc_len = 7
msg_len = len(fn) + max(len(c) + len(m), 18) + pc_len + space_punc_len
prog = generate_prog_bar(w - msg_len, cur_sz, max_sz)
msg = "{clr}{fn}: {pc:>3}% {prog}▏ {frac:<18}".format(
clr=clr_line, fn=fn, prog=prog, frac="{cur}/{max}".format(cur=c, max=m), pc=pc
)
sys.stdout.write(msg)
sys.stdout.flush()
def test_download_bar():
import time
import random
fsize = 2717221829
i = 0
while i < fsize:
write_download_progress("a-ridiculous-name-that-was-truncated.tgz", i, fsize)
time.sleep(random.random() / 500)
i += random.randint(0, 250000)
i = fsize
write_download_progress("a-ridiculous-name-that-was-truncated.tgz", i, fsize)
print()
if __name__ == "__main__":
test_download_bar()
PK ! r{ { kbdgen/gen/__init__.pyfrom collections import OrderedDict
from .ios import AppleiOSGenerator
from .android import AndroidGenerator
from .win import WindowsGenerator
from .osx import OSXGenerator
from .x11 import XKBGenerator
from .svgkbd import SVGGenerator
from .json import JSONGenerator
from .errormodel import ErrorModelGenerator
generators = OrderedDict(
(
("win", WindowsGenerator),
("osx", OSXGenerator),
("x11", XKBGenerator),
("svg", SVGGenerator),
("android", AndroidGenerator),
("ios", AppleiOSGenerator),
("json", JSONGenerator),
("errormodel", ErrorModelGenerator),
)
)
PK ! R˦u u kbdgen/gen/android.pyimport copy
import os.path
import shutil
import sys
import glob
import subprocess
from collections import defaultdict
from pathlib import Path
from lxml import etree
from lxml.etree import Element, SubElement
import tarfile
import tempfile
from .base import Generator, run_process
from ..filecache import FileCache
from ..base import get_logger
from .. import boolmap
logger = get_logger(__file__)
ANDROID_GLYPHS = {}
for api in (21, 23):
with open(
os.path.join(
os.path.dirname(__file__), "bin", "android-glyphs-api%s.bin" % api
),
"rb",
) as f:
ANDROID_GLYPHS[api] = boolmap.BoolMap(f.read())
class AndroidGenerator(Generator):
REPO = "giella-ime"
ANDROID_NS = "http://schemas.android.com/apk/res/android"
NS = "http://schemas.android.com/apk/res-auto"
def _element(self, *args, **kwargs):
o = {}
for k, v in kwargs.items():
if k in ["keySpec", "additionalMoreKeys", "keyHintLabel"] and v in [
"#",
"@",
]:
v = "\\" + v
o["{%s}%s" % (self.NS, k)] = v
return Element(*args, **o)
def _android_subelement(self, *args, **kwargs):
o = {}
for k, v in kwargs.items():
if k == "keySpec" and v in ["#", "@"]:
v = "\\" + v
o["{%s}%s" % (self.ANDROID_NS, k)] = v
return SubElement(*args, **o)
def _subelement(self, *args, **kwargs):
o = {}
for k, v in kwargs.items():
if k == "keySpec" and v in ["#", "@"]:
v = "\\" + v
o["{%s}%s" % (self.NS, k)] = v
return SubElement(*args, **o)
def _tostring(self, tree):
return etree.tostring(
tree, pretty_print=True, xml_declaration=True, encoding="utf-8"
).decode()
@property
def _version(self):
return self._project.target("android").get("version", self._project.version)
@property
def _build(self):
return self._project.target("android").get("build", self._project.build)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.cache = FileCache()
def generate(self, base="."):
if not self.sanity_check():
logger.error("Sanity checks failed; aborting.")
return
if self.dry_run:
logger.info("Dry run completed.")
return
deps_dir = os.path.join(base, "deps")
self.repo_dir = os.path.join(deps_dir, self.REPO)
os.makedirs(deps_dir, exist_ok=True)
tree_id = self.get_source_tree(base)
self.native_locale_workaround(base)
dsn = self._project.target("android").get("sentryDsn", None)
if dsn is not None:
self.add_sentry_dsn(dsn, base)
styles = [("phone", "xml"), ("tablet", "xml-sw600dp")]
files = []
layouts = defaultdict(list)
logger.info("Updating XML strings…")
for name, kbd in self.supported_layouts.items():
files += [
(
"app/src/main/res/xml/keyboard_layout_set_%s.xml" % name.lower(),
self.kbd_layout_set(kbd),
),
("app/src/main/res/xml/kbd_%s.xml" % name.lower(), self.keyboard(kbd)),
]
for style, prefix in styles:
self.gen_key_width(kbd, style)
files.append(
(
"app/src/main/res/%s/rows_%s.xml" % (prefix, name.lower()),
self.rows(kbd, style),
)
)
for row in self.rowkeys(kbd, style):
row = ("app/src/main/res/%s/%s" % (prefix, row[0]), row[1])
files.append(row)
layouts[kbd.target("android").get("minimumSdk", None)].append(kbd)
self.update_strings_xml(kbd, base)
self.update_method_xmls(layouts, base)
self.create_gradle_properties(base, self.is_release)
self.save_files(files, base)
# Add zhfst files if found
self.add_zhfst_files(base)
# self.update_dict_authority(base)
self.update_localisation(base)
self.generate_icons(base)
self.build(base, tree_id, self.is_release)
def native_locale_workaround(self, base):
for name, kbd in self.supported_layouts.items():
if len(kbd.locale) <= 2:
continue
# locale = 'zz_%s' % kbd.locale
# kbd.display_names[locale] = kbd.display_names[kbd.locale]
# kbd._tree['locale'] = locale
self.update_locale_exception(kbd, base)
def sanity_check(self):
if super().sanity_check() is False:
return False
sane = True
if os.environ.get("JAVA_HOME", None) and not shutil.which("java"):
logger.error("`java` not found on path and JAVA_HOME not set.")
sane = False
if os.environ.get("ANDROID_HOME", None) is None:
logger.error("ANDROID_HOME must be provided and point to the Android SDK directory.")
sane = False
if os.environ.get("NDK_HOME", None) is None:
logger.error("NDK_HOME must be provided and point to the Android NDK directory.")
sane = False
if self.is_release:
key_store_path = self.environ_or_target("ANDROID_KEYSTORE", "keyStore")
if key_store_path is None:
logger.error(
"A keystore must be provided with target property `keyStore` "
+ "or environment variable `ANDROID_KEYSTORE` for release builds."
)
sane = False
key_alias = self.environ_or_target("ANDROID_KEYALIAS", "keyAlias")
if key_alias is None:
logger.error(
"A key alias must be provided with target property `keyAlias` "
+ "or environment variable `ANDROID_KEYALIAS` for release builds."
)
sane = False
store_pw = os.environ.get("STORE_PW", None)
key_pw = os.environ.get("KEY_PW", None)
if store_pw is None or key_pw is None:
logger.error("STORE_PW and KEY_PW must be set for a release build.")
sane = False
pid = self._project.target("android").get("packageId")
if pid is None:
sane = False
logger.error("No package ID provided for Android target.")
for name, kbd in self.supported_layouts.items():
for dn_locale in kbd.display_names:
if dn_locale in ["zz", kbd.locale]:
continue
for mode, rows in kbd.modes.items():
for n, row in enumerate(rows):
if len(row) > 12:
logger.warning(
(
"[%s] row %s has %s keys. It is "
+ "recommended to have 12 keys or less per "
+ "row."
)
% (name, n + 1, len(row))
)
for api_v in [21, 23]:
if not self.detect_unavailable_glyphs(kbd, api_v):
sane = False
return sane
def _update_dict_auth_xml(self, auth, base):
path = os.path.join(
base, "deps", self.REPO, "app/src/main/res/values/dictionary-pack.xml"
)
with open(path) as f:
tree = etree.parse(f)
nodes = tree.xpath("string[@name='authority']")
if len(nodes) == 0:
logger.error("No authority string found in XML!")
return
nodes[0].text = auth
with open(path, "w") as f:
f.write(self._tostring(tree))
def _update_dict_auth_java(self, auth, base):
# ಠ_ಠ
target = "com.android.inputmethod.dictionarypack.aosp"
# (╯°□°)╯︵ ┻━┻
src_path = (
"app/src/main/java/com/android/inputmethod/"
+ "dictionarypack/DictionaryPackConstants.java"
)
path = os.path.join(base, "deps", self.REPO, src_path)
# (┛◉Д◉)┛彡┻━┻
with open(path) as f:
o = f.read().replace(target, auth)
with open(path, "w") as f:
f.write(o)
def update_dict_authority(self, base):
auth = "%s.dictionarypack" % self._project.target("android")["packageId"]
logger.info("Updating dict authority string to '%s'…" % auth)
self._update_dict_auth_xml(auth, base)
self._update_dict_auth_java(auth, base)
def add_zhfst_files(self, build_dir):
nm = "app/src/main/assets/dicts"
dict_path = os.path.join(build_dir, "deps", self.REPO, nm)
if os.path.exists(dict_path):
shutil.rmtree(dict_path)
os.makedirs(dict_path, exist_ok=True)
files = glob.glob(os.path.join(self._project.path, "*.zhfst"))
if len(files) == 0:
logger.warning("No ZHFST files found.")
return
path = os.path.join(
build_dir, "deps", self.REPO, "app/src/main/res", "xml", "spellchecker.xml"
)
with open(path) as f:
tree = etree.parse(f)
root = tree.getroot()
# Empty the file
for child in root:
root.remove(child)
for fn in files:
bfn = os.path.basename(fn)
logger.info("Adding '%s' to '%s'…" % (bfn, nm))
shutil.copyfile(fn, os.path.join(dict_path, bfn))
lang, _ = os.path.splitext(os.path.basename(fn))
if len(lang) > 2:
lang = "zz_%s" % lang.upper()
self._android_subelement(
root, "subtype", label="@string/subtype_generic", subtypeLocale=lang
)
with open(path, "w") as f:
f.write(self._tostring(tree))
def _update_locale(self, d, values):
fn = os.path.join(d, "strings-appname.xml")
node = None
if os.path.exists(fn):
with open(fn) as f:
tree = etree.parse(f)
nodes = tree.xpath("string[@name='english_ime_name']")
if len(nodes) > 0:
node = nodes[0]
else:
tree = etree.XML("")
if node is None:
node = SubElement(tree, "string", name="english_ime_name")
node.text = values["name"].replace("'", r"\'")
with open(fn, "w") as f:
f.write(self._tostring(tree))
def update_localisation(self, base):
res_dir = os.path.join(base, "deps", self.REPO, "app/src/main/res")
logger.info("Updating localisation values…")
self._update_locale(
os.path.join(res_dir, "values"), self._project.locales["en"]
)
for locale, values in self._project.locales.items():
d = os.path.join(res_dir, "values-%s" % locale)
if os.path.isdir(d):
self._update_locale(d, values)
def generate_icons(self, base):
icon = self._project.icon("android")
if icon is None:
logger.warning("no icon supplied!")
return
res_dir = os.path.join(base, "deps", self.REPO, "app/src/main/res")
cmd_tmpl = "convert -resize %dx%d %s %s"
for suffix, dimen in (
("mdpi", 48),
("hdpi", 72),
("xhdpi", 96),
("xxhdpi", 144),
("xxxhdpi", 192),
):
mipmap_dir = "drawable-%s" % suffix
cmd = cmd_tmpl % (
dimen,
dimen,
icon,
os.path.join(res_dir, mipmap_dir, "ic_launcher_keyboard.png"),
)
logger.info("Creating '%s' at size %dx%d" % (mipmap_dir, dimen, dimen))
process = subprocess.Popen(
cmd, shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE
)
out, err = process.communicate()
if process.returncode != 0:
logger.error(err.decode())
logger.error(
"Application ended with error code %s." % process.returncode
)
# TODO throw exception instead.
sys.exit(process.returncode)
def _gradle(self, *args):
# HACK: let's be honest it's all hacks
with open(os.path.join(self.repo_dir, "local.properties"), "a") as f:
f.write("sdk.dir=%s\n" % os.environ["ANDROID_HOME"])
cmd = ["./gradlew"] + list(args) + ["-Dorg.gradle.jvmargs=-Xmx4096M"]
return run_process(cmd, cwd=self.repo_dir, show_output=True) == 0
def build(self, base, tree_id, release_mode=True):
targets = [
("armv7-linux-androideabi", "armeabi-v7a"),
("aarch64-linux-android", "arm64-v8a"),
]
res_dir = os.path.join(base, "deps", self.REPO, "app/src/main/jniLibs")
cwd = os.path.join(self.repo_dir, "..", "hfst-ospell-rs")
if not self.cache.inject_directory_tree(tree_id, res_dir, self.repo_dir):
logger.info("Building native components…")
for (target, jni_name) in targets:
logger.info("Building %s architecture…" % target)
run_process(
[
"cargo",
"ndk",
"--android-platform",
"21",
"--target",
target,
"--",
"build",
"--release",
],
cwd=cwd,
show_output=True,
)
jni_dir = os.path.join(res_dir, jni_name)
Path(jni_dir).mkdir(parents=True, exist_ok=True)
shutil.copyfile(
os.path.join(cwd, "target", target, "release/libhfstospell.so"),
os.path.join(jni_dir, "libhfstospell.so"),
)
self.cache.save_directory_tree(tree_id, self.repo_dir, res_dir)
else:
logger.info("Native components copied from cache.")
logger.info("Generating .apk…")
if not self._gradle("assembleRelease" if release_mode else "assembleDebug"):
return 1
if not release_mode:
suffix = "debug"
else:
suffix = "release"
path = os.path.join(base, "deps", self.REPO, "app/build/outputs/apk", suffix)
fn = "app-%s.apk" % suffix
out_fn = os.path.join(
base, "%s-%s_%s.apk" % (self._project.internal_name, self._version, suffix)
)
logger.info("Copying '%s' -> '%s'…" % (fn, out_fn))
os.makedirs(base, exist_ok=True)
shutil.copy(os.path.join(path, fn), out_fn)
def _str_xml(self, val_dir, name, subtype):
os.makedirs(val_dir, exist_ok=True)
fn = os.path.join(val_dir, "strings.xml")
if not os.path.exists(fn):
root = etree.XML("")
else:
with open(fn) as f:
root = etree.parse(f).getroot()
SubElement(root, "string", name="subtype_%s" % subtype).text = name
with open(fn, "w") as f:
f.write(self._tostring(root))
def update_locale_exception(self, kbd, base):
res_dir = os.path.join(base, "deps", self.REPO, "app/src/main/res")
fn = os.path.join(res_dir, "values", "donottranslate.xml")
logger.info("Adding '%s' to '%s'…" % (kbd.locale, fn))
with open(fn) as f:
tree = etree.parse(f)
# Add to exception keys
node = tree.xpath("string-array[@name='subtype_locale_exception_keys']")[0]
SubElement(node, "item").text = kbd.locale
node = tree.xpath(
"string-array[@name='subtype_locale_displayed_in_root_locale']"
)[0]
SubElement(node, "item").text = kbd.locale
SubElement(
tree.getroot(), "string", name="subtype_in_root_locale_%s" % kbd.locale
).text = kbd.display_names[kbd.locale]
with open(fn, "w") as f:
f.write(self._tostring(tree.getroot()))
def add_sentry_dsn(self, dsn, base):
res_dir = os.path.join(base, "deps", self.REPO, "app/src/main/res")
fn = os.path.join(res_dir, "values", "donottranslate.xml")
logger.info("Adding Sentry DSN to '%s'…" % fn)
with open(fn) as f:
tree = etree.parse(f)
# Add to exception keys
node = tree.xpath("string[@name='sentry_dsn']")[0]
node.text = dsn
with open(fn, "w") as f:
f.write(self._tostring(tree.getroot()))
def update_strings_xml(self, kbd, base):
# TODO sanity check for non-existence directories
# TODO run this only once preferably
res_dir = os.path.join(base, "deps", self.REPO, "app/src/main/res")
for locale, name in kbd.display_names.items():
if len(locale) > 2:
continue
if locale == "en":
val_dir = os.path.join(res_dir, "values")
else:
val_dir = os.path.join(res_dir, "values-%s" % locale)
self._str_xml(val_dir, name, kbd.internal_name.lower())
def gen_method_xml(self, kbds, tree):
root = tree.getroot()
for kbd in kbds:
self._android_subelement(
root,
"subtype",
icon="@drawable/ic_ime_switcher_dark",
label="@string/subtype_%s" % kbd.internal_name.lower(),
imeSubtypeLocale=kbd.locale,
imeSubtypeMode="keyboard",
imeSubtypeExtraValue="KeyboardLayoutSet=%s,AsciiCapable,EmojiCapable"
% kbd.internal_name.lower(),
)
return self._tostring(tree)
def update_method_xmls(self, layouts, base):
# None because no API version specified (nor needed)
base_layouts = layouts[None]
del layouts[None]
logger.info("Updating method definitions…")
path = os.path.join(base, "deps", self.REPO, "app/src/main/res", "%s")
fn = os.path.join(path, "method.xml")
with open(fn % "xml") as f:
tree = etree.parse(f)
root = tree.getroot()
# Empty the method.xml file
for child in root:
root.remove(child)
with open(fn % "xml", "w") as f:
f.write(self.gen_method_xml(base_layouts, tree))
for kl, vl in reversed(sorted(layouts.items())):
for kr, vr in layouts.items():
if kl >= kr:
continue
layouts[kr] = vl + vr
for api_ver, kbds in layouts.items():
xmlv = "xml-v%s" % api_ver
os.makedirs(path % xmlv, exist_ok=True)
with open(fn % xmlv, "w") as f:
f.write(self.gen_method_xml(kbds, copy.deepcopy(tree)))
def save_files(self, files, base):
fn = os.path.join(base, "deps", self.REPO)
logger.info("Embedding generated keyboard XML files…")
for k, v in files:
with open(os.path.join(fn, k), "w") as f:
f.write(v)
def _unfurl_tarball(self, tarball, target_dir):
with tempfile.TemporaryDirectory() as tmpdir:
tarfile.open(tarball, "r:gz").extractall(str(tmpdir))
target = [x for x in Path(tmpdir).iterdir() if x.is_dir()][0]
os.makedirs(str(target_dir.parent), exist_ok=True)
shutil.move(target, target_dir)
def get_source_tree(self, base, repo="divvun/giella-ime", branch="master"):
"""
Downloads the IME source from Github as a tarball, then extracts to deps
dir.
"""
logger.info("Getting source files from %s %s branch…" % (repo, branch))
deps_dir = Path(os.path.join(base, "deps"))
shutil.rmtree(str(deps_dir), ignore_errors=True)
tarball = self.cache.download_latest_from_github(
repo,
branch,
username=self._args.get("github_username", None),
password=self._args.get("github_token", None),
)
hfst_ospell_tbl = self.cache.download_latest_from_github(
"bbqsrc/hfst-ospell-rs",
"master",
username=self._args.get("github_username", None),
password=self._args.get("github_token", None),
)
self._unfurl_tarball(tarball, deps_dir / self.REPO)
shutil.rmtree(str(deps_dir / "../hfst-ospell-rs"), ignore_errors=True)
self._unfurl_tarball(hfst_ospell_tbl, deps_dir / "hfst-ospell-rs")
return hfst_ospell_tbl.split("/")[-1].split(".")[0]
def environ_or_target(self, env_key, target_key):
return os.environ.get(
env_key, self._project.target("android").get(target_key, None)
)
def create_gradle_properties(self, base, release_mode=False):
key_store_path = self.environ_or_target("ANDROID_KEYSTORE", "keyStore") or ""
key_store = self._project.relpath(key_store_path)
logger.debug("Key store: %s" % key_store)
key_alias = self.environ_or_target("ANDROID_KEYALIAS", "keyAlias") or ""
tmpl = """\
ext.app = [
storeFile: "{store_file}",
keyAlias: "{key_alias}",
storePassword: "{store_pw}",
keyPassword: "{key_pw}",
packageName: "{pkg_name}",
versionCode: {build},
versionName: "{version}",
playEmail: "{play_email}",
playCredentials: "{play_creds}"
]
"""
data = tmpl.format(
store_file=os.path.abspath(key_store).replace('"', '\\"'),
key_alias=key_alias.replace('"', '\\"'),
version=self._version,
build=self._build,
pkg_name=self._project.target("android")["packageId"].replace('"', '\\"'),
play_email=os.environ.get("PLAY_STORE_ACCOUNT", "").replace('"', '\\"'),
play_creds=os.environ.get("PLAY_STORE_P12", "").replace('"', '\\"'),
store_pw=os.environ.get("STORE_PW", "").replace('"', '\\"'),
key_pw=os.environ.get("KEY_PW", "").replace('"', '\\"'),
).replace("$", "\\$")
fn = os.path.join(base, "deps", self.REPO, "app/local.gradle")
with open(fn, "w") as f:
f.write(data)
def kbd_layout_set(self, kbd):
out = Element("KeyboardLayoutSet", nsmap={"latin": self.NS})
kbd_str = "@xml/kbd_%s" % kbd.internal_name.lower()
self._subelement(
out,
"Element",
elementName="alphabet",
elementKeyboard=kbd_str,
enableProximityCharsCorrection="true",
)
for name, kbd_str in (
("alphabetAutomaticShifted", kbd_str),
("alphabetManualShifted", kbd_str),
("alphabetShiftLocked", kbd_str),
("alphabetShiftLockShifted", kbd_str),
("symbols", "@xml/kbd_symbols"),
("symbolsShifted", "@xml/kbd_symbols_shift"),
("phone", "@xml/kbd_phone"),
("phoneSymbols", "@xml/kbd_phone_symbols"),
("number", "@xml/kbd_number"),
):
self._subelement(out, "Element", elementName=name, elementKeyboard=kbd_str)
return self._tostring(out)
def row_has_special_keys(self, kbd, n, style):
for key, action in kbd.get_actions(style).items():
if action.row == n:
return True
return False
def rows(self, kbd, style):
out = Element("merge", nsmap={"latin": self.NS})
self._subelement(out, "include", keyboardLayout="@xml/key_styles_common")
for n, values in enumerate(kbd.modes["mobile-default"]):
n += 1
row = self._subelement(out, "Row")
include = self._subelement(
row,
"include",
keyboardLayout="@xml/rowkeys_%s%s" % (kbd.internal_name.lower(), n),
)
if not self.row_has_special_keys(kbd, n, style):
self._attrib(include, keyWidth="%.2f%%p" % (100 / len(values)))
else:
self._attrib(include, keyWidth="%.2f%%p" % self.key_width)
# All the fun buttons!
self._subelement(out, "include", keyboardLayout="@xml/row_qwerty4")
return self._tostring(out)
def gen_key_width(self, kbd, style):
m = 0
for row in kbd.modes["mobile-default"]:
r = len(row)
if r > m:
m = r
vals = {"phone": 95, "tablet": 90}
self.key_width = vals[style] / m
def keyboard(self, kbd, **kwargs):
out = Element("Keyboard", nsmap={"latin": self.NS})
self._attrib(out, **kwargs)
self._subelement(
out, "include", keyboardLayout="@xml/rows_%s" % kbd.internal_name.lower()
)
return self._tostring(out)
def rowkeys(self, kbd, style):
# TODO check that lengths of both modes are the same
for n in range(1, len(kbd.modes["mobile-default"]) + 1):
merge = Element("merge", nsmap={"latin": self.NS})
switch = self._subelement(merge, "switch")
case = self._subelement(
switch,
"case",
keyboardLayoutSetElement="alphabetManualShifted|alphabetShiftLocked|"
+ "alphabetShiftLockShifted",
)
self.add_rows(kbd, n, kbd.modes["mobile-shift"][n - 1], style, case)
default = self._subelement(switch, "default")
self.add_rows(kbd, n, kbd.modes["mobile-default"][n - 1], style, default)
yield (
"rowkeys_%s%s.xml" % (kbd.internal_name.lower(), n),
self._tostring(merge),
)
def _attrib(self, node, **kwargs):
for k, v in kwargs.items():
node.attrib["{%s}%s" % (self.NS, k)] = v
def add_button_type(self, key, action, row, tree, is_start):
node = self._element("Key")
width = action.width
if width == "fill":
if is_start:
width = "%.2f%%" % ((100 - (self.key_width * len(row))) / 2)
else:
width = "fillRight"
elif width.endswith("%"):
width += "p"
if key == "backspace":
self._attrib(node, keyStyle="deleteKeyStyle")
if key == "enter":
self._attrib(node, keyStyle="enterKeyStyle")
if key == "shift":
self._attrib(node, keyStyle="shiftKeyStyle")
self._attrib(node, keyWidth=width)
tree.append(node)
def add_special_buttons(self, kbd, n, style, row, tree, is_start):
side = "left" if is_start else "right"
for key, action in kbd.get_actions(style).items():
if action.row == n and action.position in [side, "both"]:
self.add_button_type(key, action, row, tree, is_start)
def add_rows(self, kbd, n, values, style, out):
i = 1
show_number_hints = kbd.target("android").get("showNumberHints", True)
self.add_special_buttons(kbd, n, style, values, out, True)
for key in values:
more_keys = kbd.get_longpress(key)
node = self._subelement(out, "Key", keySpec=key)
# If top row, and between 0 and 9 keys, show numeric hint
is_numeric = n == 1 and i > 0 and i <= 10
show_glyph_hint = more_keys is not None
if show_glyph_hint:
self._attrib(
node, keyHintLabel=more_keys[0], moreKeys=",".join(more_keys)
)
if is_numeric:
# Handle 0 being last on a keyboard case
if i == 10:
i = 0
self._attrib(node, additionalMoreKeys=str(i))
if show_number_hints:
self._attrib(node, keyHintLabel=str(i))
if i > 0:
i += 1
self.add_special_buttons(kbd, n, style, values, out, False)
def detect_unavailable_glyphs(self, layout, api_ver):
if layout.target("android").get("minimumSdk", 0) > api_ver:
return True
glyphs = ANDROID_GLYPHS.get(api_ver, None)
has_error = False
if glyphs is None:
logger.warning(
(
"no glyphs file found for API %s! Can't detect "
+ "missing characters from Android font!"
)
% api_ver
)
return
for mode_name, vals in layout.modes.items():
for v in vals:
for c in v:
if len(c) > 1:
logger.debug("%s is several glyphs?" % c)
continue
if glyphs[ord(c)] is False:
logger.error(
(
"[%s] Key '%s' (codepoint: U+%04X) "
"is not supported by API %s! Set minimumSdk "
"to suppress this error."
)
% (layout.internal_name, c, ord(c), api_ver)
)
has_error = True
for vals in layout.longpress.values():
for v in vals:
for c in v:
if glyphs[ord(c)] is False:
logger.debug(
(
"[%s] Long press key '%s' (codepoint: U+%04X) "
+ "is not supported by API %s!"
)
% (layout.internal_name, c, ord(c), api_ver)
)
return not has_error
PK ! kbdgen/gen/base.pyimport itertools
import logging
import os
import os.path
import subprocess
import sys
from functools import lru_cache
from collections import OrderedDict
from ..base import ISO_KEYS, KbdgenException
logger = logging.getLogger()
class MissingApplicationException(KbdgenException):
pass
class GenerationError(KbdgenException):
pass
def bind_iso_keys(other):
return OrderedDict(((k, v) for k, v in zip(ISO_KEYS, other)))
class Generator:
def __init__(self, project, args=None):
self._project = project
self._args = args or {}
@property
def repo(self):
return self._args.get("repo", None)
@property
def branch(self):
return self._args.get("branch", "master")
@property
def is_release(self):
return self._args.get("release", False)
@property
def dry_run(self):
return self._args.get("dry_run", False)
@property
def output_dir(self):
return self._args.get("output", ".")
@property
@lru_cache(maxsize=1)
def supported_layouts(self):
t = self._args["target"]
o = OrderedDict()
for k, v in self._project.layouts.items():
if v.supported_target(t):
o[k] = v
return o
def sanity_check(self) -> bool:
if len(self.supported_layouts) == 0:
logger.error("This project defines no supported layouts for this target.")
return False
else:
logger.debug("Supported layouts: %s" % ", ".join(self.supported_layouts))
return True
class PhysicalGenerator(Generator):
def validate_layout(self, layout):
# TODO finish cls-based validate_layout
mode_keys = set(layout.modes.keys())
deadkey_keys = set(layout.dead_keys.keys())
undefined_modes = deadkey_keys - mode_keys
if len(undefined_modes) > 0:
raise Exception(
"Dead key modes are defined for undefined modes: %r"
% (list(undefined_modes),)
)
for mode, keys in layout.dead_keys.items():
dead_keys = set(keys)
layer_keys = set(layout.modes[mode].values())
matched_keys = dead_keys & layer_keys
if matched_keys != dead_keys:
raise Exception(
"Specified dead keys missing from mode %r: %r"
% (mode, list(dead_keys - matched_keys))
)
class TouchGenerator(Generator):
def validate_layout(self):
pass
MSG_LAYOUT_MISSING = "Layout '%s' is missing a required mode: '%s'."
def mode_iter(keyboard, key, required=False):
mode = keyboard.modes.get(key, None)
if mode is None:
if required:
raise GenerationError(MSG_LAYOUT_MISSING % (keyboard.internal_name, key))
return itertools.repeat(None)
return mode.values()
def mode_dict(keyboard, key, required=False, space=False):
mode = keyboard.modes.get(key, None)
if mode is None:
if required:
raise GenerationError(MSG_LAYOUT_MISSING % (keyboard.internal_name, key))
return OrderedDict(zip(ISO_KEYS, itertools.repeat(None)))
if space:
sp = keyboard.special.get("space", {}).get(key, " ")
mode["A03"] = sp
return mode
def git_update(dst, branch, clean, cwd=".", logger=print):
msg = "Updating repository '%s'…" % dst
logger(msg)
cmd = """git reset --hard &&
git fetch --all &&
git checkout %s &&
%s
git pull &&
git submodule init &&
git submodule sync &&
git submodule update""" % (
branch,
"git clean -fdx &&" if clean else "",
)
cmd = cmd.replace("\n", " ")
cwd = os.path.join(cwd, dst)
# TODO error checking
process = subprocess.Popen(cmd, cwd=cwd, shell=True)
process.wait()
def git_clone(src, dst, branch, clean, cwd=".", logger=print):
msg = "Cloning repository '%s' to '%s'…" % (src, dst)
logger(msg)
cmd = ["git", "clone", src, dst]
# TODO error checking
process = subprocess.Popen(cmd, cwd=cwd)
process.wait()
# Silence logger for update.
git_update(dst, branch, cwd, logger=lambda x: None)
def iterable_set(iterable):
return {i for i in itertools.chain.from_iterable(iterable)}
def filepath(fp, *args):
return os.path.join(os.path.dirname(fp), *args)
class DictWalker:
def on_branch(self, base, branch):
return base, branch
def on_leaf(self, base, branch, leaf):
return base, branch, leaf
def __init__(self, dict_):
self._dict = dict_
def __iter__(self):
def walk(dict_, buf):
for k, v in dict_.items():
if isinstance(v, dict):
c = yield self.on_branch(tuple(buf), k)
if c is False:
continue
nbuf = buf[:]
nbuf.append(k)
for vv in walk(v, nbuf):
yield vv
elif isinstance(v, (int, str)):
yield self.on_leaf(tuple(buf), k, v)
else:
raise TypeError(v)
for v in walk(self._dict, []):
yield v
def __call__(self):
# Run iterator to death
for _ in self:
pass
def run_process(cmd, cwd=None, show_output=False, return_process=False, shell=False):
try:
process = subprocess.Popen(
cmd,
shell=shell,
cwd=str(cwd) if cwd is not None else None,
stderr=None if show_output else subprocess.PIPE,
stdout=None if show_output else subprocess.PIPE,
)
except Exception as e:
logger.error("Process failed to launch with the following error message:")
logger.error(e)
sys.exit(1)
if return_process:
return process
if show_output:
process.wait()
return process.returncode
else:
out, err = process.communicate()
if process.returncode != 0:
x = err.decode()
if x.strip() == "":
x = out.decode()
logger.error(x)
logger.error("Application ended with error code %s." % (process.returncode))
sys.exit(process.returncode)
return out, err
PK ! ?B> > ' kbdgen/gen/bin/android-glyphs-api21.bin y == =` =`@ } ? %;_? ? 7pD ? 0@QM====== ? x ?????? ?0 ? u| xc2 ? `0@ ,$ 0 ` p ! ? ~ @ &