Skip to content

Commit aa3cfc8

Browse files
authored
Merge pull request #222 from darrenburns/startup-time
Startup time improvements
2 parents 350f480 + e19fa28 commit aa3cfc8

26 files changed

+209
-161
lines changed

.coverage

-52 KB
Binary file not shown.

docs/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## 2.5.3 [13th March 2025]
2+
3+
### Changed
4+
5+
- Lazily load content of tabs which are hidden on startup (100ms saved at startup).
6+
- Only import openapi-pydantic when importing OpenAPI specs via `posting import` (63ms saved at startup).
7+
- Pin httpx and patch out httpx._main to prevent slow import (20ms saved at startup).
8+
- Defer import of watchfiles until app is running (6ms saved at startup).
9+
- Defer `HelpScreen` import until it's used (10ms saved at startup).
10+
111
## 2.5.2 [8th March 2025]
212

313
### Added

pyproject.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "posting"
3-
version = "2.5.2"
3+
version = "2.5.3"
44
description = "The modern API client that lives in your terminal."
55
authors = [
66
{ name = "Darren Burns", email = "darrenb900@gmail.com" }
@@ -9,18 +9,19 @@ dependencies = [
99
"click>=8.1.7,<9.0.0",
1010
"xdg-base-dirs>=6.0.1,<7.0.0",
1111
"click-default-group>=1.2.4,<2.0.0",
12-
"httpx[brotli]>=0.27.2,<1.0.0",
12+
"httpx[brotli]==0.28.1",
13+
# pinned httpx automatically since we monkeypatch _main.py
1314
"openapi-pydantic>=0.5.0",
1415
"pyperclip>=1.9.0,<2.0.0",
1516
"pydantic>=2.9.2,<3.0.0",
1617
"pyyaml>=6.0.2,<7.0.0",
1718
"pydantic-settings>=2.4.0,<3.0.0",
1819
"python-dotenv>=1.0.1,<2.0.0",
19-
"textual[syntax]==2.1.1",
2020
# pinned intentionally
2121
"textual-autocomplete==4.0.0a0",
2222
# pinned intentionally
2323
"watchfiles>=0.24.0",
24+
"textual[syntax]>=2.1.2,<2.2.0",
2425
]
2526
readme = "README.md"
2627
requires-python = ">= 3.11"
@@ -59,6 +60,7 @@ dev-dependencies = [
5960
"pytest-cov>=5.0.0",
6061
"pytest-textual-snapshot>=1.0.0",
6162
"mkdocs-material>=9.5.30",
63+
"pyinstrument>=5.0.1",
6264
]
6365

6466
[tool.hatch.metadata]

src/posting/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
# This import should be the first thing to run, to ensure that
2+
# the START_TIME is set as early as possible.
3+
from posting._start_time import START_TIME # type: ignore # noqa: F401
4+
5+
6+
# This is a hack to prevent httpx from importing _main.py
7+
import sys
8+
sys.modules['httpx._main'] = None
9+
110
from .collection import (
211
Auth,
312
Cookie,

src/posting/__main__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
"""The main entry point for the Posting CLI."""
12
from pathlib import Path
23
import click
34

@@ -7,7 +8,6 @@
78
from posting.app import Posting
89
from posting.collection import Collection
910
from posting.config import Settings
10-
from posting.importing.open_api import import_openapi_spec
1111
from posting.locations import (
1212
config_file,
1313
default_collection_directory,
@@ -105,6 +105,11 @@ def import_spec(spec_path: str, output: str | None) -> None:
105105
console.print(
106106
"Importing is currently an experimental feature.", style="bold yellow"
107107
)
108+
109+
# We defer this import as it takes 64ms on an M4 MacBook Pro,
110+
# and is only needed for a single CLI command - not for the main TUI.
111+
from posting.importing.open_api import import_openapi_spec
112+
108113
try:
109114
collection = import_openapi_spec(spec_path)
110115

src/posting/_start_time.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import time
2+
3+
4+
START_TIME = time.perf_counter_ns()

src/posting/app.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,8 @@
2323
from textual.signal import Signal
2424
from textual.theme import Theme, BUILTIN_THEMES as TEXTUAL_THEMES
2525
from textual.widget import Widget
26-
from textual.widgets import (
27-
Button,
28-
Footer,
29-
Input,
30-
Label,
31-
TextArea,
32-
)
26+
from textual.widgets import Button, Footer, Input, Label
3327
from textual.widgets._tabbed_content import ContentTab
34-
from watchfiles import Change, awatch
3528
from posting.collection import (
3629
Collection,
3730
Cookie,
@@ -43,7 +36,6 @@
4336

4437
from posting.commands import PostingProvider
4538
from posting.config import SETTINGS, Settings
46-
from posting.help_screen import HelpScreen
4739
from posting.jump_overlay import JumpOverlay
4840
from posting.jumper import Jumper
4941
from posting.scripts import execute_script, uncache_module, Posting as PostingContext
@@ -227,6 +219,7 @@ def compose(self) -> ComposeResult:
227219
yield collection_browser
228220
yield RequestEditor()
229221
yield ResponseArea()
222+
230223
yield Footer(show_command_palette=False)
231224

232225
def get_and_run_script(
@@ -618,8 +611,8 @@ def watch_expanded_section(
618611
request_editor.set_class(section == "response", "hidden")
619612
response_area.set_class(section == "request", "hidden")
620613

621-
@on(TextArea.Changed, selector="RequestBodyTextArea")
622-
def on_request_body_change(self, event: TextArea.Changed) -> None:
614+
@on(RequestBodyTextArea.Changed, selector="RequestBodyTextArea")
615+
def on_request_body_change(self, event: RequestBodyTextArea.Changed) -> None:
623616
"""Update the body tab to indicate if there is a body."""
624617
body_tab = self.query_one("#--content-tab-body-pane", ContentTab)
625618
if event.text_area.text:
@@ -965,9 +958,19 @@ def __init__(
965958
_jumping: Reactive[bool] = reactive(False, init=False, bindings=True)
966959
"""True if 'jump mode' is currently active, otherwise False."""
967960

961+
def on_ready(self) -> None:
962+
import time
963+
from posting._start_time import START_TIME
964+
965+
message = f"Posting started in {(time.perf_counter_ns() - START_TIME) // 1_000_000} milliseconds."
966+
log.debug(message)
967+
968968
@work(exclusive=True, group="environment-watcher")
969969
async def watch_environment_files(self) -> None:
970970
"""Watching files that were passed in as the environment."""
971+
972+
from watchfiles import awatch
973+
971974
async for changes in awatch(*self.environment_files):
972975
# Reload the variables from the environment files.
973976
load_variables(
@@ -993,6 +996,9 @@ async def watch_environment_files(self) -> None:
993996
@work(exclusive=True, group="collection-watcher")
994997
async def watch_collection_files(self) -> None:
995998
"""Watching specific files within the collection directory."""
999+
1000+
from watchfiles import awatch, Change
1001+
9961002
async for changes in awatch(self.collection.path):
9971003
for change_type, file_path in changes:
9981004
if file_path.endswith(".py"):
@@ -1021,6 +1027,9 @@ async def watch_collection_files(self) -> None:
10211027
@work(exclusive=True, group="theme-watcher")
10221028
async def watch_themes(self) -> None:
10231029
"""Watching the theme directory for changes."""
1030+
1031+
from watchfiles import awatch
1032+
10241033
async for changes in awatch(self.settings.theme_directory):
10251034
for _change_type, file_path in changes:
10261035
if file_path.endswith((".yml", ".yaml")):
@@ -1283,6 +1292,8 @@ def reset_focus(_) -> None:
12831292
self.screen.set_focus(focused)
12841293

12851294
self.set_focus(None)
1295+
from posting.help_screen import HelpScreen
1296+
12861297
await self.push_screen(HelpScreen(widget=focused), callback=reset_focus)
12871298

12881299
def exit(

src/posting/collection.py

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,34 +7,18 @@
77
import httpx
88
from pydantic import BaseModel, Field, HttpUrl
99
import rich
10-
import yaml
1110
import os
1211
from textual import log
1312
from posting.auth import HttpxBearerTokenAuth
1413
from posting.tuple_to_multidict import tuples_to_dict
1514
from posting.variables import SubstitutionError
1615
from posting.version import VERSION
17-
16+
from posting.yaml import dump, load, Loader
1817

1918
HttpRequestMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
2019
VALID_HTTP_METHODS = get_args(HttpRequestMethod)
2120

2221

23-
def str_presenter(dumper, data):
24-
if data.count("\n") > 0:
25-
data = "\n".join(
26-
[line.rstrip() for line in data.splitlines()]
27-
) # Remove any trailing spaces, then put it back together again
28-
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
29-
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
30-
31-
32-
yaml.add_representer(str, str_presenter)
33-
yaml.representer.SafeRepresenter.add_representer(
34-
str, str_presenter
35-
) # to use with safe_dump
36-
37-
3822
class Auth(BaseModel):
3923
type: Literal["basic", "digest", "bearer_token"] | None = Field(default=None)
4024
basic: BasicAuth | None = Field(default=None)
@@ -284,7 +268,7 @@ def to_httpx(self, client: httpx.AsyncClient) -> httpx.Request:
284268
def save_to_disk(self, path: Path) -> None:
285269
"""Save the request model to a YAML file."""
286270
content = self.model_dump(exclude_defaults=True, exclude_none=True)
287-
yaml_content = yaml.dump(
271+
yaml_content = dump(
288272
content,
289273
None,
290274
sort_keys=False,
@@ -552,5 +536,5 @@ def load_request_from_yaml(file_path: str) -> RequestModel:
552536
RequestModel: The request model loaded from the YAML file.
553537
"""
554538
with open(file_path, "r") as file:
555-
data = yaml.safe_load(file)
539+
data = load(file, Loader=Loader)
556540
return RequestModel(**data, path=Path(file_path))

src/posting/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from textual.types import AnimationLevel
1313

1414
from posting.locations import config_file, theme_directory
15-
1615
from posting.types import PostingLayout
1716

1817

src/posting/help_data.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from dataclasses import dataclass, field
2+
3+
4+
@dataclass
5+
class HelpData:
6+
"""Data relating to the widget to be displayed in the HelpScreen"""
7+
8+
title: str = field(default="")
9+
"""Title of the widget"""
10+
description: str = field(default="")
11+
"""Markdown description to be displayed in the HelpScreen"""

0 commit comments

Comments
 (0)