jrnl/jrnl/util.py
Eshan 0b9137c17d Fix set_keychain errors (#964)
* fix keyring problems
* black
* remove else and use stderr
* black
* add tests
* black
* change description of nokeyring
* dumb syntax error
2020-05-30 12:43:10 -07:00

291 lines
9.2 KiB
Python

#!/usr/bin/env python
import getpass as gp
import logging
import os
import re
import shlex
from string import punctuation, whitespace
import subprocess
import sys
import tempfile
import textwrap
from typing import Callable, Optional
import unicodedata
import colorama
import yaml
if "win32" in sys.platform:
colorama.init()
log = logging.getLogger(__name__)
WARNING_COLOR = colorama.Fore.YELLOW
ERROR_COLOR = colorama.Fore.RED
RESET_COLOR = colorama.Fore.RESET
# Based on Segtok by Florian Leitner
# https://github.com/fnl/segtok
SENTENCE_SPLITTER = re.compile(
r"""
( # A sentence ends at one of two sequences:
[.!?\u203C\u203D\u2047\u2048\u2049\u3002\uFE52\uFE57\uFF01\uFF0E\uFF1F\uFF61] # Either, a sequence starting with a sentence terminal,
[\'\u2019\"\u201D]? # an optional right quote,
[\]\)]* # optional closing brackets and
\s+ # a sequence of required spaces.
)""",
re.VERBOSE,
)
SENTENCE_SPLITTER_ONLY_NEWLINE = re.compile("\n")
class UserAbort(Exception):
pass
def create_password(
journal_name: str, prompt: str = "Enter password for new journal: "
) -> str:
while True:
pw = gp.getpass(prompt)
if not pw:
print("Password can't be an empty string!", file=sys.stderr)
continue
elif pw == gp.getpass("Enter password again: "):
break
print("Passwords did not match, please try again", file=sys.stderr)
if yesno("Do you want to store the password in your keychain?", default=True):
set_keychain(journal_name, pw)
return pw
def decrypt_content(
decrypt_func: Callable[[str], Optional[str]],
keychain: str = None,
max_attempts: int = 3,
) -> str:
pwd_from_keychain = keychain and get_keychain(keychain)
password = pwd_from_keychain or gp.getpass()
result = decrypt_func(password)
# Password is bad:
if result is None and pwd_from_keychain:
set_keychain(keychain, None)
attempt = 1
while result is None and attempt < max_attempts:
print("Wrong password, try again.", file=sys.stderr)
password = gp.getpass()
result = decrypt_func(password)
attempt += 1
if result is not None:
return result
else:
print("Extremely wrong password.", file=sys.stderr)
sys.exit(1)
def get_keychain(journal_name):
import keyring
try:
return keyring.get_password("jrnl", journal_name)
except RuntimeError:
return ""
def set_keychain(journal_name, password):
import keyring
if password is None:
try:
keyring.delete_password("jrnl", journal_name)
except keyring.errors.PasswordDeleteError:
pass
else:
try:
keyring.set_password("jrnl", journal_name, password)
except keyring.errors.NoKeyringError:
print(
"Keyring backend not found. Please install one of the supported backends by visiting: https://pypi.org/project/keyring/",
file=sys.stderr,
)
def yesno(prompt, default=True):
prompt = f"{prompt.strip()} {'[Y/n]' if default else '[y/N]'} "
response = input(prompt)
return {"y": True, "n": False}.get(response.lower().strip(), default)
def load_config(config_path):
"""Tries to load a config file from YAML.
"""
with open(config_path) as f:
return yaml.load(f, Loader=yaml.FullLoader)
def is_config_json(config_path):
with open(config_path, "r", encoding="utf-8") as f:
config_file = f.read()
return config_file.strip().startswith("{")
def is_old_version(config_path):
return is_config_json(config_path)
def scope_config(config, journal_name):
if journal_name not in config["journals"]:
return config
config = config.copy()
journal_conf = config["journals"].get(journal_name)
if (
type(journal_conf) is dict
): # We can override the default config on a by-journal basis
log.debug(
"Updating configuration with specific journal overrides %s", journal_conf
)
config.update(journal_conf)
else: # But also just give them a string to point to the journal file
config["journal"] = journal_conf
config.pop("journals")
return config
def verify_config(config):
"""
Ensures the keys set for colors are valid colorama.Fore attributes, or "None"
:return: True if all keys are set correctly, False otherwise
"""
all_valid_colors = True
for key, color in config["colors"].items():
upper_color = color.upper()
if upper_color == "NONE":
continue
if not getattr(colorama.Fore, upper_color, None):
print(
"[{2}ERROR{3}: {0} set to invalid color: {1}]".format(
key, color, ERROR_COLOR, RESET_COLOR
),
file=sys.stderr,
)
all_valid_colors = False
return all_valid_colors
def get_text_from_editor(config, template=""):
filehandle, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=".txt")
os.close(filehandle)
with open(tmpfile, "w", encoding="utf-8") as f:
if template:
f.write(template)
try:
subprocess.call(
shlex.split(config["editor"], posix="win32" not in sys.platform) + [tmpfile]
)
except Exception as e:
error_msg = f"""
{ERROR_COLOR}{str(e)}{RESET_COLOR}
Please check the 'editor' key in your config file for errors:
{repr(config['editor'])}
"""
print(textwrap.dedent(error_msg).strip(), file=sys.stderr)
exit(1)
with open(tmpfile, "r", encoding="utf-8") as f:
raw = f.read()
os.remove(tmpfile)
if not raw:
print("[Nothing saved to file]", file=sys.stderr)
return raw
def colorize(string, color, bold=False):
"""Returns the string colored with colorama.Fore.color. If the color set by
the user is "NONE" or the color doesn't exist in the colorama.Fore attributes,
it returns the string without any modification."""
color_escape = getattr(colorama.Fore, color.upper(), None)
if not color_escape:
return string
elif not bold:
return color_escape + string + colorama.Fore.RESET
else:
return colorama.Style.BRIGHT + color_escape + string + colorama.Style.RESET_ALL
def highlight_tags_with_background_color(entry, text, color, is_title=False):
"""
Takes a string and colorizes the tags in it based upon the config value for
color.tags, while colorizing the rest of the text based on `color`.
:param entry: Entry object, for access to journal config
:param text: Text to be colorized
:param color: Color for non-tag text, passed to colorize()
:param is_title: Boolean flag indicating if the text is a title or not
:return: Colorized str
"""
def colorized_text_generator(fragments):
"""Efficiently generate colorized tags / text from text fragments.
Taken from @shobrook. Thanks, buddy :)
:param fragments: List of strings representing parts of entry (tag or word).
:rtype: List of tuples
:returns [(colorized_str, original_str)]"""
for part in fragments:
if part and part[0] not in config["tagsymbols"]:
yield (colorize(part, color, bold=is_title), part)
elif part:
yield (colorize(part, config["colors"]["tags"], bold=True), part)
config = entry.journal.config
if config["highlight"]: # highlight tags
text_fragments = re.split(entry.tag_regex(config["tagsymbols"]), text)
# Colorizing tags inside of other blocks of text
final_text = ""
previous_piece = ""
for colorized_piece, piece in colorized_text_generator(text_fragments):
# If this piece is entirely punctuation or whitespace or the start
# of a line or the previous piece was a tag or this piece is a tag,
# then add it to the final text without a leading space.
if (
all(char in punctuation + whitespace for char in piece)
or previous_piece.endswith("\n")
or (previous_piece and previous_piece[0] in config["tagsymbols"])
or piece[0] in config["tagsymbols"]
):
final_text += colorized_piece
else:
# Otherwise add a leading space and then append the piece.
final_text += " " + colorized_piece
previous_piece = piece
return final_text.lstrip()
else:
return text
def slugify(string):
"""Slugifies a string.
Based on public domain code from https://github.com/zacharyvoase/slugify
"""
normalized_string = str(unicodedata.normalize("NFKD", string))
no_punctuation = re.sub(r"[^\w\s-]", "", normalized_string).strip().lower()
slug = re.sub(r"[-\s]+", "-", no_punctuation)
return slug
def split_title(text):
"""Splits the first sentence off from a text."""
sep = SENTENCE_SPLITTER_ONLY_NEWLINE.search(text.lstrip())
if not sep:
sep = SENTENCE_SPLITTER.search(text)
if not sep:
return text, ""
return text[: sep.end()].strip(), text[sep.end() :].strip()