snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
0
tools/lib/__init__.py
Normal file
0
tools/lib/__init__.py
Normal file
341
tools/lib/capitalization.py
Normal file
341
tools/lib/capitalization.py
Normal file
@@ -0,0 +1,341 @@
|
||||
import re
|
||||
from io import StringIO
|
||||
from re import Match
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# The phrases in this list will be ignored. The longest phrase is
|
||||
# tried first; this removes the chance of smaller phrases changing
|
||||
# the text before longer phrases are tried.
|
||||
# The errors shown by `tools/check-capitalization` can be added to
|
||||
# this list without any modification.
|
||||
IGNORED_PHRASES = [
|
||||
# Proper nouns and acronyms
|
||||
r"AI",
|
||||
r"API",
|
||||
r"APNS",
|
||||
r"Apple Silicon",
|
||||
r"Botserver",
|
||||
r"Cookie Bot",
|
||||
r"DevAuthBackend",
|
||||
r"DSN",
|
||||
r"Esc",
|
||||
r"GCM",
|
||||
r"GitHub",
|
||||
r"GitLab",
|
||||
r"Gravatar",
|
||||
r"HTTP",
|
||||
r"ID",
|
||||
r"IDs",
|
||||
r"Inbox",
|
||||
r"Intel",
|
||||
r"IP",
|
||||
r"JSON",
|
||||
r"Jitsi",
|
||||
r"Jotform",
|
||||
r"LinkedIn",
|
||||
r"LDAP",
|
||||
r"Markdown",
|
||||
r"OAuth",
|
||||
r"OTP",
|
||||
r"Recent conversations",
|
||||
r"DM",
|
||||
r"DMs",
|
||||
r"Slack",
|
||||
r"Google",
|
||||
r"Terms of Service",
|
||||
r"TikTok",
|
||||
r"Tuesday",
|
||||
r"URL",
|
||||
r"UUID",
|
||||
r"WordPress",
|
||||
r"YouTube",
|
||||
r"Zoom",
|
||||
r"Zulip",
|
||||
r"Zulip Server",
|
||||
r"Zulip Account Security",
|
||||
r"Zulip Security",
|
||||
r"Zulip Cloud",
|
||||
r"Zulip Cloud Standard",
|
||||
r"Zulip Cloud Plus",
|
||||
r"Zulip Desktop",
|
||||
r"Download Zulip for macOS \(Apple Silicon\)",
|
||||
r"Download Zulip for macOS \(Intel\)",
|
||||
r"BigBlueButton",
|
||||
r"Constructor Groups",
|
||||
r"Nextcloud Talk",
|
||||
# Code things
|
||||
r"\.zuliprc",
|
||||
# BeautifulSoup will remove <z-user> which is horribly confusing,
|
||||
# so we need more of the sentence.
|
||||
r"<z-user></z-user> will have the same role",
|
||||
r"<z-user></z-user> will have the same properties",
|
||||
# Things using "I"
|
||||
r"I understand",
|
||||
r"I'm",
|
||||
r"I've",
|
||||
r"Topics I participate in",
|
||||
r"Topics I send a message to",
|
||||
r"Topics I start",
|
||||
# Specific short words
|
||||
r"beta",
|
||||
r"and",
|
||||
r"bot",
|
||||
r"e\.g\.",
|
||||
r"email",
|
||||
r"enabled",
|
||||
r"signups",
|
||||
# Pasted text filename
|
||||
r"PastedText",
|
||||
# Placeholders
|
||||
r"keyword",
|
||||
r"streamname",
|
||||
r"user@example\.com",
|
||||
r"example\.com",
|
||||
r"acme",
|
||||
# Fragments of larger strings
|
||||
r"is …",
|
||||
r"your subscriptions on your Channels page",
|
||||
r"Add global time<br />Everyone sees global times in their own time zone\.",
|
||||
r"user",
|
||||
r"an unknown operating system",
|
||||
r"Go to Settings",
|
||||
r"find accounts for another email address",
|
||||
# SPECIAL CASES
|
||||
# Because topics usually are lower-case, this would look weird if it were capitalized
|
||||
r"show all topics",
|
||||
# Used alone in a parenthetical where capitalized looks worse.
|
||||
r"^deprecated$",
|
||||
# We want the similar text in the Private Messages section to have the same capitalization.
|
||||
r"more conversations",
|
||||
r"back to channels",
|
||||
# Capital 'i' looks weird in reminders popover
|
||||
r"in 1 hour",
|
||||
r"in 20 minutes",
|
||||
r"in 3 hours",
|
||||
# these are used as channel or topic names
|
||||
r"^new channels$",
|
||||
r"^channel events$",
|
||||
r"^general$",
|
||||
r"^sandbox$",
|
||||
r"^experiments$",
|
||||
r"^greetings$",
|
||||
r"^moving messages$",
|
||||
r"^start a conversation$",
|
||||
r"^welcome to Zulip!$",
|
||||
r"^general chat$",
|
||||
# These are used as example short names (e.g. an uncapitalized context):
|
||||
r"^marketing$",
|
||||
r"^cookie$",
|
||||
# Used to refer custom time limits
|
||||
r"\bN\b",
|
||||
r"minute",
|
||||
r"minutes",
|
||||
# Capital c feels obtrusive in clear status option
|
||||
r"clear",
|
||||
r"group direct messages with \{recipient\}",
|
||||
r"direct messages with \{recipient\}",
|
||||
r"direct messages with yourself",
|
||||
r"GIF",
|
||||
# Emoji name placeholder
|
||||
r"leafy green vegetable",
|
||||
# Subdomain placeholder
|
||||
r"your-organization",
|
||||
# Used in invite modal
|
||||
r"or",
|
||||
# Units
|
||||
r"MB",
|
||||
r"GB",
|
||||
# Used in GIPHY integration setting. GIFs Rating.
|
||||
r"rated Y",
|
||||
r"rated G",
|
||||
r"rated PG",
|
||||
r"rated PG13",
|
||||
r"rated R",
|
||||
# Used in GIPHY popover.
|
||||
r"GIFs",
|
||||
r"GIPHY",
|
||||
# Used for Tenor attributions
|
||||
r"Search Tenor",
|
||||
# Used for KLIPY attributions
|
||||
r"Search KLIPY",
|
||||
# Used in our case studies
|
||||
r"Technical University of Munich",
|
||||
r"University of California San Diego",
|
||||
# Used in stream creation form
|
||||
r"email hidden",
|
||||
# Use in compose box.
|
||||
r"to send",
|
||||
r"to add a new line",
|
||||
# Used in showing Notification Bot read receipts message
|
||||
"Notification Bot",
|
||||
# Used in strings around welcome bot custom messages
|
||||
r"Welcome Bot",
|
||||
# Used in presence_enabled setting label
|
||||
r"invisible mode off",
|
||||
# Typeahead suggestions for "Pronouns" custom field type.
|
||||
r"he/him",
|
||||
r"she/her",
|
||||
r"they/them",
|
||||
# Used in message-move-time-limit setting label
|
||||
r"does not apply to moderators and administrators",
|
||||
# Used in message-delete-time-limit setting label
|
||||
r"does not apply to users who can delete any message",
|
||||
# Used as indicator with names for guest users.
|
||||
r"guest",
|
||||
# Used as indicator with names for archived streams.
|
||||
r"archived",
|
||||
# Used in pills for deactivated users.
|
||||
r"deactivated",
|
||||
# Used in pills for resolved topics.
|
||||
r"resolved",
|
||||
# Used in pills for unresolved topics.
|
||||
r"unresolved",
|
||||
# Used in pills for followed topics.
|
||||
r"followed",
|
||||
# Used in pills for unfollowed topics.
|
||||
r"unfollowed",
|
||||
# This is a reference to a setting/secret and should be lowercase.
|
||||
r"zulip_org_id",
|
||||
# These are custom time unit options for modal dropdowns
|
||||
r"minutes",
|
||||
r"hours",
|
||||
r"days",
|
||||
r"weeks",
|
||||
# Used in "Who can subscribe to this channel" label.
|
||||
r"everyone except guests can subscribe to any public channel",
|
||||
# Used in branch-filtering label in the integration-url-modal.
|
||||
r"comma-separated list",
|
||||
# Used in info_overlay.
|
||||
r"then",
|
||||
r"Joe Smith",
|
||||
r"bold",
|
||||
r"channel name",
|
||||
r"is busy working",
|
||||
r"italic",
|
||||
r"strikethrough",
|
||||
r"support team",
|
||||
r"topic name",
|
||||
]
|
||||
|
||||
# Sort regexes in descending order of their lengths. As a result, the
|
||||
# longer phrases will be ignored first.
|
||||
IGNORED_PHRASES.sort(key=len, reverse=True)
|
||||
|
||||
# Compile regexes to improve performance. This also extracts the
|
||||
# text using BeautifulSoup and then removes extra whitespaces from
|
||||
# it. This step enables us to add HTML in our regexes directly.
|
||||
COMPILED_IGNORED_PHRASES = [
|
||||
re.compile(r" ".join(BeautifulSoup(StringIO(regex), "lxml").text.split()))
|
||||
for regex in IGNORED_PHRASES
|
||||
]
|
||||
|
||||
SPLIT_BOUNDARY = r"?.!" # Used to split string into sentences.
|
||||
SPLIT_BOUNDARY_REGEX = re.compile(rf"[{SPLIT_BOUNDARY}]")
|
||||
|
||||
# Regexes which check capitalization in sentences.
|
||||
DISALLOWED = [
|
||||
r"^[a-z](?!\})", # Checks if the sentence starts with a lower case character.
|
||||
r"^[A-Z][a-z]+[\sa-z0-9]+[A-Z]", # Checks if an upper case character exists
|
||||
# after a lower case character when the first character is in upper case.
|
||||
]
|
||||
DISALLOWED_REGEX = re.compile(r"|".join(DISALLOWED))
|
||||
|
||||
BANNED_WORDS = {
|
||||
"realm": "The term realm should not appear in user-facing strings. Use organization instead.",
|
||||
}
|
||||
|
||||
|
||||
def get_safe_phrase(phrase: str) -> str:
|
||||
"""
|
||||
Safe phrase is in lower case and doesn't contain characters which can
|
||||
conflict with split boundaries. All conflicting characters are replaced
|
||||
with low dash (_).
|
||||
"""
|
||||
phrase = SPLIT_BOUNDARY_REGEX.sub("_", phrase)
|
||||
return phrase.lower()
|
||||
|
||||
|
||||
def replace_with_safe_phrase(matchobj: Match[str]) -> str:
|
||||
"""
|
||||
The idea is to convert IGNORED_PHRASES into safe phrases, see
|
||||
`get_safe_phrase()` function. The only exception is when the
|
||||
IGNORED_PHRASE is at the start of the text or after a split
|
||||
boundary; in this case, we change the first letter of the phrase
|
||||
to upper case.
|
||||
"""
|
||||
ignored_phrase = matchobj.group(0)
|
||||
safe_string = get_safe_phrase(ignored_phrase)
|
||||
|
||||
start_index = matchobj.start()
|
||||
complete_string = matchobj.string
|
||||
|
||||
is_string_start = start_index == 0
|
||||
# We expect that there will be one space between split boundary
|
||||
# and the next word.
|
||||
punctuation = complete_string[max(start_index - 2, 0)]
|
||||
is_after_split_boundary = punctuation in SPLIT_BOUNDARY
|
||||
if is_string_start or is_after_split_boundary:
|
||||
return safe_string.capitalize()
|
||||
|
||||
return safe_string
|
||||
|
||||
|
||||
def get_safe_text(text: str) -> str:
|
||||
"""
|
||||
This returns text which is rendered by BeautifulSoup and is in the
|
||||
form that can be split easily and has all IGNORED_PHRASES processed.
|
||||
"""
|
||||
soup = BeautifulSoup(StringIO(text), "lxml")
|
||||
text = " ".join(soup.text.split()) # Remove extra whitespaces.
|
||||
for phrase_regex in COMPILED_IGNORED_PHRASES:
|
||||
text = phrase_regex.sub(replace_with_safe_phrase, text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def is_capitalized(safe_text: str) -> bool:
|
||||
sentences = SPLIT_BOUNDARY_REGEX.split(safe_text)
|
||||
return not any(DISALLOWED_REGEX.search(sentence.strip()) for sentence in sentences)
|
||||
|
||||
|
||||
def check_banned_words(text: str) -> list[str]:
|
||||
lower_cased_text = text.lower()
|
||||
errors = []
|
||||
for word, reason in BANNED_WORDS.items():
|
||||
if word in lower_cased_text:
|
||||
# Hack: Should move this into BANNED_WORDS framework; for
|
||||
# now, just hand-code the skips:
|
||||
if (
|
||||
"realm_name" in lower_cased_text
|
||||
or "realm_uri" in lower_cased_text
|
||||
or "realm_url" in lower_cased_text
|
||||
or "remote_realm_host" in lower_cased_text
|
||||
or "realm_message" in lower_cased_text
|
||||
or "realm_move" in lower_cased_text
|
||||
):
|
||||
continue
|
||||
kwargs = dict(word=word, text=text, reason=reason)
|
||||
msg = "{word} found in '{text}'. {reason}".format(**kwargs)
|
||||
errors.append(msg)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def check_capitalization(strings: list[str]) -> tuple[list[str], list[str], list[str]]:
|
||||
errors = []
|
||||
ignored = []
|
||||
banned_word_errors = []
|
||||
for text in strings:
|
||||
text = " ".join(text.split()) # Remove extra whitespaces.
|
||||
safe_text = get_safe_text(text)
|
||||
has_ignored_phrase = text != safe_text
|
||||
capitalized = is_capitalized(safe_text)
|
||||
if not capitalized:
|
||||
errors.append(text)
|
||||
elif has_ignored_phrase:
|
||||
ignored.append(text)
|
||||
|
||||
banned_word_errors.extend(check_banned_words(text))
|
||||
|
||||
return sorted(errors), sorted(ignored), sorted(banned_word_errors)
|
||||
34
tools/lib/git-tools.bash
Normal file
34
tools/lib/git-tools.bash
Normal file
@@ -0,0 +1,34 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
# Borrowed from Git's git-sh-setup.
|
||||
#
|
||||
# See git.git commit 92c62a3f4 (from 2010!); as of 2020 with Git 2.26,
|
||||
# this function has only needed one edit since then, adding localization
|
||||
# with gettext, which we can omit.
|
||||
require_clean_work_tree() {
|
||||
local action="$1"
|
||||
|
||||
git rev-parse --verify HEAD >/dev/null || exit 1
|
||||
git update-index -q --ignore-submodules --refresh
|
||||
local err=0
|
||||
|
||||
if ! git diff-files --quiet --ignore-submodules; then
|
||||
echo >&2 "Cannot $action: You have unstaged changes."
|
||||
err=1
|
||||
fi
|
||||
|
||||
if ! git diff-index --cached --quiet --ignore-submodules HEAD --; then
|
||||
if [ $err = 0 ]; then
|
||||
echo >&2 "Cannot $action: Your index contains uncommitted changes."
|
||||
else
|
||||
echo >&2 "Additionally, your index contains uncommitted changes."
|
||||
fi
|
||||
err=1
|
||||
fi
|
||||
|
||||
if [ $err = 1 ]; then
|
||||
git status --short
|
||||
echo >&2 "Doing nothing to avoid losing your work."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
108
tools/lib/gitlint_rules.py
Normal file
108
tools/lib/gitlint_rules.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from gitlint.git import GitCommit
|
||||
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||
|
||||
# Word list from https://github.com/m1foley/fit-commit
|
||||
# Copyright (c) 2015 Mike Foley
|
||||
# License: MIT
|
||||
# Ref: fit_commit/validators/tense.rb
|
||||
TENSE_DATA = [
|
||||
(["adds", "adding", "added"], "add"),
|
||||
(["allows", "allowing", "allowed"], "allow"),
|
||||
(["amends", "amending", "amended"], "amend"),
|
||||
(["bumps", "bumping", "bumped"], "bump"),
|
||||
(["calculates", "calculating", "calculated"], "calculate"),
|
||||
(["changes", "changing", "changed"], "change"),
|
||||
(["cleans", "cleaning", "cleaned"], "clean"),
|
||||
(["commits", "committing", "committed"], "commit"),
|
||||
(["corrects", "correcting", "corrected"], "correct"),
|
||||
(["creates", "creating", "created"], "create"),
|
||||
(["darkens", "darkening", "darkened"], "darken"),
|
||||
(["disables", "disabling", "disabled"], "disable"),
|
||||
(["displays", "displaying", "displayed"], "display"),
|
||||
(["documents", "documenting", "documented"], "document"),
|
||||
(["drys", "drying", "dried"], "dry"),
|
||||
(["ends", "ending", "ended"], "end"),
|
||||
(["enforces", "enforcing", "enforced"], "enforce"),
|
||||
(["enqueues", "enqueuing", "enqueued"], "enqueue"),
|
||||
(["extracts", "extracting", "extracted"], "extract"),
|
||||
(["finishes", "finishing", "finished"], "finish"),
|
||||
(["fixes", "fixing", "fixed"], "fix"),
|
||||
(["formats", "formatting", "formatted"], "format"),
|
||||
(["guards", "guarding", "guarded"], "guard"),
|
||||
(["handles", "handling", "handled"], "handle"),
|
||||
(["hides", "hiding", "hid"], "hide"),
|
||||
(["increases", "increasing", "increased"], "increase"),
|
||||
(["ignores", "ignoring", "ignored"], "ignore"),
|
||||
(["implements", "implementing", "implemented"], "implement"),
|
||||
(["improves", "improving", "improved"], "improve"),
|
||||
(["keeps", "keeping", "kept"], "keep"),
|
||||
(["kills", "killing", "killed"], "kill"),
|
||||
(["makes", "making", "made"], "make"),
|
||||
(["merges", "merging", "merged"], "merge"),
|
||||
(["moves", "moving", "moved"], "move"),
|
||||
(["permits", "permitting", "permitted"], "permit"),
|
||||
(["prevents", "preventing", "prevented"], "prevent"),
|
||||
(["pushes", "pushing", "pushed"], "push"),
|
||||
(["rebases", "rebasing", "rebased"], "rebase"),
|
||||
(["refactors", "refactoring", "refactored"], "refactor"),
|
||||
(["removes", "removing", "removed"], "remove"),
|
||||
(["renames", "renaming", "renamed"], "rename"),
|
||||
(["reorders", "reordering", "reordered"], "reorder"),
|
||||
(["replaces", "replacing", "replaced"], "replace"),
|
||||
(["requires", "requiring", "required"], "require"),
|
||||
(["restores", "restoring", "restored"], "restore"),
|
||||
(["sends", "sending", "sent"], "send"),
|
||||
(["sets", "setting"], "set"),
|
||||
(["separates", "separating", "separated"], "separate"),
|
||||
(["shows", "showing", "showed"], "show"),
|
||||
(["simplifies", "simplifying", "simplified"], "simplify"),
|
||||
(["skips", "skipping", "skipped"], "skip"),
|
||||
(["sorts", "sorting"], "sort"),
|
||||
(["speeds", "speeding", "sped"], "speed"),
|
||||
(["starts", "starting", "started"], "start"),
|
||||
(["supports", "supporting", "supported"], "support"),
|
||||
(["takes", "taking", "took"], "take"),
|
||||
(["testing", "tested"], "test"), # "tests" excluded to reduce false negatives
|
||||
(["truncates", "truncating", "truncated"], "truncate"),
|
||||
(["updates", "updating", "updated"], "update"),
|
||||
(["uses", "using", "used"], "use"),
|
||||
]
|
||||
|
||||
TENSE_CORRECTIONS = {word: imperative for words, imperative in TENSE_DATA for word in words}
|
||||
|
||||
|
||||
class ImperativeMood(LineRule):
|
||||
"""This rule will enforce that the commit message title uses imperative
|
||||
mood. This is done by checking if the first word is in `WORD_SET`, if so
|
||||
show the word in the correct mood."""
|
||||
|
||||
name = "title-imperative-mood"
|
||||
id = "Z1"
|
||||
target = CommitMessageTitle
|
||||
|
||||
error_msg = (
|
||||
"The first word in commit title should be in imperative mood "
|
||||
'("{word}" -> "{imperative}"): "{title}"'
|
||||
)
|
||||
|
||||
def validate(self, line: str, commit: GitCommit) -> list[RuleViolation]:
|
||||
violations = []
|
||||
|
||||
# Ignore the section tag (ie `<section tag>: <message body>.`)
|
||||
words = line.split(": ", 1)[-1].split()
|
||||
first_word = words[0].lower()
|
||||
|
||||
if first_word in TENSE_CORRECTIONS:
|
||||
imperative = TENSE_CORRECTIONS[first_word]
|
||||
violation = RuleViolation(
|
||||
self.id,
|
||||
self.error_msg.format(
|
||||
word=first_word,
|
||||
imperative=imperative,
|
||||
title=commit.message.title,
|
||||
),
|
||||
)
|
||||
|
||||
violations.append(violation)
|
||||
|
||||
return violations
|
||||
98
tools/lib/html_branches.py
Normal file
98
tools/lib/html_branches.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from .template_parser import FormattedError, Token, tokenize
|
||||
|
||||
|
||||
class TagInfo:
|
||||
def __init__(self, tag: str, classes: list[str], ids: list[str], token: Token) -> None:
|
||||
self.tag = tag
|
||||
self.classes = classes
|
||||
self.ids = ids
|
||||
self.token = token
|
||||
self.words = [
|
||||
self.tag,
|
||||
*("." + s for s in classes),
|
||||
*("#" + s for s in ids),
|
||||
]
|
||||
|
||||
def text(self) -> str:
|
||||
s = self.tag
|
||||
if self.classes:
|
||||
s += "." + ".".join(self.classes)
|
||||
if self.ids:
|
||||
s += "#" + "#".join(self.ids)
|
||||
return s
|
||||
|
||||
|
||||
def get_tag_info(token: Token) -> TagInfo:
|
||||
s = token.s
|
||||
tag = token.tag
|
||||
classes: list[str] = []
|
||||
ids: list[str] = []
|
||||
|
||||
searches = [
|
||||
(classes, ' class="(.*?)"'),
|
||||
(classes, " class='(.*?)'"),
|
||||
(ids, ' id="(.*?)"'),
|
||||
(ids, " id='(.*?)'"),
|
||||
]
|
||||
|
||||
for lst, regex in searches:
|
||||
m = re.search(regex, s)
|
||||
if m:
|
||||
for g in m.groups():
|
||||
lst += split_for_id_and_class(g)
|
||||
|
||||
return TagInfo(tag=tag, classes=classes, ids=ids, token=token)
|
||||
|
||||
|
||||
def split_for_id_and_class(element: str) -> list[str]:
|
||||
# Here we split a given string which is expected to contain id or class
|
||||
# attributes from HTML tags. This also takes care of template variables
|
||||
# in string during splitting process. For eg. 'red black {{ a|b|c }}'
|
||||
# is split as ['red', 'black', '{{ a|b|c }}']
|
||||
outside_braces: bool = True
|
||||
lst = []
|
||||
s = ""
|
||||
|
||||
for ch in element:
|
||||
if ch == "{":
|
||||
outside_braces = False
|
||||
if ch == "}":
|
||||
outside_braces = True
|
||||
if ch == " " and outside_braces:
|
||||
if s != "":
|
||||
lst.append(s)
|
||||
s = ""
|
||||
else:
|
||||
s += ch
|
||||
if s != "":
|
||||
lst.append(s)
|
||||
|
||||
return lst
|
||||
|
||||
|
||||
def build_id_dict(templates: list[str]) -> dict[str, list[str]]:
|
||||
template_id_dict: dict[str, list[str]] = defaultdict(list)
|
||||
|
||||
for fn in templates:
|
||||
with open(fn) as f:
|
||||
text = f.read()
|
||||
|
||||
try:
|
||||
list_tags = tokenize(text)
|
||||
except FormattedError as e:
|
||||
raise Exception(
|
||||
f"""
|
||||
fn: {fn}
|
||||
{e}"""
|
||||
)
|
||||
|
||||
for tag in list_tags:
|
||||
info = get_tag_info(tag)
|
||||
|
||||
for ids in info.ids:
|
||||
template_id_dict[ids].append("Line " + str(info.token.line) + ":" + fn)
|
||||
|
||||
return template_id_dict
|
||||
191
tools/lib/html_elements.py
Normal file
191
tools/lib/html_elements.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from collections.abc import Iterator
|
||||
|
||||
VALID_HTML_CONTEXTS: dict[tuple[str, str], str] = {
|
||||
# https://html.spec.whatwg.org/multipage/indices.html#elements-3
|
||||
("a", "phrasing"): "transparent",
|
||||
("abbr", "phrasing"): "phrasing",
|
||||
("address", "flow"): "flow",
|
||||
("area", "phrasing"): "void",
|
||||
("article", "flow"): "flow",
|
||||
("aside", "flow"): "flow",
|
||||
("audio", "phrasing"): "<audio>",
|
||||
("b", "phrasing"): "phrasing",
|
||||
("base", "<head>"): "void",
|
||||
("bdi", "phrasing"): "phrasing",
|
||||
("bdo", "phrasing"): "phrasing",
|
||||
("blockquote", "flow"): "flow",
|
||||
("body", "<html>"): "flow",
|
||||
("br", "phrasing"): "void",
|
||||
("button", "phrasing"): "phrasing",
|
||||
("button", "<select>"): "phrasing",
|
||||
("canvas", "phrasing"): "transparent",
|
||||
("caption", "<table>"): "flow",
|
||||
("center", "flow"): "flow", # FIXME: obsolete, remove this
|
||||
("cite", "phrasing"): "phrasing",
|
||||
("code", "phrasing"): "phrasing",
|
||||
("col", "<colgroup>"): "void",
|
||||
("colgroup", "<table>"): "<colgroup>",
|
||||
("data", "phrasing"): "phrasing",
|
||||
("datalist", "phrasing"): "<datalist>",
|
||||
("dd", "<dl>"): "flow",
|
||||
("del", "phrasing"): "transparent",
|
||||
("details", "flow"): "<details>",
|
||||
("dfn", "phrasing"): "phrasing",
|
||||
("dialog", "flow"): "flow",
|
||||
("div", "flow"): "flow",
|
||||
("div", "<dl>"): "<dl>",
|
||||
("div", "<select> content"): "<select> content",
|
||||
("div", "<optgroup> content"): "<optgroup> content",
|
||||
("div", "<option> content"): "<option> content",
|
||||
("dl", "flow"): "<dl>",
|
||||
("dt", "<dl>"): "phrasing",
|
||||
("em", "phrasing"): "phrasing",
|
||||
("embed", "phrasing"): "void",
|
||||
("fieldset", "flow"): "<fieldset>",
|
||||
("figcaption", "<figure>"): "flow",
|
||||
("figure", "flow"): "<figure>",
|
||||
("footer", "flow"): "flow",
|
||||
("form", "flow"): "flow",
|
||||
("h1", "plain heading"): "phrasing",
|
||||
("h2", "plain heading"): "phrasing",
|
||||
("h3", "plain heading"): "phrasing",
|
||||
("h4", "plain heading"): "phrasing",
|
||||
("h5", "plain heading"): "phrasing",
|
||||
("h6", "plain heading"): "phrasing",
|
||||
("head", "<html>"): "<head>",
|
||||
("header", "flow"): "flow",
|
||||
("hgroup", "heading"): "<hgroup>",
|
||||
("hr", "flow"): "void",
|
||||
("hr", "<select> content"): "void",
|
||||
("html", "document"): "<html>",
|
||||
("i", "phrasing"): "phrasing",
|
||||
("iframe", "phrasing"): "empty",
|
||||
("img", "phrasing"): "void",
|
||||
("img", "<picture>"): "void",
|
||||
("input", "phrasing"): "void",
|
||||
("ins", "phrasing"): "transparent",
|
||||
("kbd", "phrasing"): "phrasing",
|
||||
("label", "phrasing"): "phrasing",
|
||||
("legend", "<fieldset>"): "phrasing/heading",
|
||||
("li", "list"): "flow",
|
||||
("link", "<head>"): "void",
|
||||
("link", "phrasing"): "void",
|
||||
("main", "flow"): "flow",
|
||||
("map", "phrasing"): "<map>",
|
||||
("mark", "phrasing"): "phrasing",
|
||||
("math", "phrasing"): "MathML",
|
||||
("menu", "flow"): "list",
|
||||
("meta", "<head>"): "void",
|
||||
("meta", "phrasing"): "void",
|
||||
("meter", "phrasing"): "phrasing",
|
||||
("nav", "flow"): "flow",
|
||||
("noscript", "<head>"): "transparent",
|
||||
("noscript", "phrasing"): "transparent",
|
||||
("noscript", "<select> content"): "transparent",
|
||||
("noscript", "<optgroup> content"): "transparent",
|
||||
("object", "phrasing"): "transparent",
|
||||
("ol", "flow"): "list",
|
||||
("optgroup", "<select>"): "<optgroup> content",
|
||||
("option", "<select>"): "<option> content",
|
||||
("option", "<datalist>"): "<option> content",
|
||||
("option", "<optgroup>"): "<option> content",
|
||||
("output", "phrasing"): "phrasing",
|
||||
("p", "flow"): "phrasing",
|
||||
("p", "<hgroup>"): "phrasing",
|
||||
("picture", "phrasing"): "<picture>",
|
||||
("pre", "flow"): "phrasing",
|
||||
("progress", "phrasing"): "phrasing",
|
||||
("q", "phrasing"): "phrasing",
|
||||
("rp", "<ruby>"): "phrasing",
|
||||
("rt", "<ruby>"): "phrasing",
|
||||
("ruby", "phrasing"): "<ruby>",
|
||||
("s", "phrasing"): "phrasing",
|
||||
("samp", "phrasing"): "phrasing",
|
||||
("script", "<head>"): "<script>",
|
||||
("script", "phrasing"): "<script>",
|
||||
("script", "script-supporting"): "<script>",
|
||||
("search", "flow"): "flow",
|
||||
("section", "flow"): "flow",
|
||||
("select", "phrasing"): "<select>",
|
||||
("selectedcontent", "<button>"): "empty",
|
||||
("slot", "phrasing"): "transparent",
|
||||
("small", "phrasing"): "phrasing",
|
||||
("source", "<picture>"): "void",
|
||||
("source", "<video>"): "void",
|
||||
("source", "<audio>"): "void",
|
||||
("span", "phrasing"): "phrasing",
|
||||
("strong", "phrasing"): "phrasing",
|
||||
("style", "<head>"): "<style>",
|
||||
("sub", "phrasing"): "phrasing",
|
||||
("summary", "<details>"): "phrasing/heading",
|
||||
("sup", "phrasing"): "phrasing",
|
||||
("svg", "phrasing"): "SVG",
|
||||
("table", "flow"): "<table>",
|
||||
("tbody", "<table>"): "<tbody>",
|
||||
("td", "<tr>"): "flow",
|
||||
("template", "<head>"): "unknown",
|
||||
("template", "phrasing"): "unknown",
|
||||
("template", "script-supporting"): "unknown",
|
||||
("template", "<colgroup>"): "unknown",
|
||||
("textarea", "phrasing"): "text",
|
||||
("tfoot", "<table>"): "<tfoot>",
|
||||
("th", "<tr>"): "flow",
|
||||
("thead", "<table>"): "<thead>",
|
||||
("time", "phrasing"): "phrasing",
|
||||
("title", "<head>"): "text",
|
||||
("tr", "<table>"): "<tr>",
|
||||
("tr", "<thead>"): "<tr>",
|
||||
("tr", "<tbody>"): "<tr>",
|
||||
("tr", "<tfoot>"): "<tr>",
|
||||
("track", "<audio>"): "void",
|
||||
("track", "<video>"): "void",
|
||||
("u", "phrasing"): "phrasing",
|
||||
("ul", "flow"): "list",
|
||||
("var", "phrasing"): "phrasing",
|
||||
("video", "phrasing"): "<video>",
|
||||
("wbr", "phrasing"): "void",
|
||||
# https://html.spec.whatwg.org/multipage/embedded-content-other.html#mathml
|
||||
("annotation-xml", "MathML"): "flow",
|
||||
("mi", "MathML"): "phrasing",
|
||||
("mo", "MathML"): "phrasing",
|
||||
("mn", "MathML"): "phrasing",
|
||||
("ms", "MathML"): "phrasing",
|
||||
("mtext", "MathML"): "phrasing",
|
||||
# https://html.spec.whatwg.org/multipage/embedded-content-other.html#svg-0
|
||||
("foreignObject", "SVG"): "flow",
|
||||
("title", "SVG"): "phrasing",
|
||||
}
|
||||
|
||||
HTML_CONTEXT_FALLBACKS: dict[str, list[str]] = {
|
||||
"<datalist>": ["phrasing", "script-supporting"],
|
||||
"<details>": ["flow"],
|
||||
"<dl>": ["script-supporting"],
|
||||
"<fieldset>": ["flow"],
|
||||
"<figure>": ["flow"],
|
||||
"<hgroup>": ["plain heading", "script-supporting"],
|
||||
"<optgroup> content": ["script-supporting"],
|
||||
"<option> content": ["phrasing"],
|
||||
"<option>": ["<option> content"],
|
||||
"<picture>": ["script-supporting"],
|
||||
"<ruby>": ["phrasing"],
|
||||
"<select> content": ["script-supporting"],
|
||||
"<select>": ["<select> content"],
|
||||
"<table>": ["script-supporting"],
|
||||
"<tbody>": ["script-supporting"],
|
||||
"<tfoot>": ["script-supporting"],
|
||||
"<thead>": ["script-supporting"],
|
||||
"<tr>": ["script-supporting"],
|
||||
"flow": ["phrasing", "heading"],
|
||||
"heading": ["plain heading"],
|
||||
"list": ["script-supporting"],
|
||||
"phrasing/heading": ["phrasing", "heading"],
|
||||
"unknown": ["document", "flow", "list", "<head>", "<select>", "<table>", "<tr>"],
|
||||
}
|
||||
|
||||
FOREIGN_CONTEXTS = ["MathML", "SVG"]
|
||||
|
||||
|
||||
def html_context_fallbacks(context: str) -> Iterator[str]:
|
||||
yield context
|
||||
for fallback_context in HTML_CONTEXT_FALLBACKS.get(context, []):
|
||||
yield from html_context_fallbacks(fallback_context)
|
||||
197
tools/lib/pretty_print.py
Normal file
197
tools/lib/pretty_print.py
Normal file
@@ -0,0 +1,197 @@
|
||||
import subprocess
|
||||
|
||||
from zulint.printer import BOLDRED, CYAN, ENDC, GREEN
|
||||
|
||||
from .template_parser import Token
|
||||
|
||||
|
||||
def shift_indents_to_the_next_tokens(tokens: list[Token]) -> None:
|
||||
"""
|
||||
During the parsing/validation phase, it's useful to have separate
|
||||
tokens for "indent" chunks, but during pretty printing, we like
|
||||
to attach an `.indent` field to the substantive node, whether
|
||||
it's an HTML tag or template directive or whatever.
|
||||
"""
|
||||
tokens[0].indent = ""
|
||||
|
||||
for i, token in enumerate(tokens[:-1]):
|
||||
next_token = tokens[i + 1]
|
||||
|
||||
if token.kind == "indent":
|
||||
next_token.indent = token.s
|
||||
token.new_s = ""
|
||||
|
||||
if token.kind == "newline" and next_token.kind != "indent":
|
||||
next_token.indent = ""
|
||||
|
||||
|
||||
def token_allows_children_to_skip_indents(token: Token) -> bool:
|
||||
# To avoid excessive indentation in templates with other
|
||||
# conditionals, we don't require extra indentation for template
|
||||
# logic blocks don't contain further logic as direct children.
|
||||
|
||||
# Each blocks are excluded from this rule, since we want loops to
|
||||
# stand out.
|
||||
if token.tag == "each":
|
||||
return False
|
||||
|
||||
return token.kind in ("django_start", "handlebars_start") or token.tag == "a"
|
||||
|
||||
|
||||
def adjust_block_indentation(tokens: list[Token], fn: str) -> None:
|
||||
start_token: Token | None = None
|
||||
|
||||
for token in tokens:
|
||||
if token.kind in ("indent", "whitespace", "newline"):
|
||||
continue
|
||||
|
||||
if token.tag in ("code", "pre"):
|
||||
continue
|
||||
|
||||
# print(token.line, repr(start_token.indent) if start_token else "?", repr(token.indent), token.s, token.end_token and "start", token.start_token and "end")
|
||||
|
||||
if token.tag == "else":
|
||||
assert token.start_token
|
||||
if token.indent is not None:
|
||||
token.indent = token.start_token.indent
|
||||
continue
|
||||
|
||||
if start_token and token.indent is not None:
|
||||
if (
|
||||
not start_token.indent_is_final
|
||||
and token.indent == start_token.orig_indent
|
||||
and token_allows_children_to_skip_indents(start_token)
|
||||
):
|
||||
start_token.child_indent = start_token.indent
|
||||
start_token.indent_is_final = True
|
||||
|
||||
# Detect start token by its having a end token
|
||||
if token.end_token:
|
||||
if token.indent is not None:
|
||||
token.orig_indent = token.indent
|
||||
if start_token:
|
||||
assert start_token.child_indent is not None
|
||||
token.indent = start_token.child_indent
|
||||
else:
|
||||
token.indent = ""
|
||||
token.child_indent = token.indent + " "
|
||||
token.parent_token = start_token
|
||||
start_token = token
|
||||
continue
|
||||
|
||||
# Detect end token by its having a start token
|
||||
if token.start_token:
|
||||
if start_token != token.start_token:
|
||||
raise AssertionError(
|
||||
f"""
|
||||
{token.kind} was unexpected in {token.s}
|
||||
in row {token.line} of {fn}
|
||||
"""
|
||||
)
|
||||
|
||||
if token.indent is not None:
|
||||
token.indent = start_token.indent
|
||||
start_token = start_token.parent_token
|
||||
continue
|
||||
|
||||
if token.indent is None:
|
||||
continue
|
||||
|
||||
if start_token is None:
|
||||
token.indent = ""
|
||||
continue
|
||||
|
||||
if start_token.child_indent is not None:
|
||||
token.indent = start_token.child_indent
|
||||
|
||||
|
||||
def fix_indents_for_multi_line_tags(tokens: list[Token]) -> None:
|
||||
def fix(frag: str) -> str:
|
||||
frag = frag.strip()
|
||||
return continue_indent + frag if frag else ""
|
||||
|
||||
for token in tokens:
|
||||
if token.kind == "code":
|
||||
continue
|
||||
|
||||
if token.line_span == 1 or token.indent is None:
|
||||
continue
|
||||
|
||||
if token.kind in ("django_comment", "handlebars_comment", "html_comment", "text"):
|
||||
continue_indent = token.indent
|
||||
else:
|
||||
continue_indent = token.indent + " "
|
||||
|
||||
frags = token.new_s.split("\n")
|
||||
|
||||
token.new_s = frags[0] + "\n" + "\n".join(fix(frag) for frag in frags[1:])
|
||||
|
||||
|
||||
def apply_token_indents(tokens: list[Token]) -> None:
|
||||
for token in tokens:
|
||||
if token.indent:
|
||||
token.new_s = token.indent + token.new_s
|
||||
|
||||
|
||||
def pretty_print_html(tokens: list[Token], fn: str) -> str:
|
||||
for token in tokens:
|
||||
token.new_s = token.s
|
||||
|
||||
shift_indents_to_the_next_tokens(tokens)
|
||||
adjust_block_indentation(tokens, fn)
|
||||
fix_indents_for_multi_line_tags(tokens)
|
||||
apply_token_indents(tokens)
|
||||
|
||||
return "".join(token.new_s for token in tokens)
|
||||
|
||||
|
||||
def numbered_lines(s: str) -> str:
|
||||
return "".join(f"{i + 1: >5} {line}\n" for i, line in enumerate(s.split("\n")))
|
||||
|
||||
|
||||
def validate_indent_html(fn: str, tokens: list[Token], fix: bool) -> bool:
|
||||
with open(fn) as f:
|
||||
html = f.read()
|
||||
phtml = pretty_print_html(tokens, fn)
|
||||
if html.split("\n") != phtml.split("\n"):
|
||||
if fix:
|
||||
print(GREEN + f"Automatically fixing indentation for {fn}" + ENDC)
|
||||
with open(fn, "w") as f:
|
||||
f.write(phtml)
|
||||
# Since we successfully fixed the issues, we return True.
|
||||
return True
|
||||
print(
|
||||
f"""
|
||||
{BOLDRED}PROBLEM{ENDC}: formatting errors in {fn}
|
||||
|
||||
Here is how we would like you to format
|
||||
{CYAN}{fn}{ENDC}:
|
||||
---
|
||||
{numbered_lines(phtml)}
|
||||
---
|
||||
|
||||
Here is the diff that you should either execute in your editor
|
||||
or apply automatically with the --fix option.
|
||||
|
||||
({CYAN}Scroll up{ENDC} to see how we would like the file formatted.)
|
||||
|
||||
Proposed {BOLDRED}diff{ENDC} for {CYAN}{fn}{ENDC}:
|
||||
""",
|
||||
flush=True,
|
||||
)
|
||||
subprocess.run(["diff", fn, "-"], input=phtml, text=True, check=False)
|
||||
print(
|
||||
f"""
|
||||
---
|
||||
|
||||
{BOLDRED}PROBLEM!!!{ENDC}
|
||||
|
||||
You have formatting errors in {CYAN}{fn}{ENDC}
|
||||
(Usually these messages are related to indentation.)
|
||||
|
||||
This problem can be fixed with the {CYAN}`--fix`{ENDC} option.
|
||||
Scroll up for more details about {BOLDRED}what you need to fix ^^^{ENDC}.
|
||||
"""
|
||||
)
|
||||
return False
|
||||
return True
|
||||
512
tools/lib/provision.py
Executable file
512
tools/lib/provision.py
Executable file
@@ -0,0 +1,512 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import contextlib
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import NoReturn
|
||||
|
||||
os.environ["PYTHONUNBUFFERED"] = "y"
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
sys.path.append(ZULIP_PATH)
|
||||
|
||||
from scripts.lib.node_cache import setup_node_modules
|
||||
from scripts.lib.setup_venv import get_venv_dependencies
|
||||
from scripts.lib.zulip_tools import (
|
||||
ENDC,
|
||||
FAIL,
|
||||
WARNING,
|
||||
get_dev_uuid_var_path,
|
||||
os_families,
|
||||
run,
|
||||
run_as_root,
|
||||
)
|
||||
|
||||
VAR_DIR_PATH = os.path.join(ZULIP_PATH, "var")
|
||||
|
||||
CONTINUOUS_INTEGRATION = "GITHUB_ACTIONS" in os.environ
|
||||
|
||||
if not os.path.exists(os.path.join(ZULIP_PATH, ".git")):
|
||||
print(FAIL + "Error: No Zulip Git repository present!" + ENDC)
|
||||
print("To set up the Zulip development environment, you should clone the code")
|
||||
print("from GitHub, rather than using a Zulip production release tarball.")
|
||||
sys.exit(1)
|
||||
|
||||
# Check the RAM on the user's system, and throw an effort if <1.5GB.
|
||||
# This avoids users getting segfaults running `pip install` that are
|
||||
# generally more annoying to debug.
|
||||
with open("/proc/meminfo") as meminfo:
|
||||
ram_size = meminfo.readlines()[0].strip().split(" ")[-2]
|
||||
ram_gb = float(ram_size) / 1024.0 / 1024.0
|
||||
if ram_gb < 1.5:
|
||||
print(
|
||||
f"You have insufficient RAM ({round(ram_gb, 2)} GB) to run the Zulip development environment."
|
||||
)
|
||||
print("We recommend at least 2 GB of RAM, and require at least 1.5 GB.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
UUID_VAR_PATH = get_dev_uuid_var_path(create_if_missing=True)
|
||||
os.makedirs(UUID_VAR_PATH, exist_ok=True)
|
||||
if os.path.exists(os.path.join(VAR_DIR_PATH, "zulip-test-symlink")):
|
||||
os.remove(os.path.join(VAR_DIR_PATH, "zulip-test-symlink"))
|
||||
os.symlink(
|
||||
os.path.join(ZULIP_PATH, "README.md"),
|
||||
os.path.join(VAR_DIR_PATH, "zulip-test-symlink"),
|
||||
)
|
||||
os.remove(os.path.join(VAR_DIR_PATH, "zulip-test-symlink"))
|
||||
except OSError:
|
||||
print(
|
||||
FAIL + "Error: Unable to create symlinks. "
|
||||
"Make sure you have permission to create symbolic links." + ENDC
|
||||
)
|
||||
print("See this page for more information:")
|
||||
print(
|
||||
" https://zulip.readthedocs.io/en/latest/development/setup-recommended.html#os-symlink-error"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
distro_info = platform.freedesktop_os_release()
|
||||
vendor = distro_info["ID"]
|
||||
os_version = distro_info["VERSION_ID"]
|
||||
if vendor == "debian" and os_version == "12": # bookworm
|
||||
POSTGRESQL_VERSION = "15"
|
||||
elif vendor == "debian" and os_version == "13": # trixie
|
||||
POSTGRESQL_VERSION = "17"
|
||||
elif vendor == "ubuntu" and os_version == "22.04": # jammy
|
||||
POSTGRESQL_VERSION = "14"
|
||||
elif vendor == "ubuntu" and os_version == "24.04": # noble
|
||||
POSTGRESQL_VERSION = "16"
|
||||
elif vendor == "ubuntu" and os_version == "26.04": # resolute
|
||||
POSTGRESQL_VERSION = "18"
|
||||
elif vendor == "fedora" and os_version in ("43", "44"):
|
||||
POSTGRESQL_VERSION = "17"
|
||||
elif vendor == "rhel" and os_version.startswith("7."):
|
||||
POSTGRESQL_VERSION = "10"
|
||||
elif vendor == "centos" and os_version == "7":
|
||||
POSTGRESQL_VERSION = "10"
|
||||
else:
|
||||
logging.critical("Unsupported platform: %s %s", vendor, os_version)
|
||||
sys.exit(1)
|
||||
|
||||
VENV_DEPENDENCIES = get_venv_dependencies(vendor, os_version)
|
||||
|
||||
COMMON_DEPENDENCIES = [
|
||||
"memcached",
|
||||
"rabbitmq-server",
|
||||
"supervisor",
|
||||
"git",
|
||||
"curl",
|
||||
"ca-certificates", # Explicit dependency in case e.g. curl is already installed
|
||||
"puppet", # Used by lint (`puppet parser validate`)
|
||||
"gettext", # Used by makemessages i18n
|
||||
"curl", # Used for testing our API documentation
|
||||
"moreutils", # Used for sponge command
|
||||
"unzip", # Needed for Slack import
|
||||
"crudini", # Used for shell tooling w/ zulip.conf
|
||||
# Puppeteer dependencies from here
|
||||
"xdg-utils",
|
||||
# Puppeteer dependencies end here.
|
||||
]
|
||||
|
||||
UBUNTU_COMMON_APT_DEPENDENCIES = [
|
||||
*COMMON_DEPENDENCIES,
|
||||
"redis-server",
|
||||
"hunspell-en-us",
|
||||
"puppet-lint",
|
||||
(
|
||||
"openjdk-17-jre-headless"
|
||||
if vendor == "ubuntu" and os_version == "22.04"
|
||||
else "default-jre-headless"
|
||||
), # Required by vnu-jar
|
||||
# Puppeteer dependencies from here
|
||||
"fonts-freefont-ttf",
|
||||
"libatk-bridge2.0-0",
|
||||
"libgbm1",
|
||||
"libgtk-3-0",
|
||||
"libx11-xcb1",
|
||||
"libxcb-dri3-0",
|
||||
"libxss1",
|
||||
"xvfb",
|
||||
# Puppeteer dependencies end here.
|
||||
]
|
||||
|
||||
COMMON_YUM_DEPENDENCIES = [
|
||||
*COMMON_DEPENDENCIES,
|
||||
"redis",
|
||||
"hunspell-en-US",
|
||||
"rubygem-puppet-lint",
|
||||
"nmap-ncat",
|
||||
"ccache", # Required to build pgroonga from source.
|
||||
# Puppeteer dependencies from here
|
||||
"at-spi2-atk",
|
||||
"GConf2",
|
||||
"gtk3",
|
||||
"libX11-xcb",
|
||||
"libxcb",
|
||||
"libXScrnSaver",
|
||||
"mesa-libgbm",
|
||||
"xorg-x11-server-Xvfb",
|
||||
# Puppeteer dependencies end here.
|
||||
]
|
||||
|
||||
BUILD_PGROONGA_FROM_SOURCE = False
|
||||
if (vendor == "debian" and os_version == "13") or (vendor == "ubuntu" and os_version == "26.04"):
|
||||
# For platforms without a PGroonga release, we need to build it
|
||||
# from source.
|
||||
BUILD_PGROONGA_FROM_SOURCE = True
|
||||
SYSTEM_DEPENDENCIES = [
|
||||
*UBUNTU_COMMON_APT_DEPENDENCIES,
|
||||
f"postgresql-{POSTGRESQL_VERSION}",
|
||||
# Dependency for building PGroonga from source
|
||||
"meson",
|
||||
f"postgresql-server-dev-{POSTGRESQL_VERSION}",
|
||||
"libgroonga-dev",
|
||||
"libmsgpack-dev",
|
||||
*VENV_DEPENDENCIES,
|
||||
]
|
||||
elif "debian" in os_families():
|
||||
DEBIAN_DEPENDENCIES = UBUNTU_COMMON_APT_DEPENDENCIES
|
||||
|
||||
# If we are on an aarch64 processor, ninja will be built from source,
|
||||
# so cmake is required
|
||||
if platform.machine() == "aarch64":
|
||||
DEBIAN_DEPENDENCIES.append("cmake")
|
||||
|
||||
SYSTEM_DEPENDENCIES = [
|
||||
*DEBIAN_DEPENDENCIES,
|
||||
f"postgresql-{POSTGRESQL_VERSION}",
|
||||
f"postgresql-{POSTGRESQL_VERSION}-pgroonga",
|
||||
*VENV_DEPENDENCIES,
|
||||
]
|
||||
elif "rhel" in os_families():
|
||||
SYSTEM_DEPENDENCIES = [
|
||||
*COMMON_YUM_DEPENDENCIES,
|
||||
f"postgresql{POSTGRESQL_VERSION}-server",
|
||||
f"postgresql{POSTGRESQL_VERSION}",
|
||||
f"postgresql{POSTGRESQL_VERSION}-devel",
|
||||
f"postgresql{POSTGRESQL_VERSION}-pgdg-pgroonga",
|
||||
*VENV_DEPENDENCIES,
|
||||
]
|
||||
elif "fedora" in os_families():
|
||||
SYSTEM_DEPENDENCIES = [
|
||||
*COMMON_YUM_DEPENDENCIES,
|
||||
f"postgresql{POSTGRESQL_VERSION}-server",
|
||||
f"postgresql{POSTGRESQL_VERSION}",
|
||||
f"postgresql{POSTGRESQL_VERSION}-devel",
|
||||
# Needed to build PGroonga from source
|
||||
"groonga-devel",
|
||||
"msgpack-devel",
|
||||
# PGroonga compiles against <xxhash.h>; on systems where
|
||||
# `xxhash-libs` is already pulled in transitively (e.g. by
|
||||
# blosc2 / pyarrow) but `xxhash-devel` is not, meson detects
|
||||
# libxxhash via pkg-config and skips its vendored fallback,
|
||||
# so we have to provide the header ourselves.
|
||||
"xxhash-devel",
|
||||
"meson",
|
||||
# Provides /usr/lib/rpm/redhat/redhat-hardened-cc1 and
|
||||
# redhat-annobin-cc1 spec files referenced by the CFLAGS that
|
||||
# PostgreSQL's pg_config exports; without it the PGroonga
|
||||
# compile fails with "cannot read spec file".
|
||||
"redhat-rpm-config",
|
||||
*VENV_DEPENDENCIES,
|
||||
]
|
||||
BUILD_PGROONGA_FROM_SOURCE = True
|
||||
|
||||
if "fedora" in os_families():
|
||||
TSEARCH_STOPWORDS_PATH = f"/usr/pgsql-{POSTGRESQL_VERSION}/share/tsearch_data/"
|
||||
else:
|
||||
TSEARCH_STOPWORDS_PATH = f"/usr/share/postgresql/{POSTGRESQL_VERSION}/tsearch_data/"
|
||||
REPO_STOPWORDS_PATH = os.path.join(
|
||||
ZULIP_PATH,
|
||||
"puppet",
|
||||
"zulip",
|
||||
"files",
|
||||
"postgresql",
|
||||
"zulip_english.stop",
|
||||
)
|
||||
|
||||
|
||||
def install_system_deps() -> None:
|
||||
# By doing list -> set -> list conversion, we remove duplicates.
|
||||
deps_to_install = sorted(set(SYSTEM_DEPENDENCIES))
|
||||
|
||||
if "fedora" in os_families():
|
||||
install_yum_deps(deps_to_install)
|
||||
elif "debian" in os_families():
|
||||
install_apt_deps(deps_to_install)
|
||||
else:
|
||||
raise AssertionError("Invalid vendor")
|
||||
|
||||
# For some platforms, there aren't published PGroonga
|
||||
# packages available, so we build them from source.
|
||||
if BUILD_PGROONGA_FROM_SOURCE:
|
||||
run_as_root(["./scripts/lib/build-pgroonga"])
|
||||
|
||||
|
||||
def install_apt_deps(deps_to_install: list[str]) -> None:
|
||||
# setup-apt-repo does an `apt-get update` if the sources.list files changed.
|
||||
run_as_root(["./scripts/lib/setup-apt-repo"])
|
||||
|
||||
# But we still need to do our own to make sure we have up-to-date
|
||||
# data before installing new packages, as the system might not have
|
||||
# done an apt update in weeks otherwise, which could result in 404s
|
||||
# trying to download old versions that were already removed from mirrors.
|
||||
run_as_root(["apt-get", "update"])
|
||||
run_as_root(
|
||||
[
|
||||
"env",
|
||||
"DEBIAN_FRONTEND=noninteractive",
|
||||
"apt-get",
|
||||
"-y",
|
||||
"install",
|
||||
"--allow-downgrades",
|
||||
"--no-install-recommends",
|
||||
*deps_to_install,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def install_yum_deps(deps_to_install: list[str]) -> None:
|
||||
print(WARNING + "RedHat support is still experimental." + ENDC)
|
||||
run_as_root(["./scripts/lib/setup-yum-repo"])
|
||||
|
||||
# Hack specific to unregistered RHEL system. The moreutils
|
||||
# package requires a perl module package, which isn't available in
|
||||
# the unregistered RHEL repositories.
|
||||
#
|
||||
# Error: Package: moreutils-0.49-2.el7.x86_64 (epel)
|
||||
# Requires: perl(IPC::Run)
|
||||
yum_extra_flags: list[str] = []
|
||||
if vendor == "rhel":
|
||||
proc = subprocess.run(
|
||||
["sudo", "subscription-manager", "status"],
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 1:
|
||||
# TODO this might overkill since `subscription-manager` is already
|
||||
# called in setup-yum-repo
|
||||
if "Status" in proc.stdout:
|
||||
# The output is well-formed
|
||||
yum_extra_flags = ["--skip-broken"]
|
||||
else:
|
||||
print("Unrecognized output. `subscription-manager` might not be available")
|
||||
|
||||
run_as_root(["yum", "install", "-y", *yum_extra_flags, *deps_to_install])
|
||||
if "rhel" in os_families():
|
||||
# This is how a pip3 is installed to /usr/bin in CentOS/RHEL
|
||||
# for python35 and later.
|
||||
run_as_root(["python36", "-m", "ensurepip"])
|
||||
# `python36` is not aliased to `python3` by default
|
||||
run_as_root(["ln", "-nsf", "/usr/bin/python36", "/usr/bin/python3"])
|
||||
postgresql_dir = f"pgsql-{POSTGRESQL_VERSION}"
|
||||
for cmd in ["pg_config", "pg_isready", "psql"]:
|
||||
# Our tooling expects these PostgreSQL scripts to be at
|
||||
# well-known paths. There's an argument for eventually
|
||||
# making our tooling auto-detect, but this is simpler.
|
||||
run_as_root(["ln", "-nsf", f"/usr/{postgresql_dir}/bin/{cmd}", f"/usr/bin/{cmd}"])
|
||||
|
||||
# From here, we do the first-time setup/initialization for the PostgreSQL database.
|
||||
pg_datadir = f"/var/lib/pgsql/{POSTGRESQL_VERSION}/data"
|
||||
pg_hba_conf = os.path.join(pg_datadir, "pg_hba.conf")
|
||||
|
||||
# We can't just check if the file exists with os.path, since the
|
||||
# current user likely doesn't have permission to read the
|
||||
# pg_datadir directory.
|
||||
if subprocess.call(["sudo", "test", "-e", pg_hba_conf]) == 0:
|
||||
# Skip setup if it has been applied previously
|
||||
return
|
||||
|
||||
run_as_root(
|
||||
[f"/usr/{postgresql_dir}/bin/postgresql-{POSTGRESQL_VERSION}-setup", "initdb"],
|
||||
sudo_args=["-H"],
|
||||
)
|
||||
# Use vendored pg_hba.conf, which enables password authentication.
|
||||
run_as_root(["cp", "-a", "puppet/zulip/files/postgresql/centos_pg_hba.conf", pg_hba_conf])
|
||||
# Later steps will ensure PostgreSQL is started
|
||||
|
||||
# Link in tsearch data files
|
||||
if vendor == "fedora":
|
||||
# Since F36 dictionary files were moved away from /usr/share/myspell
|
||||
tsearch_source_prefix = "/usr/share/hunspell"
|
||||
else:
|
||||
tsearch_source_prefix = "/usr/share/myspell"
|
||||
run_as_root(
|
||||
[
|
||||
"ln",
|
||||
"-nsf",
|
||||
os.path.join(tsearch_source_prefix, "en_US.dic"),
|
||||
f"/usr/pgsql-{POSTGRESQL_VERSION}/share/tsearch_data/en_us.dict",
|
||||
]
|
||||
)
|
||||
run_as_root(
|
||||
[
|
||||
"ln",
|
||||
"-nsf",
|
||||
os.path.join(tsearch_source_prefix, "en_US.aff"),
|
||||
f"/usr/pgsql-{POSTGRESQL_VERSION}/share/tsearch_data/en_us.affix",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def main(options: argparse.Namespace) -> NoReturn:
|
||||
# pnpm and management commands expect to be run from the root of the
|
||||
# project.
|
||||
os.chdir(ZULIP_PATH)
|
||||
|
||||
# hash the apt dependencies
|
||||
sha_sum = hashlib.sha1()
|
||||
|
||||
for apt_dependency in SYSTEM_DEPENDENCIES:
|
||||
sha_sum.update(apt_dependency.encode())
|
||||
if "debian" in os_families():
|
||||
with open("scripts/lib/setup-apt-repo", "rb") as fb:
|
||||
sha_sum.update(fb.read())
|
||||
else:
|
||||
# hash the content of setup-yum-repo*
|
||||
with open("scripts/lib/setup-yum-repo", "rb") as fb:
|
||||
sha_sum.update(fb.read())
|
||||
|
||||
# hash the content of build-pgroonga if PGroonga is built from source
|
||||
if BUILD_PGROONGA_FROM_SOURCE:
|
||||
with open("scripts/lib/build-pgroonga", "rb") as fb:
|
||||
sha_sum.update(fb.read())
|
||||
|
||||
new_apt_dependencies_hash = sha_sum.hexdigest()
|
||||
last_apt_dependencies_hash = None
|
||||
apt_hash_file_path = os.path.join(UUID_VAR_PATH, "apt_dependencies_hash")
|
||||
with open(apt_hash_file_path, "a+") as hash_file:
|
||||
hash_file.seek(0)
|
||||
last_apt_dependencies_hash = hash_file.read()
|
||||
|
||||
if new_apt_dependencies_hash != last_apt_dependencies_hash:
|
||||
try:
|
||||
install_system_deps()
|
||||
except subprocess.CalledProcessError:
|
||||
try:
|
||||
# Might be a failure due to network connection issues. Retrying...
|
||||
print(WARNING + "Installing system dependencies failed; retrying..." + ENDC)
|
||||
install_system_deps()
|
||||
except BaseException as e:
|
||||
# Suppress exception chaining
|
||||
raise e from None
|
||||
with open(apt_hash_file_path, "w") as hash_file:
|
||||
hash_file.write(new_apt_dependencies_hash)
|
||||
else:
|
||||
print("No changes to apt dependencies, so skipping apt operations.")
|
||||
|
||||
# Here we install node.
|
||||
proxy_env = [
|
||||
"env",
|
||||
"http_proxy=" + os.environ.get("http_proxy", ""),
|
||||
"https_proxy=" + os.environ.get("https_proxy", ""),
|
||||
"no_proxy=" + os.environ.get("no_proxy", ""),
|
||||
]
|
||||
# Preserve PATH to catch mistaken extra installations of node in the user's
|
||||
# home directory.
|
||||
run_as_root([*proxy_env, "scripts/lib/install-node"], sudo_args=["--preserve-env=PATH"])
|
||||
|
||||
try:
|
||||
setup_node_modules()
|
||||
except subprocess.CalledProcessError:
|
||||
print(WARNING + "`pnpm install` failed; retrying..." + ENDC)
|
||||
try:
|
||||
setup_node_modules()
|
||||
except subprocess.CalledProcessError:
|
||||
print(
|
||||
FAIL
|
||||
+ "`pnpm install` is failing; check your network connection (and proxy settings)."
|
||||
+ ENDC
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Install shellcheck.
|
||||
run_as_root([*proxy_env, "tools/setup/install-shellcheck"], sudo_args=["--preserve-env=PATH"])
|
||||
# Install shfmt.
|
||||
run_as_root([*proxy_env, "tools/setup/install-shfmt"], sudo_args=["--preserve-env=PATH"])
|
||||
|
||||
# Install tusd
|
||||
run_as_root([*proxy_env, "tools/setup/install-tusd"], sudo_args=["--preserve-env=PATH"])
|
||||
|
||||
# Install Python environment
|
||||
run_as_root([*proxy_env, "scripts/lib/install-uv"], sudo_args=["--preserve-env=PATH"])
|
||||
run(
|
||||
[*proxy_env, "uv", "sync", "--frozen", "--no-managed-python"],
|
||||
env={k: v for k, v in os.environ.items() if k not in {"PYTHONDEVMODE", "PYTHONWARNINGS"}},
|
||||
)
|
||||
# Clean old symlinks used before uv migration
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink("zulip-py3-venv")
|
||||
if os.path.lexists("/srv/zulip-py3-venv"):
|
||||
run_as_root(["rm", "/srv/zulip-py3-venv"])
|
||||
|
||||
run_as_root(["cp", REPO_STOPWORDS_PATH, TSEARCH_STOPWORDS_PATH])
|
||||
|
||||
if CONTINUOUS_INTEGRATION and not options.is_build_release_tarball_only:
|
||||
run_as_root(["service", "redis-server", "start"])
|
||||
run_as_root(["service", "memcached", "start"])
|
||||
run_as_root(["service", "rabbitmq-server", "start"])
|
||||
run_as_root(["service", "postgresql", "start"])
|
||||
elif "fedora" in os_families():
|
||||
# These platforms don't enable and start services on
|
||||
# installing their package, so we do that here.
|
||||
for service in [
|
||||
f"postgresql-{POSTGRESQL_VERSION}",
|
||||
"rabbitmq-server",
|
||||
"memcached",
|
||||
"redis",
|
||||
]:
|
||||
run_as_root(["systemctl", "enable", service], sudo_args=["-H"])
|
||||
run_as_root(["systemctl", "start", service], sudo_args=["-H"])
|
||||
|
||||
# If we imported modules after activating the virtualenv in this
|
||||
# Python process, they could end up mismatching with modules we’ve
|
||||
# already imported from outside the virtualenv. That seems like a
|
||||
# bad idea, and empirically it can cause Python to segfault on
|
||||
# certain cffi-related imports. Instead, start a new Python
|
||||
# process inside the virtualenv.
|
||||
provision_inner = os.path.join(ZULIP_PATH, "tools", "lib", "provision_inner.py")
|
||||
os.execvp(
|
||||
"uv",
|
||||
[
|
||||
"uv",
|
||||
"run",
|
||||
"--no-sync",
|
||||
provision_inner,
|
||||
*(["--force"] if options.is_force else []),
|
||||
*(["--build-release-tarball-only"] if options.is_build_release_tarball_only else []),
|
||||
*(["--skip-dev-db-build"] if options.skip_dev_db_build else []),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
description = "Provision script to install Zulip"
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
dest="is_force",
|
||||
help="Ignore all provisioning optimizations.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--build-release-tarball-only",
|
||||
action="store_true",
|
||||
dest="is_build_release_tarball_only",
|
||||
help="Provision needed to build release tarball.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--skip-dev-db-build", action="store_true", help="Don't run migrations on dev database."
|
||||
)
|
||||
|
||||
options = parser.parse_args()
|
||||
main(options)
|
||||
451
tools/lib/provision_inner.py
Executable file
451
tools/lib/provision_inner.py
Executable file
@@ -0,0 +1,451 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
############################## NOTE ################################
|
||||
# This script is used to provision a development environment ONLY.
|
||||
# For production, extend update-prod-static to generate new static
|
||||
# assets, and puppet to install other software.
|
||||
####################################################################
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
sys.path.append(ZULIP_PATH)
|
||||
import pygments
|
||||
|
||||
from scripts.lib import clean_unused_caches
|
||||
from scripts.lib.zulip_tools import (
|
||||
ENDC,
|
||||
OKBLUE,
|
||||
get_dev_uuid_var_path,
|
||||
get_tzdata_zi,
|
||||
is_digest_obsolete,
|
||||
run,
|
||||
run_as_root,
|
||||
write_new_digest,
|
||||
)
|
||||
from tools.setup.generate_bots_integrations_static_files import (
|
||||
generate_pythonapi_integrations_static_files,
|
||||
generate_zulip_bots_static_files,
|
||||
)
|
||||
from version import PROVISION_VERSION
|
||||
|
||||
VENV_PATH = os.path.join(ZULIP_PATH, ".venv")
|
||||
UUID_VAR_PATH = get_dev_uuid_var_path()
|
||||
|
||||
with get_tzdata_zi() as f:
|
||||
line = f.readline()
|
||||
assert line.startswith("# version ")
|
||||
timezones_version = line.removeprefix("# version ")
|
||||
|
||||
|
||||
def create_var_directories() -> None:
|
||||
# create var/coverage, var/log, etc.
|
||||
var_dir = os.path.join(ZULIP_PATH, "var")
|
||||
sub_dirs = [
|
||||
"coverage",
|
||||
"log",
|
||||
"node-coverage",
|
||||
"test_uploads",
|
||||
"uploads",
|
||||
"xunit-test-results",
|
||||
]
|
||||
for sub_dir in sub_dirs:
|
||||
path = os.path.join(var_dir, sub_dir)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
def build_pygments_data_paths() -> list[str]:
|
||||
paths = [
|
||||
"tools/setup/build_pygments_data",
|
||||
"tools/setup/lang.json",
|
||||
]
|
||||
return paths
|
||||
|
||||
|
||||
def build_timezones_data_paths() -> list[str]:
|
||||
paths = [
|
||||
"tools/setup/build_timezone_values",
|
||||
]
|
||||
return paths
|
||||
|
||||
|
||||
def build_landing_page_images_paths() -> list[str]:
|
||||
paths = ["tools/setup/generate_landing_page_images.py"]
|
||||
paths += glob.glob("static/images/landing-page/hello/original/*")
|
||||
return paths
|
||||
|
||||
|
||||
def compilemessages_paths() -> list[str]:
|
||||
paths = ["zerver/management/commands/compilemessages.py"]
|
||||
paths += glob.glob("locale/*/LC_MESSAGES/*.po")
|
||||
paths += glob.glob("locale/*/translations.json")
|
||||
return paths
|
||||
|
||||
|
||||
def configure_rabbitmq_paths() -> list[str]:
|
||||
paths = [
|
||||
"scripts/setup/configure-rabbitmq",
|
||||
]
|
||||
return paths
|
||||
|
||||
|
||||
def is_wsl_instance() -> bool:
|
||||
if "WSL_DISTRO_NAME" in os.environ:
|
||||
return True
|
||||
|
||||
with open("/proc/version") as file:
|
||||
content = file.read().lower()
|
||||
if "microsoft" in content:
|
||||
return True
|
||||
|
||||
result = subprocess.run(["uname", "-r"], capture_output=True, text=True, check=True)
|
||||
if "microsoft" in result.stdout.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_vagrant_or_digitalocean_instance() -> bool:
|
||||
user_id = os.getuid()
|
||||
user_name = pwd.getpwuid(user_id).pw_name
|
||||
return user_name in ["vagrant", "zulipdev"]
|
||||
|
||||
|
||||
def setup_shell_profile(shell_profile: str) -> None:
|
||||
shell_profile_path = os.path.expanduser(shell_profile)
|
||||
if os.path.exists(shell_profile_path):
|
||||
with open(shell_profile_path) as f:
|
||||
code = f.read()
|
||||
else:
|
||||
code = ""
|
||||
|
||||
# We want to activate the virtual environment for login shells only on virtualized systems.
|
||||
zulip_code = ""
|
||||
if is_vagrant_or_digitalocean_instance() or is_wsl_instance():
|
||||
zulip_code += (
|
||||
"if [ -L /srv/zulip-py3-venv ]; then\n" # For development environment downgrades
|
||||
"source /srv/zulip-py3-venv/bin/activate\n" # Not indented so old versions recognize and avoid re-adding this
|
||||
"else\n"
|
||||
f"source {shlex.quote(os.path.join(VENV_PATH, 'bin', 'activate'))}\n"
|
||||
"fi\n"
|
||||
)
|
||||
zulip_code += f"cd {shlex.quote(ZULIP_PATH)}\n"
|
||||
if zulip_code:
|
||||
zulip_code = f"\n# begin Zulip setup\n{zulip_code}# end Zulip setup\n"
|
||||
|
||||
def patch_code(code: str) -> str:
|
||||
return re.sub(
|
||||
r"\n# begin Zulip setup\n(?s:.*)# end Zulip setup\n|(?:source /srv/zulip-py3-venv/bin/activate\n|cd /srv/zulip\n)+|\Z",
|
||||
lambda m: zulip_code,
|
||||
code,
|
||||
count=1,
|
||||
)
|
||||
|
||||
new_code = patch_code(code)
|
||||
if new_code != code:
|
||||
assert patch_code(new_code) == new_code
|
||||
with open(f"{shell_profile_path}.new", "w") as f:
|
||||
f.write(new_code)
|
||||
os.rename(f"{shell_profile_path}.new", shell_profile_path)
|
||||
|
||||
|
||||
def setup_bash_profile() -> None:
|
||||
"""Select a bash profile file to add setup code to."""
|
||||
|
||||
BASH_PROFILES = [
|
||||
os.path.expanduser(p) for p in ("~/.bash_profile", "~/.bash_login", "~/.profile")
|
||||
]
|
||||
|
||||
def clear_old_profile() -> None:
|
||||
# An earlier version of this script would output a fresh .bash_profile
|
||||
# even though a .profile existed in the image used. As a convenience to
|
||||
# existing developers (and, perhaps, future developers git-bisecting the
|
||||
# provisioning scripts), check for this situation, and blow away the
|
||||
# created .bash_profile if one is found.
|
||||
|
||||
BASH_PROFILE = BASH_PROFILES[0]
|
||||
DOT_PROFILE = BASH_PROFILES[2]
|
||||
OLD_PROFILE_TEXT = "source /srv/zulip-py3-venv/bin/activate\ncd /srv/zulip\n"
|
||||
|
||||
if os.path.exists(DOT_PROFILE):
|
||||
try:
|
||||
with open(BASH_PROFILE) as f:
|
||||
profile_contents = f.read()
|
||||
if profile_contents == OLD_PROFILE_TEXT:
|
||||
os.unlink(BASH_PROFILE)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
clear_old_profile()
|
||||
|
||||
for candidate_profile in BASH_PROFILES:
|
||||
if os.path.exists(candidate_profile):
|
||||
setup_shell_profile(candidate_profile)
|
||||
break
|
||||
else:
|
||||
# no existing bash profile found; claim .bash_profile
|
||||
setup_shell_profile(BASH_PROFILES[0])
|
||||
|
||||
|
||||
def need_to_run_build_pygments_data() -> bool:
|
||||
if not os.path.exists("web/generated/pygments_data.json"):
|
||||
return True
|
||||
|
||||
return is_digest_obsolete(
|
||||
"build_pygments_data_hash",
|
||||
build_pygments_data_paths(),
|
||||
[pygments.__version__],
|
||||
)
|
||||
|
||||
|
||||
def need_to_run_build_timezone_data() -> bool:
|
||||
if not os.path.exists("web/generated/timezones.json"):
|
||||
return True
|
||||
|
||||
return is_digest_obsolete(
|
||||
"build_timezones_data_hash",
|
||||
build_timezones_data_paths(),
|
||||
[timezones_version],
|
||||
)
|
||||
|
||||
|
||||
def need_to_regenerate_landing_page_images() -> bool:
|
||||
if not os.path.exists("static/images/landing-page/hello/generated"):
|
||||
return True
|
||||
|
||||
return is_digest_obsolete(
|
||||
"landing_page_images_hash",
|
||||
build_landing_page_images_paths(),
|
||||
)
|
||||
|
||||
|
||||
def need_to_run_compilemessages() -> bool:
|
||||
if not os.path.exists("locale/language_name_map.json"):
|
||||
# User may have cleaned their Git checkout.
|
||||
print("Need to run compilemessages due to missing language_name_map.json")
|
||||
return True
|
||||
|
||||
return is_digest_obsolete(
|
||||
"last_compilemessages_hash",
|
||||
compilemessages_paths(),
|
||||
)
|
||||
|
||||
|
||||
def need_to_run_configure_rabbitmq(settings_list: list[str]) -> bool:
|
||||
obsolete = is_digest_obsolete(
|
||||
"last_configure_rabbitmq_hash",
|
||||
configure_rabbitmq_paths(),
|
||||
settings_list,
|
||||
)
|
||||
|
||||
if obsolete:
|
||||
return True
|
||||
|
||||
try:
|
||||
from zerver.lib.queue import SimpleQueueClient
|
||||
|
||||
SimpleQueueClient()
|
||||
return False
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def main(options: argparse.Namespace) -> int:
|
||||
setup_bash_profile()
|
||||
setup_shell_profile("~/.zprofile")
|
||||
|
||||
# This needs to happen before anything that imports zproject.settings.
|
||||
run(["scripts/setup/generate_secrets.py", "--development"])
|
||||
|
||||
create_var_directories()
|
||||
|
||||
# The `build_emoji` script requires `emoji-datasource` package
|
||||
# which we install via npm; thus this step is after installing npm
|
||||
# packages.
|
||||
run(["tools/setup/emoji/build_emoji"])
|
||||
|
||||
# copy over static files from the zulip_bots and integrations packages
|
||||
generate_zulip_bots_static_files()
|
||||
generate_pythonapi_integrations_static_files()
|
||||
|
||||
run(["node", "tools/setup/build_supported_browser_regex.ts"])
|
||||
|
||||
if options.is_force or need_to_run_build_pygments_data():
|
||||
run(["tools/setup/build_pygments_data"])
|
||||
write_new_digest(
|
||||
"build_pygments_data_hash",
|
||||
build_pygments_data_paths(),
|
||||
[pygments.__version__],
|
||||
)
|
||||
else:
|
||||
print("No need to run `tools/setup/build_pygments_data`.")
|
||||
|
||||
if options.is_force or need_to_run_build_timezone_data():
|
||||
run(["tools/setup/build_timezone_values"])
|
||||
write_new_digest(
|
||||
"build_timezones_data_hash",
|
||||
build_timezones_data_paths(),
|
||||
[timezones_version],
|
||||
)
|
||||
else:
|
||||
print("No need to run `tools/setup/build_timezone_values`.")
|
||||
|
||||
if options.is_force or need_to_regenerate_landing_page_images():
|
||||
run(["tools/setup/generate_landing_page_images.py"])
|
||||
write_new_digest(
|
||||
"landing_page_images_hash",
|
||||
build_landing_page_images_paths(),
|
||||
)
|
||||
|
||||
if not options.is_build_release_tarball_only:
|
||||
# The following block is skipped when we just need the development
|
||||
# environment to build a release tarball.
|
||||
|
||||
# Need to set up Django before using template_status
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "zproject.settings")
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from zerver.lib.test_fixtures import (
|
||||
DEV_DATABASE,
|
||||
TEST_DATABASE,
|
||||
destroy_leaked_test_databases,
|
||||
)
|
||||
|
||||
if os.environ.get("CODESPACES") == "true":
|
||||
print("Starting background services explicitly for GitHub Codespaces...")
|
||||
run_as_root(["service", "postgresql", "start"])
|
||||
run_as_root(["service", "rabbitmq-server", "start"])
|
||||
run_as_root(["service", "redis-server", "start"])
|
||||
run_as_root(["service", "memcached", "start"])
|
||||
|
||||
assert settings.RABBITMQ_PASSWORD is not None
|
||||
if options.is_force or need_to_run_configure_rabbitmq([settings.RABBITMQ_PASSWORD]):
|
||||
run_as_root(["scripts/setup/configure-rabbitmq"])
|
||||
write_new_digest(
|
||||
"last_configure_rabbitmq_hash",
|
||||
configure_rabbitmq_paths(),
|
||||
[settings.RABBITMQ_PASSWORD],
|
||||
)
|
||||
else:
|
||||
print("No need to run `scripts/setup/configure-rabbitmq.")
|
||||
|
||||
dev_template_db_status = DEV_DATABASE.template_status()
|
||||
if options.is_force or dev_template_db_status == "needs_rebuild":
|
||||
run(["tools/setup/postgresql-init-dev-db"])
|
||||
if options.skip_dev_db_build:
|
||||
# We don't need to build the manual development
|
||||
# database on continuous integration for running tests, so we can
|
||||
# just leave it as a template db and save a minute.
|
||||
#
|
||||
# Important: We don't write a digest as that would
|
||||
# incorrectly claim that we ran migrations.
|
||||
pass
|
||||
else:
|
||||
run(["tools/rebuild-dev-database"])
|
||||
DEV_DATABASE.write_new_db_digest()
|
||||
elif dev_template_db_status == "run_migrations":
|
||||
DEV_DATABASE.run_db_migrations()
|
||||
elif dev_template_db_status == "current":
|
||||
print("No need to regenerate the dev DB.")
|
||||
|
||||
test_template_db_status = TEST_DATABASE.template_status()
|
||||
if options.is_force or test_template_db_status == "needs_rebuild":
|
||||
run(["tools/setup/postgresql-init-test-db"])
|
||||
run(["tools/rebuild-test-database"])
|
||||
TEST_DATABASE.write_new_db_digest()
|
||||
elif test_template_db_status == "run_migrations":
|
||||
TEST_DATABASE.run_db_migrations()
|
||||
elif test_template_db_status == "current":
|
||||
print("No need to regenerate the test DB.")
|
||||
|
||||
if options.is_force or need_to_run_compilemessages():
|
||||
run(["./manage.py", "compilemessages", "--ignore=*"])
|
||||
write_new_digest(
|
||||
"last_compilemessages_hash",
|
||||
compilemessages_paths(),
|
||||
)
|
||||
else:
|
||||
print("No need to run `manage.py compilemessages`.")
|
||||
|
||||
destroyed = destroy_leaked_test_databases()
|
||||
if destroyed:
|
||||
print(f"Dropped {destroyed} stale test databases!")
|
||||
|
||||
clean_unused_caches.main(
|
||||
argparse.Namespace(
|
||||
threshold_days=6,
|
||||
# The defaults here should match parse_cache_script_args in zulip_tools.py
|
||||
dry_run=False,
|
||||
verbose=False,
|
||||
no_headings=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Keeping this cache file around can cause eslint to throw
|
||||
# random TypeErrors when new/updated dependencies are added
|
||||
if os.path.isfile(".eslintcache"):
|
||||
# Remove this block when
|
||||
# https://github.com/eslint/eslint/issues/11639 is fixed
|
||||
# upstream.
|
||||
os.remove(".eslintcache")
|
||||
|
||||
# Clean up the root of the `var/` directory for various
|
||||
# testing-related files that we have migrated to
|
||||
# `var/<uuid>/test-backend`.
|
||||
print("Cleaning var/ directory files...")
|
||||
var_paths = glob.glob("var/test*")
|
||||
var_paths.append("var/bot_avatar")
|
||||
for path in var_paths:
|
||||
try:
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
version_file = os.path.join(UUID_VAR_PATH, "provision_version")
|
||||
print(f"writing to {version_file}\n")
|
||||
with open(version_file, "w") as f:
|
||||
f.write(".".join(map(str, PROVISION_VERSION)) + "\n")
|
||||
|
||||
print()
|
||||
print(OKBLUE + "Zulip development environment setup succeeded!" + ENDC)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
dest="is_force",
|
||||
help="Ignore all provisioning optimizations.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--build-release-tarball-only",
|
||||
action="store_true",
|
||||
dest="is_build_release_tarball_only",
|
||||
help="Provision for test suite with production settings.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--skip-dev-db-build", action="store_true", help="Don't run migrations on dev database."
|
||||
)
|
||||
|
||||
options = parser.parse_args()
|
||||
sys.exit(main(options))
|
||||
19
tools/lib/sanity_check.py
Normal file
19
tools/lib/sanity_check.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
import pwd
|
||||
import sys
|
||||
|
||||
|
||||
def check_venv(filename: str) -> None:
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
venv = os.path.realpath(os.path.join(BASE_DIR, ".venv"))
|
||||
if sys.prefix != venv:
|
||||
print(f"You need to run {filename} inside a Zulip dev environment.")
|
||||
user_id = os.getuid()
|
||||
user_name = pwd.getpwuid(user_id).pw_name
|
||||
|
||||
print(f"You can `source {venv}/bin/activate` to enter the development environment.")
|
||||
|
||||
if user_name not in ("vagrant", "zulipdev"):
|
||||
print()
|
||||
print("If you are using Vagrant, first run `vagrant ssh` to enter the Vagrant guest.")
|
||||
sys.exit(1)
|
||||
790
tools/lib/template_parser.py
Normal file
790
tools/lib/template_parser.py
Normal file
@@ -0,0 +1,790 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from .html_elements import FOREIGN_CONTEXTS, VALID_HTML_CONTEXTS, html_context_fallbacks
|
||||
|
||||
|
||||
class FormattedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateParserError(Exception):
|
||||
def __init__(self, message: str) -> None:
|
||||
self.message = message
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
class TokenizationError(Exception):
|
||||
def __init__(self, message: str, line_content: str | None = None) -> None:
|
||||
self.message = message
|
||||
self.line_content = line_content
|
||||
|
||||
|
||||
class TokenizerState:
|
||||
def __init__(self) -> None:
|
||||
self.i = 0
|
||||
self.line = 1
|
||||
self.col = 1
|
||||
|
||||
|
||||
class Token:
|
||||
def __init__(self, kind: str, s: str, tag: str, line: int, col: int, line_span: int) -> None:
|
||||
self.kind = kind
|
||||
self.s = s
|
||||
self.tag = tag
|
||||
self.line = line
|
||||
self.col = col
|
||||
self.line_span = line_span
|
||||
|
||||
# These get set during the validation pass.
|
||||
self.start_token: Token | None = None
|
||||
self.end_token: Token | None = None
|
||||
|
||||
# These get set during the pretty-print phase.
|
||||
self.new_s = ""
|
||||
self.indent: str | None = None
|
||||
self.orig_indent: str | None = None
|
||||
self.child_indent: str | None = None
|
||||
self.indent_is_final = False
|
||||
self.parent_token: Token | None = None
|
||||
|
||||
|
||||
def tokenize(text: str, template_format: str | None = None) -> list[Token]:
|
||||
in_code_block = False
|
||||
|
||||
def advance(n: int) -> None:
|
||||
for _ in range(n):
|
||||
state.i += 1
|
||||
if state.i >= 0 and text[state.i - 1] == "\n":
|
||||
state.line += 1
|
||||
state.col = 1
|
||||
else:
|
||||
state.col += 1
|
||||
|
||||
def looking_at(s: str) -> bool:
|
||||
return text[state.i : state.i + len(s)] == s
|
||||
|
||||
def looking_at_htmlcomment() -> bool:
|
||||
return looking_at("<!--")
|
||||
|
||||
def looking_at_handlebars_comment() -> bool:
|
||||
return looking_at("{{!") or looking_at("{{~!")
|
||||
|
||||
def looking_at_djangocomment() -> bool:
|
||||
return template_format == "django" and looking_at("{#")
|
||||
|
||||
def looking_at_handlebars_partial() -> bool:
|
||||
return template_format == "handlebars" and looking_at("{{>")
|
||||
|
||||
def looking_at_handlebars_partial_block() -> bool:
|
||||
return template_format == "handlebars" and looking_at("{{#>")
|
||||
|
||||
def looking_at_handlebars_triple_stache() -> bool:
|
||||
return template_format == "handlebars" and (looking_at("{{{") or looking_at("{{~{"))
|
||||
|
||||
def looking_at_html_start() -> bool:
|
||||
return looking_at("<") and not looking_at("</")
|
||||
|
||||
def looking_at_html_end() -> bool:
|
||||
return looking_at("</")
|
||||
|
||||
def looking_at_handlebars_start() -> bool:
|
||||
return looking_at("{{#") or looking_at("{{^") or looking_at("{{~#")
|
||||
|
||||
def looking_at_handlebars_else() -> bool:
|
||||
return template_format == "handlebars" and looking_at("{{else")
|
||||
|
||||
def looking_at_template_var() -> bool:
|
||||
return looking_at("{")
|
||||
|
||||
def looking_at_handlebars_end() -> bool:
|
||||
return template_format == "handlebars" and (looking_at("{{/") or looking_at("{{~/"))
|
||||
|
||||
def looking_at_django_start() -> bool:
|
||||
return template_format == "django" and looking_at("{% ")
|
||||
|
||||
def looking_at_django_else() -> bool:
|
||||
return template_format == "django" and (
|
||||
looking_at("{% else")
|
||||
or looking_at("{% elif")
|
||||
or looking_at("{%- else")
|
||||
or looking_at("{%- elif")
|
||||
)
|
||||
|
||||
def looking_at_django_end() -> bool:
|
||||
return template_format == "django" and looking_at("{% end")
|
||||
|
||||
def looking_at_jinja2_end_whitespace_stripped() -> bool:
|
||||
return template_format == "django" and looking_at("{%- end")
|
||||
|
||||
def looking_at_jinja2_start_whitespace_stripped_type2() -> bool:
|
||||
# This function detects tag like {%- if foo -%}...{% endif %}
|
||||
return template_format == "django" and looking_at("{%-") and not looking_at("{%- end")
|
||||
|
||||
def looking_at_whitespace() -> bool:
|
||||
return looking_at("\n") or looking_at(" ")
|
||||
|
||||
state = TokenizerState()
|
||||
tokens: list[Token] = []
|
||||
|
||||
while state.i < len(text):
|
||||
try:
|
||||
if in_code_block:
|
||||
in_code_block = False
|
||||
s = get_code(text, state.i)
|
||||
if s == "":
|
||||
continue
|
||||
tag = ""
|
||||
kind = "code"
|
||||
elif looking_at_htmlcomment():
|
||||
s = get_html_comment(text, state.i)
|
||||
tag = s[4:-3]
|
||||
kind = "html_comment"
|
||||
elif looking_at_handlebars_comment():
|
||||
s = get_handlebars_comment(text, state.i)
|
||||
tag = s[3:-2]
|
||||
kind = "handlebars_comment"
|
||||
elif looking_at_djangocomment():
|
||||
s = get_django_comment(text, state.i)
|
||||
tag = s[2:-2]
|
||||
kind = "django_comment"
|
||||
elif looking_at_handlebars_partial():
|
||||
s = get_handlebars_partial(text, state.i)
|
||||
tag = s[9:-2]
|
||||
kind = "handlebars_partial"
|
||||
elif looking_at_handlebars_partial_block():
|
||||
s = get_handlebars_partial(text, state.i)
|
||||
tag = s[5:-2].split(None, 1)[0]
|
||||
kind = "handlebars_partial_block"
|
||||
elif looking_at_html_start():
|
||||
s = get_html_tag(text, state.i)
|
||||
if s.endswith("/>"):
|
||||
end_offset = -2
|
||||
else:
|
||||
end_offset = -1
|
||||
tag_parts = s[1:end_offset].split()
|
||||
|
||||
if not tag_parts:
|
||||
raise TemplateParserError("Tag name missing")
|
||||
|
||||
tag = tag_parts[0]
|
||||
|
||||
if tag == "!DOCTYPE":
|
||||
kind = "html_doctype"
|
||||
elif s.endswith("/>"):
|
||||
kind = "html_singleton"
|
||||
else:
|
||||
kind = "html_start"
|
||||
if tag in ("code", "pre", "script"):
|
||||
in_code_block = True
|
||||
elif looking_at_html_end():
|
||||
s = get_html_tag(text, state.i)
|
||||
tag = s[2:-1]
|
||||
kind = "html_end"
|
||||
elif looking_at_handlebars_else():
|
||||
s = get_handlebars_tag(text, state.i)
|
||||
tag = "else"
|
||||
kind = "handlebars_else"
|
||||
elif looking_at_handlebars_triple_stache():
|
||||
s = get_handlebars_triple_stache_tag(text, state.i)
|
||||
start_offset = end_offset = 3
|
||||
if s.startswith("{{~{"):
|
||||
start_offset += 1
|
||||
if s.endswith("}~}}"):
|
||||
end_offset += 1
|
||||
tag = s[start_offset:-end_offset].strip()
|
||||
if not tag.endswith("_html"):
|
||||
raise TemplateParserError(
|
||||
"Unescaped variables in triple staches {{{ }}} must be suffixed with `_html`"
|
||||
)
|
||||
kind = "handlebars_triple_stache"
|
||||
elif looking_at_handlebars_start():
|
||||
s = get_handlebars_tag(text, state.i)
|
||||
tag = s[3:-2].split()[0].strip("#").removeprefix("*")
|
||||
kind = "handlebars_start"
|
||||
elif looking_at_handlebars_end():
|
||||
s = get_handlebars_tag(text, state.i)
|
||||
tag = s[3:-2].strip("/#~")
|
||||
kind = "handlebars_end"
|
||||
elif looking_at_django_else():
|
||||
s = get_django_tag(text, state.i)
|
||||
tag = "else"
|
||||
kind = "django_else"
|
||||
elif looking_at_django_end():
|
||||
s = get_django_tag(text, state.i)
|
||||
tag = s[6:-3]
|
||||
kind = "django_end"
|
||||
elif looking_at_django_start():
|
||||
# must check this after end/else
|
||||
s = get_django_tag(text, state.i)
|
||||
tag = s[3:-2].split()[0]
|
||||
kind = "django_start"
|
||||
|
||||
if s[-3] == "-":
|
||||
kind = "jinja2_whitespace_stripped_start"
|
||||
elif looking_at_jinja2_end_whitespace_stripped():
|
||||
s = get_django_tag(text, state.i)
|
||||
tag = s[7:-3]
|
||||
kind = "jinja2_whitespace_stripped_end"
|
||||
elif looking_at_jinja2_start_whitespace_stripped_type2():
|
||||
s = get_django_tag(text, state.i, stripped=True)
|
||||
tag = s[3:-3].split()[0]
|
||||
kind = "jinja2_whitespace_stripped_type2_start"
|
||||
elif looking_at_template_var():
|
||||
# order is important here
|
||||
s = get_template_var(text, state.i)
|
||||
tag = "var"
|
||||
kind = "template_var"
|
||||
elif looking_at("\n"):
|
||||
s = "\n"
|
||||
tag = "newline"
|
||||
kind = "newline"
|
||||
elif looking_at(" "):
|
||||
s = get_spaces(text, state.i)
|
||||
tag = ""
|
||||
if not tokens or tokens[-1].kind == "newline":
|
||||
kind = "indent"
|
||||
else:
|
||||
kind = "whitespace"
|
||||
elif text[state.i] in "{<":
|
||||
snippet = text[state.i :][:15]
|
||||
raise AssertionError(f"tool cannot parse {snippet}")
|
||||
else:
|
||||
s = get_text(text, state.i)
|
||||
if s == "":
|
||||
continue
|
||||
tag = ""
|
||||
kind = "text"
|
||||
except TokenizationError as e:
|
||||
raise FormattedError(
|
||||
f'''{e.message} at line {state.line} col {state.col}:"{e.line_content}"''',
|
||||
)
|
||||
|
||||
line_span = len(s.strip("\n").split("\n"))
|
||||
token = Token(
|
||||
kind=kind,
|
||||
s=s,
|
||||
tag=tag.strip(),
|
||||
line=state.line,
|
||||
col=state.col,
|
||||
line_span=line_span,
|
||||
)
|
||||
tokens.append(token)
|
||||
advance(len(s))
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
# The following excludes some obscure tags that are never used
|
||||
# in Zulip code.
|
||||
HTML_INLINE_TAGS = {
|
||||
"a",
|
||||
"b",
|
||||
"br",
|
||||
"button",
|
||||
"cite",
|
||||
"code",
|
||||
"em",
|
||||
"i",
|
||||
"img",
|
||||
"input",
|
||||
"kbd",
|
||||
"label",
|
||||
"object",
|
||||
"script",
|
||||
"select",
|
||||
"small",
|
||||
"span",
|
||||
"strong",
|
||||
"textarea",
|
||||
}
|
||||
|
||||
|
||||
def tag_flavor(token: Token) -> str | None:
|
||||
kind = token.kind
|
||||
tag = token.tag
|
||||
if kind in (
|
||||
"code",
|
||||
"django_comment",
|
||||
"handlebars_comment",
|
||||
"handlebars_partial",
|
||||
"html_comment",
|
||||
"html_doctype",
|
||||
"html_singleton",
|
||||
"indent",
|
||||
"newline",
|
||||
"template_var",
|
||||
"text",
|
||||
"whitespace",
|
||||
"handlebars_triple_stache",
|
||||
):
|
||||
return None
|
||||
|
||||
if kind in ("handlebars_start", "handlebars_partial_block", "html_start"):
|
||||
return "start"
|
||||
elif kind in (
|
||||
"django_else",
|
||||
"django_end",
|
||||
"handlebars_else",
|
||||
"handlebars_end",
|
||||
"html_end",
|
||||
"jinja2_whitespace_stripped_end",
|
||||
):
|
||||
return "end"
|
||||
elif kind in {
|
||||
"django_start",
|
||||
"django_else",
|
||||
"jinja2_whitespace_stripped_start",
|
||||
"jinja2_whitespace_stripped_type2_start",
|
||||
}:
|
||||
if is_django_block_tag(tag):
|
||||
return "start"
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
raise AssertionError(f"tools programmer neglected to handle {kind} tokens")
|
||||
|
||||
|
||||
def validate(
|
||||
fn: str | None = None,
|
||||
text: str | None = None,
|
||||
template_format: str | None = None,
|
||||
) -> list[Token]:
|
||||
assert fn or text
|
||||
|
||||
if fn is None:
|
||||
fn = "<in memory file>"
|
||||
|
||||
if text is None:
|
||||
with open(fn) as f:
|
||||
text = f.read()
|
||||
|
||||
lines = text.split("\n")
|
||||
|
||||
try:
|
||||
tokens = tokenize(text, template_format=template_format)
|
||||
except FormattedError as e:
|
||||
raise TemplateParserError(
|
||||
f"""
|
||||
fn: {fn}
|
||||
{e}"""
|
||||
)
|
||||
|
||||
prevent_whitespace_violations(fn, tokens)
|
||||
|
||||
class State:
|
||||
def __init__(self, func: Callable[[Token | None], None]) -> None:
|
||||
self.depth = 0
|
||||
self.matcher = func
|
||||
self.html_context = "unknown"
|
||||
|
||||
def no_start_tag(token: Token | None) -> None:
|
||||
assert token
|
||||
raise TemplateParserError(
|
||||
f"""
|
||||
No start tag
|
||||
fn: {fn}
|
||||
end tag:
|
||||
{token.tag}
|
||||
line {token.line}, col {token.col}
|
||||
"""
|
||||
)
|
||||
|
||||
state = State(no_start_tag)
|
||||
|
||||
def start_tag_matcher(start_token: Token) -> None:
|
||||
state.depth += 1
|
||||
start_tag = start_token.tag.strip("~")
|
||||
start_line = start_token.line
|
||||
start_col = start_token.col
|
||||
|
||||
old_matcher = state.matcher
|
||||
old_html_context = state.html_context
|
||||
|
||||
def f(end_token: Token | None) -> None:
|
||||
if end_token is None:
|
||||
raise TemplateParserError(
|
||||
f"""
|
||||
|
||||
Problem with {fn}
|
||||
Missing end tag for the token at row {start_line} {start_col}!
|
||||
|
||||
{start_token.s}
|
||||
|
||||
It's possible you have a typo in a token that you think is
|
||||
matching this tag.
|
||||
"""
|
||||
)
|
||||
|
||||
is_else_tag = end_token.tag == "else"
|
||||
|
||||
end_tag = end_token.tag.strip("~")
|
||||
end_line = end_token.line
|
||||
end_col = end_token.col
|
||||
|
||||
def report_problem() -> str | None:
|
||||
if (start_tag == "code") and (end_line == start_line + 1):
|
||||
return "Code tag is split across two lines."
|
||||
|
||||
if is_else_tag:
|
||||
# We are not completely rigorous about having a sensible
|
||||
# order of if/elif/elif/else, but we catch obviously
|
||||
# mismatching else tags.
|
||||
if start_tag not in ("if", "else", "unless", "list_each"):
|
||||
return f"Unexpected else/elif tag encountered after {start_tag} tag."
|
||||
elif start_tag != end_tag:
|
||||
return f"Mismatched tags: ({start_tag} != {end_tag})"
|
||||
|
||||
return None
|
||||
|
||||
problem = report_problem()
|
||||
if problem:
|
||||
raise TemplateParserError(
|
||||
f"""
|
||||
fn: {fn}
|
||||
{problem}
|
||||
start:
|
||||
{start_token.s}
|
||||
line {start_line}, col {start_col}
|
||||
end tag:
|
||||
{end_tag}
|
||||
line {end_line}, col {end_col}
|
||||
"""
|
||||
)
|
||||
|
||||
if not is_else_tag:
|
||||
state.matcher = old_matcher
|
||||
state.html_context = old_html_context
|
||||
state.depth -= 1
|
||||
|
||||
# TODO: refine this for the else/elif use cases
|
||||
end_token.start_token = start_token
|
||||
start_token.end_token = end_token
|
||||
|
||||
state.matcher = f
|
||||
|
||||
for token in tokens:
|
||||
kind = token.kind
|
||||
tag = token.tag
|
||||
|
||||
flavor = tag_flavor(token)
|
||||
if flavor == "start":
|
||||
start_tag_matcher(token)
|
||||
elif flavor == "end":
|
||||
state.matcher(token)
|
||||
|
||||
if kind in ("html_start", "html_singleton"):
|
||||
for context in html_context_fallbacks(state.html_context):
|
||||
if (tag, context) in VALID_HTML_CONTEXTS:
|
||||
new_context = VALID_HTML_CONTEXTS[tag, context]
|
||||
if new_context == "transparent":
|
||||
new_context = state.html_context
|
||||
break
|
||||
else:
|
||||
if "-" in tag and "phrasing" in html_context_fallbacks(state.html_context):
|
||||
new_context = state.html_context # custom elements
|
||||
elif state.html_context in FOREIGN_CONTEXTS:
|
||||
new_context = state.html_context # unchecked foreign elements
|
||||
else:
|
||||
raise TemplateParserError(
|
||||
f"<{tag}> is not valid in {state.html_context} context"
|
||||
+ (
|
||||
' (consider growing HTML_CONTEXT_FALLBACKS["unknown"]?)'
|
||||
if state.html_context == "unknown"
|
||||
else ""
|
||||
)
|
||||
+ f" at {fn} line {token.line}, col {token.col}"
|
||||
)
|
||||
|
||||
if new_context not in FOREIGN_CONTEXTS:
|
||||
if kind == "html_start" and new_context == "void":
|
||||
raise TemplateParserError(
|
||||
f"Tag must be self-closing: {tag} at {fn} line {token.line}, col {token.col}"
|
||||
)
|
||||
elif kind == "html_singleton" and new_context != "void":
|
||||
raise TemplateParserError(
|
||||
f"Tag must not be self-closing: {tag} at {fn} line {token.line}, col {token.col}"
|
||||
)
|
||||
|
||||
if kind == "html_start":
|
||||
state.html_context = new_context
|
||||
|
||||
if state.depth != 0:
|
||||
state.matcher(None)
|
||||
|
||||
ensure_matching_indentation(fn, tokens, lines)
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def ensure_matching_indentation(fn: str, tokens: list[Token], lines: list[str]) -> None:
|
||||
def has_bad_indentation() -> bool:
|
||||
is_inline_tag = start_tag in HTML_INLINE_TAGS and start_token.kind == "html_start"
|
||||
|
||||
if end_line > start_line + 1:
|
||||
if is_inline_tag:
|
||||
end_row_text = lines[end_line - 1]
|
||||
if end_row_text.lstrip().startswith(end_token.s) and end_col != start_col:
|
||||
return True
|
||||
else:
|
||||
if end_col != start_col:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
for token in tokens:
|
||||
if token.start_token is None:
|
||||
continue
|
||||
|
||||
end_token = token
|
||||
|
||||
start_token = token.start_token
|
||||
start_line = start_token.line
|
||||
start_col = start_token.col
|
||||
start_tag = start_token.tag
|
||||
end_tag = end_token.tag.strip("~")
|
||||
end_line = end_token.line
|
||||
end_col = end_token.col
|
||||
|
||||
if has_bad_indentation():
|
||||
raise TemplateParserError(
|
||||
f"""
|
||||
fn: {fn}
|
||||
Indentation for start/end tags does not match.
|
||||
start tag: {start_token.s}
|
||||
|
||||
start:
|
||||
line {start_line}, col {start_col}
|
||||
end:
|
||||
{end_tag}
|
||||
line {end_line}, col {end_col}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def prevent_extra_newlines(fn: str, tokens: list[Token]) -> None:
|
||||
count = 0
|
||||
|
||||
for token in tokens:
|
||||
if token.kind != "newline":
|
||||
count = 0
|
||||
continue
|
||||
|
||||
count += 1
|
||||
if count >= 4:
|
||||
raise TemplateParserError(
|
||||
f"""Please avoid so many blank lines near row {token.line} in {fn}."""
|
||||
)
|
||||
|
||||
|
||||
def prevent_whitespace_violations(fn: str, tokens: list[Token]) -> None:
|
||||
if tokens[0].kind in ("indent", "whitespace"):
|
||||
raise TemplateParserError(f" Please remove the whitespace at the beginning of {fn}.")
|
||||
|
||||
prevent_extra_newlines(fn, tokens)
|
||||
|
||||
for i in range(1, len(tokens) - 1):
|
||||
token = tokens[i]
|
||||
next_token = tokens[i + 1]
|
||||
|
||||
if token.kind == "indent":
|
||||
if next_token.kind in ("indent", "whitespace"):
|
||||
raise AssertionError("programming error parsing indents")
|
||||
|
||||
if next_token.kind == "newline":
|
||||
raise TemplateParserError(
|
||||
f"""Please just make row {token.line} in {fn} a truly blank line (no spaces)."""
|
||||
)
|
||||
|
||||
if len(token.s) % 4 != 0:
|
||||
raise TemplateParserError(
|
||||
f"""
|
||||
Please use 4-space indents for template files. Most of our
|
||||
codebase (including Python and JavaScript) uses 4-space indents,
|
||||
so it's worth investing in configuring your editor to use
|
||||
4-space indents for files like
|
||||
{fn}
|
||||
|
||||
The line at row {token.line} is indented with {len(token.s)} spaces.
|
||||
"""
|
||||
)
|
||||
|
||||
if token.kind == "whitespace":
|
||||
if len(token.s) > 1:
|
||||
raise TemplateParserError(
|
||||
f"""
|
||||
We did not expect this much whitespace at row {token.line} column {token.col} in {fn}.
|
||||
"""
|
||||
)
|
||||
if next_token.kind == "newline":
|
||||
raise TemplateParserError(
|
||||
f"""
|
||||
Unexpected trailing whitespace at row {token.line} column {token.col} in {fn}.
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def is_django_block_tag(tag: str) -> bool:
|
||||
return tag in [
|
||||
"autoescape",
|
||||
"block",
|
||||
"comment",
|
||||
"for",
|
||||
"if",
|
||||
"ifequal",
|
||||
"macro",
|
||||
"verbatim",
|
||||
"blocktrans",
|
||||
"trans",
|
||||
"raw",
|
||||
"with",
|
||||
]
|
||||
|
||||
|
||||
def get_handlebars_tag(text: str, i: int) -> str:
|
||||
end = i + 2
|
||||
while end < len(text) - 2 and text[end] != "}":
|
||||
end += 1
|
||||
if text[end] != "}" or text[end + 1] != "}":
|
||||
raise TokenizationError('Tag missing "}}"', text[i : end + 2])
|
||||
s = text[i : end + 2]
|
||||
return s
|
||||
|
||||
|
||||
def get_handlebars_triple_stache_tag(text: str, i: int) -> str:
|
||||
end = i + 3
|
||||
while end < len(text) - 3 and text[end] != "}":
|
||||
end += 1
|
||||
if text[end : end + 3] == "}}}":
|
||||
return text[i : end + 3]
|
||||
elif end + 4 <= len(text) and text[end : end + 4] == "}~}}":
|
||||
return text[i : end + 4]
|
||||
else:
|
||||
raise TokenizationError('Tag missing "}}}"', text[i : end + 3])
|
||||
|
||||
|
||||
def get_spaces(text: str, i: int) -> str:
|
||||
s = ""
|
||||
while i < len(text) and text[i] == " ":
|
||||
s += text[i]
|
||||
i += 1
|
||||
return s
|
||||
|
||||
|
||||
def get_code(text: str, i: int) -> str:
|
||||
s = ""
|
||||
while i < len(text) and text[i] != "<":
|
||||
s += text[i]
|
||||
i += 1
|
||||
return s
|
||||
|
||||
|
||||
def get_text(text: str, i: int) -> str:
|
||||
s = ""
|
||||
while i < len(text) and text[i] not in "{<":
|
||||
s += text[i]
|
||||
i += 1
|
||||
return s.strip()
|
||||
|
||||
|
||||
def get_django_tag(text: str, i: int, stripped: bool = False) -> str:
|
||||
end = i + 2
|
||||
if stripped:
|
||||
end += 1
|
||||
while end < len(text) - 1 and text[end] != "%":
|
||||
end += 1
|
||||
if text[end] != "%" or text[end + 1] != "}":
|
||||
raise TokenizationError('Tag missing "%}"', text[i : end + 2])
|
||||
s = text[i : end + 2]
|
||||
return s
|
||||
|
||||
|
||||
def get_html_tag(text: str, i: int) -> str:
|
||||
quote_count = 0
|
||||
end = i + 1
|
||||
unclosed_end = 0
|
||||
while end < len(text) and (text[end] != ">" or (quote_count % 2 != 0 and text[end] != "<")):
|
||||
if text[end] == '"':
|
||||
quote_count += 1
|
||||
if not unclosed_end and text[end] == "<":
|
||||
unclosed_end = end
|
||||
end += 1
|
||||
if quote_count % 2 != 0:
|
||||
if unclosed_end:
|
||||
raise TokenizationError("Unbalanced quotes", text[i:unclosed_end])
|
||||
else:
|
||||
raise TokenizationError("Unbalanced quotes", text[i : end + 1])
|
||||
if end == len(text) or text[end] != ">":
|
||||
raise TokenizationError('Tag missing ">"', text[i : end + 1])
|
||||
s = text[i : end + 1]
|
||||
return s
|
||||
|
||||
|
||||
def get_html_comment(text: str, i: int) -> str:
|
||||
end = i + 7
|
||||
unclosed_end = 0
|
||||
while end <= len(text):
|
||||
if text[end - 3 : end] == "-->":
|
||||
return text[i:end]
|
||||
if not unclosed_end and text[end] == "<":
|
||||
unclosed_end = end
|
||||
end += 1
|
||||
raise TokenizationError("Unclosed comment", text[i:unclosed_end])
|
||||
|
||||
|
||||
def get_handlebars_comment(text: str, i: int) -> str:
|
||||
long = text[i : i + 5] == "{{!--" or text[i : i + 6] == "{{~!--"
|
||||
end = i + 5
|
||||
unclosed_end = 0
|
||||
while end <= len(text):
|
||||
if text[end - 2 : end] == "}}" and (
|
||||
not long or text[end - 4 : end] == "--}}" or text[end - 5 : end] == "--~}}"
|
||||
):
|
||||
return text[i:end]
|
||||
if not unclosed_end and text[end] == "<":
|
||||
unclosed_end = end
|
||||
end += 1
|
||||
raise TokenizationError("Unclosed comment", text[i:unclosed_end])
|
||||
|
||||
|
||||
def get_template_var(text: str, i: int) -> str:
|
||||
end = i + 3
|
||||
unclosed_end = 0
|
||||
while end <= len(text):
|
||||
if text[end - 1] == "}":
|
||||
if end < len(text) and text[end] == "}":
|
||||
end += 1
|
||||
return text[i:end]
|
||||
if not unclosed_end and text[end] == "<":
|
||||
unclosed_end = end
|
||||
end += 1
|
||||
raise TokenizationError("Unclosed var", text[i:unclosed_end])
|
||||
|
||||
|
||||
def get_django_comment(text: str, i: int) -> str:
|
||||
end = i + 4
|
||||
unclosed_end = 0
|
||||
while end <= len(text):
|
||||
if text[end - 2 : end] == "#}":
|
||||
return text[i:end]
|
||||
if not unclosed_end and text[end] == "<":
|
||||
unclosed_end = end
|
||||
end += 1
|
||||
raise TokenizationError("Unclosed comment", text[i:unclosed_end])
|
||||
|
||||
|
||||
def get_handlebars_partial(text: str, i: int) -> str:
|
||||
"""Works for both partials and partial blocks."""
|
||||
end = i + 10
|
||||
unclosed_end = 0
|
||||
while end <= len(text):
|
||||
if text[end - 2 : end] == "}}":
|
||||
return text[i:end]
|
||||
if not unclosed_end and text[end] == "<":
|
||||
unclosed_end = end
|
||||
end += 1
|
||||
raise TokenizationError("Unclosed partial", text[i:unclosed_end])
|
||||
128
tools/lib/test_script.py
Normal file
128
tools/lib/test_script.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from argparse import ArgumentParser
|
||||
from collections.abc import Iterable
|
||||
|
||||
from scripts.lib.zulip_tools import get_dev_uuid_var_path
|
||||
from version import PROVISION_VERSION
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def get_version_file() -> str:
|
||||
uuid_var_path = get_dev_uuid_var_path()
|
||||
return os.path.join(uuid_var_path, "provision_version")
|
||||
|
||||
|
||||
PREAMBLE = """
|
||||
Provisioning state check failed! This check compares
|
||||
`var/provision_version` (currently {}) to the version in
|
||||
source control (`version.py`), which is {}, to see if you
|
||||
likely need to provision before this command can run
|
||||
properly.
|
||||
"""
|
||||
|
||||
|
||||
def preamble(version: tuple[int, ...]) -> str:
|
||||
text = PREAMBLE.format(version, PROVISION_VERSION)
|
||||
text += "\n"
|
||||
return text
|
||||
|
||||
|
||||
NEED_TO_DOWNGRADE = """
|
||||
The branch you are currently on expects an older version of
|
||||
dependencies than the version you provisioned last. This may
|
||||
be ok, but it's likely that you either want to rebase your
|
||||
branch on top of upstream/main or re-provision your machine.
|
||||
|
||||
Do this: `./tools/provision`
|
||||
"""
|
||||
|
||||
NEED_TO_UPGRADE = """
|
||||
The branch you are currently on has added dependencies beyond
|
||||
what you last provisioned. Your command is likely to fail
|
||||
until you add dependencies by provisioning.
|
||||
|
||||
Do this: `./tools/provision`
|
||||
"""
|
||||
|
||||
|
||||
def get_provisioning_status() -> tuple[bool, str | None]:
|
||||
version_file = get_version_file()
|
||||
if not os.path.exists(version_file):
|
||||
# If the developer doesn't have a version_file written by
|
||||
# a previous provision, then we don't do any safety checks
|
||||
# here on the assumption that the developer is managing
|
||||
# their own dependencies and not running provision.
|
||||
return True, None
|
||||
|
||||
with open(version_file) as f:
|
||||
version = tuple(map(int, f.read().strip().split(".")))
|
||||
|
||||
# We may be more provisioned than the branch we just moved to. As
|
||||
# long as the major version hasn't changed, then we should be ok.
|
||||
if version < PROVISION_VERSION:
|
||||
return False, preamble(version) + NEED_TO_UPGRADE
|
||||
elif version < (PROVISION_VERSION[0] + 1,):
|
||||
return True, None
|
||||
else:
|
||||
return False, preamble(version) + NEED_TO_DOWNGRADE
|
||||
|
||||
|
||||
def assert_provisioning_status_ok(skip_provision_check: bool) -> None:
|
||||
if not skip_provision_check:
|
||||
ok, msg = get_provisioning_status()
|
||||
if not ok:
|
||||
print(msg)
|
||||
print(
|
||||
"If you really know what you are doing, use --skip-provision-check to run anyway."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def add_provision_check_override_param(parser: ArgumentParser) -> None:
|
||||
"""
|
||||
Registers --skip-provision-check argument to be used with various commands/tests in our tools.
|
||||
"""
|
||||
parser.add_argument(
|
||||
"--skip-provision-check",
|
||||
action="store_true",
|
||||
help="Skip check that provision has been run; useful to save time if you know the dependency changes are not relevant to this command and will not cause it to fail",
|
||||
)
|
||||
|
||||
|
||||
def find_js_test_files(test_dir: str, files: Iterable[str]) -> list[str]:
|
||||
test_files = []
|
||||
for file in files:
|
||||
file = min(
|
||||
(
|
||||
os.path.join(test_dir, file_name)
|
||||
for file_name in os.listdir(test_dir)
|
||||
if file_name.startswith(file)
|
||||
),
|
||||
default=file,
|
||||
)
|
||||
|
||||
if not os.path.isfile(file):
|
||||
raise Exception(f"Cannot find a matching file for '{file}' in '{test_dir}'")
|
||||
|
||||
test_files.append(os.path.abspath(file))
|
||||
|
||||
if not test_files:
|
||||
test_files = sorted(
|
||||
glob.glob(os.path.join(test_dir, "*.ts")) + glob.glob(os.path.join(test_dir, "*.js"))
|
||||
)
|
||||
|
||||
return test_files
|
||||
|
||||
|
||||
def prepare_puppeteer_run(is_firefox: bool = False) -> None:
|
||||
os.chdir(ZULIP_PATH)
|
||||
# This will determine if the browser will be firefox or chrome.
|
||||
os.environ["PUPPETEER_PRODUCT"] = "firefox" if is_firefox else "chrome"
|
||||
subprocess.check_call(["node", "install.mjs"], cwd="node_modules/puppeteer")
|
||||
os.makedirs("var/puppeteer", exist_ok=True)
|
||||
for f in glob.glob("var/puppeteer/failure-*.png"):
|
||||
os.remove(f)
|
||||
109
tools/lib/test_server.py
Normal file
109
tools/lib/test_server.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import ExitStack, contextmanager
|
||||
|
||||
# Verify the Zulip venv is available.
|
||||
from tools.lib import sanity_check
|
||||
|
||||
sanity_check.check_venv(__file__)
|
||||
|
||||
import django
|
||||
import requests
|
||||
|
||||
MAX_SERVER_WAIT = 180
|
||||
|
||||
TOOLS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if TOOLS_DIR not in sys.path:
|
||||
sys.path.insert(0, os.path.dirname(TOOLS_DIR))
|
||||
|
||||
from scripts.lib.zulip_tools import get_or_create_dev_uuid_var_path
|
||||
from zerver.lib.test_fixtures import update_test_databases_if_required
|
||||
|
||||
|
||||
def set_up_django(external_host: str) -> None:
|
||||
os.environ["FULL_STACK_ZULIP_TEST"] = "1"
|
||||
os.environ["TEST_EXTERNAL_HOST"] = external_host
|
||||
os.environ["LOCAL_UPLOADS_DIR"] = get_or_create_dev_uuid_var_path("test-backend/test_uploads")
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.test_settings"
|
||||
django.setup()
|
||||
os.environ["PYTHONUNBUFFERED"] = "y"
|
||||
|
||||
|
||||
def assert_server_running(server: "subprocess.Popen[bytes]", log_file: str | None) -> None:
|
||||
"""Get the exit code of the server, or None if it is still running."""
|
||||
if server.poll() is not None:
|
||||
message = "Server died unexpectedly!"
|
||||
if log_file:
|
||||
message += f"\nSee {log_file}\n"
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def server_is_up(server: "subprocess.Popen[bytes]", log_file: str | None) -> bool:
|
||||
assert_server_running(server, log_file)
|
||||
try:
|
||||
# We could get a 501 error if the reverse proxy is up but the Django app isn't.
|
||||
# Note that zulipdev.com is mapped via DNS to 127.0.0.1.
|
||||
return requests.get("http://zulipdev.com:9981/accounts/home").status_code == 200
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def test_server_running(
|
||||
skip_provision_check: bool = False,
|
||||
external_host: str = "testserver",
|
||||
log_file: str | None = None,
|
||||
dots: bool = False,
|
||||
enable_help_center: bool = False,
|
||||
) -> Iterator[None]:
|
||||
with ExitStack() as stack:
|
||||
log = sys.stdout
|
||||
if log_file:
|
||||
if os.path.exists(log_file) and os.path.getsize(log_file) < 100000:
|
||||
log = stack.enter_context(open(log_file, "a"))
|
||||
log.write("\n\n")
|
||||
else:
|
||||
log = stack.enter_context(open(log_file, "w"))
|
||||
|
||||
set_up_django(external_host)
|
||||
|
||||
update_test_databases_if_required(rebuild_test_database=True)
|
||||
|
||||
# Run this not through the shell, so that we have the actual PID.
|
||||
run_dev_server_command = ["tools/run-dev", "--test", "--streamlined"]
|
||||
if skip_provision_check:
|
||||
run_dev_server_command.append("--skip-provision-check")
|
||||
if enable_help_center:
|
||||
run_dev_server_command.append("--help-center-static-build")
|
||||
server = subprocess.Popen(run_dev_server_command, stdout=log, stderr=log)
|
||||
|
||||
try:
|
||||
# Wait for the server to start up.
|
||||
print(end="\nWaiting for test server (may take a while)")
|
||||
if not dots:
|
||||
print("\n", flush=True)
|
||||
t = time.time()
|
||||
while not server_is_up(server, log_file):
|
||||
if dots:
|
||||
print(end=".", flush=True)
|
||||
time.sleep(0.4)
|
||||
if time.time() - t > MAX_SERVER_WAIT:
|
||||
raise Exception("Timeout waiting for server")
|
||||
print("\n\n--- SERVER IS UP! ---\n", flush=True)
|
||||
|
||||
# DO OUR ACTUAL TESTING HERE!!!
|
||||
yield
|
||||
|
||||
finally:
|
||||
assert_server_running(server, log_file)
|
||||
server.terminate()
|
||||
server.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# The code below is for testing this module works
|
||||
with test_server_running():
|
||||
print("\n\n SERVER IS UP!\n\n")
|
||||
Reference in New Issue
Block a user