Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Watch dotenvs and automatically refreshing #104

Merged
merged 9 commits into from
Sep 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .coverage
Binary file not shown.
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
## 1.13.0 [8th September 2024]

### Added

- New `collection_browser.show_on_startup` config to control whether the collection browser is shown on startup.
- Watch for changes to loaded dotenv files and reload UI elements that depend on them when they change.

### Changed

- Upgraded all dependencies
- Remove `pydantic-settings` crash workaround on empty config files.
- Renaming `App.maximized` as it now clashes with a Textual concept.
- Removed "using default collection" message from startup.

### Fixed

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ The table below lists all available configuration options and their environment
| `theme_directory` (`POSTING_THEME_DIRECTORY`) | (Default: `${XDG_DATA_HOME}/posting/themes`) | The directory containing user themes. |
| `layout` (`POSTING_LAYOUT`) | `"vertical"`, `"horizontal"` (Default: `"horizontal"`) | Sets the layout of the application. |
| `use_host_environment` (`POSTING_USE_HOST_ENVIRONMENT`) | `true`, `false` (Default: `false`) | Allow/deny using environment variables from the host machine in requests via `$env:` syntax. When disabled, only variables defined explicitly in `.env` files will be available for use. |
| `watch_env_files` (`POSTING_WATCH_ENV_FILES`) | `true`, `false` (Default: `true`) | If enabled, automatically reload environment files when they change. |
| `animation` (`POSTING_ANIMATION`) | `"none"`, `"basic"`, `"full"` (Default: `"none"`) | Controls the animation level. |
| `response.prettify_json` (`POSTING_RESPONSE__PRETTIFY_JSON`) | `true`, `false` (Default: `true`) | If enabled, JSON responses will be pretty-formatted. |
| `response.show_size_and_time` (`POSTING_RESPONSE__SHOW_SIZE_AND_TIME`) | `true`, `false` (Default: `true`) | If enabled, the size and time taken for the response will be displayed in the response area border subtitle. |
Expand All @@ -118,6 +119,7 @@ The table below lists all available configuration options and their environment
| `heading.show_version` (`POSTING_HEADING__SHOW_VERSION`) | `true`, `false` (Default: `true`) | Show/hide the version in the app header. |
| `url_bar.show_value_preview` (`POSTING_URL_BAR__SHOW_VALUE_PREVIEW`) | `true`, `false` (Default: `true`) | Show/hide the variable value preview below the URL bar. |
| `collection_browser.position` (`POSTING_COLLECTION_BROWSER__POSITION`) | `"left"`, `"right"` (Default: `"left"`) | The position of the collection browser on screen. |
| `collection_browser.show_on_startup` (`POSTING_COLLECTION_BROWSER__SHOW_ON_STARTUP`) | `true`, `false` (Default: `true`) | Show/hide the collection browser on startup. Can always be toggled using the command palette. |
| `pager` (`POSTING_PAGER`) | (Default: `$PAGER`) | Command to use for paging text. |
| `pager_json` (`POSTING_PAGER_JSON`) | (Default: `$PAGER`) | Command to use for paging JSON. |
| `editor` (`POSTING_EDITOR`) | (Default: `$EDITOR`) | Command to use for opening files in an external editor. |
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ This introduction will show you how to create a simple POST request to the [JSON

A *collection* is simply a directory which may contain requests saved by Posting.

If you launch Posting without specifying a collection, any requests you create will be saved in the "default" collection.
If you launch Posting without specifying a collection, any requests you create will be saved in the `"default"` collection.
This is a directory reserved by Posting on your filesystem, and unrelated to the directory you launched Posting from.

This is fine for quick throwaway requests, but you'll probably want to create a new collection for each project you work on so that you can check it into version control.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
"python-dotenv==1.0.1",
"textual[syntax]==0.79.1",
"textual-autocomplete==3.0.0a9",
"watchfiles>=0.24.0",
]
readme = "README.md"
requires-python = ">= 3.11"
Expand Down
3 changes: 3 additions & 0 deletions src/posting/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
from pathlib import Path
import click

Expand All @@ -13,6 +14,7 @@
default_collection_directory,
theme_directory,
)
from posting.variables import load_variables


def create_config_file() -> None:
Expand Down Expand Up @@ -132,5 +134,6 @@ def make_posting(

env_paths = tuple(Path(e).resolve() for e in env)
settings = Settings(_env_file=env_paths) # type: ignore[call-arg]
asyncio.run(load_variables(env_paths, settings.use_host_environment))

return Posting(settings, env_paths, collection_tree, not using_default_collection)
33 changes: 24 additions & 9 deletions src/posting/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)
from textual.widgets._tabbed_content import ContentTab
from textual.widgets.text_area import TextAreaTheme
from watchfiles import awatch
from posting.collection import (
Collection,
Cookie,
Expand Down Expand Up @@ -145,7 +146,6 @@ def __init__(
self._initial_layout: PostingLayout = layout
self.environment_files = environment_files
self.settings = SETTINGS.get()
load_variables(self.environment_files, self.settings.use_host_environment)

def on_mount(self) -> None:
self.layout = self._initial_layout
Expand All @@ -168,7 +168,11 @@ def compose(self) -> ComposeResult:
yield AppHeader()
yield UrlBar()
with AppBody():
yield CollectionBrowser(collection=self.collection)
collection_browser = CollectionBrowser(collection=self.collection)
collection_browser.display = (
self.settings.collection_browser.show_on_startup
)
yield collection_browser
yield RequestEditor()
yield ResponseArea()
yield Footer(show_command_palette=False)
Expand Down Expand Up @@ -628,10 +632,26 @@ def __init__(
self.collection = collection
self.collection_specified = collection_specified
self.animation_level = settings.animation
self.env_changed_signal = Signal[None](self, "env-changed")

theme: Reactive[str] = reactive("galaxy", init=False)
_jumping: Reactive[bool] = reactive(False, init=False, bindings=True)

@work(exclusive=True, group="environment-watcher")
async def watch_environment_files(self) -> None:
async for changes in awatch(*self.environment_files):
await load_variables(
self.environment_files,
self.settings.use_host_environment,
avoid_cache=True,
)
self.env_changed_signal.publish(None)
self.notify(
title="Environment changed",
message=f"Reloaded {len(changes)} dotenv files",
timeout=3,
)

def on_mount(self) -> None:
self.jumper = Jumper(
{
Expand All @@ -653,20 +673,15 @@ def on_mount(self) -> None:
)
self.theme_change_signal = Signal[Theme](self, "theme-changed")
self.theme = self.settings.theme
if self.settings.watch_env_files:
self.watch_environment_files()

def get_default_screen(self) -> MainScreen:
self.main_screen = MainScreen(
collection=self.collection,
layout=self.settings.layout,
environment_files=self.environment_files,
)
if not self.collection_specified:
self.notify(
"Using the default collection directory.",
title="No collection specified",
severity="warning",
timeout=7,
)
return self.main_screen

def get_css_variables(self) -> dict[str, str]:
Expand Down
12 changes: 6 additions & 6 deletions src/posting/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,28 +45,28 @@ def commands(

# Change the available commands depending on what is currently
# maximized on the main screen.
maximized = screen.maximized
reset_command = (
"view: reset",
partial(screen.maximize_section, None),
partial(screen.expand_section, None),
"Reset section sizes to default",
True,
)
expand_request_command = (
"view: expand request",
partial(screen.maximize_section, "request"),
partial(screen.expand_section, "request"),
"Expand the request section",
True,
)
expand_response_command = (
"view: expand response",
partial(screen.maximize_section, "response"),
partial(screen.expand_section, "response"),
"Expand the response section",
True,
)
if maximized == "request":
expanded_section = screen.expanded_section
if expanded_section == "request":
commands_to_show.extend([reset_command, expand_response_command])
elif maximized == "response":
elif expanded_section == "response":
commands_to_show.extend([reset_command, expand_request_command])
else:
commands_to_show.extend(
Expand Down
6 changes: 6 additions & 0 deletions src/posting/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ class CollectionBrowserSettings(BaseModel):
position: Literal["left", "right"] = Field(default="left")
"""The position of the collection browser on screen."""

show_on_startup: bool = Field(default=True)
"""If enabled, the collection browser will be shown on startup."""


class Settings(BaseSettings):
model_config = SettingsConfigDict(
Expand Down Expand Up @@ -122,6 +125,9 @@ class Settings(BaseSettings):
using the `${VARIABLE_NAME}` syntax. When disabled, you are restricted to variables
defined in any `.env` files explicitly supplied via the `--env` option."""

watch_env_files: bool = Field(default=True)
"""If enabled, automatically reload environment files when they change."""

text_input: TextInputSettings = Field(default_factory=TextInputSettings)
"""General configuration for inputs and text area widgets."""

Expand Down
33 changes: 24 additions & 9 deletions src/posting/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,45 @@
import os
from pathlib import Path
from dotenv import dotenv_values
from asyncio import Lock


_VARIABLES_PATTERN = re.compile(
r"\$(?:([a-zA-Z_][a-zA-Z0-9_]*)|{([a-zA-Z_][a-zA-Z0-9_]*)})"
)

_initial_variables: dict[str, str | None] = {}
VARIABLES: ContextVar[dict[str, str | None]] = ContextVar(
"variables", default=_initial_variables
)

class SharedVariables:
def __init__(self):
self._variables: dict[str, str | None] = {}
self._lock = Lock()

def get(self) -> dict[str, str | None]:
return self._variables.copy()

async def set(self, variables: dict[str, str | None]) -> None:
async with self._lock:
self._variables = variables


VARIABLES = SharedVariables()


def get_variables() -> dict[str, str | None]:
return VARIABLES.get()


def load_variables(
environment_files: tuple[Path, ...], use_host_environment: bool
async def load_variables(
environment_files: tuple[Path, ...],
use_host_environment: bool,
avoid_cache: bool = False,
) -> dict[str, str | None]:
"""Load the variables that are currently available in the environment.

This will make them available via the `get_variables` function."""

existing_variables = VARIABLES.get()
if existing_variables:
existing_variables = get_variables()
if existing_variables and not avoid_cache:
return {key: value for key, value in existing_variables}

variables: dict[str, str | None] = {
Expand All @@ -42,7 +56,7 @@ def load_variables(
host_env_variables = {key: value for key, value in os.environ.items()}
variables = {**variables, **host_env_variables}

VARIABLES.set(variables)
await VARIABLES.set(variables)
return variables


Expand Down Expand Up @@ -162,6 +176,7 @@ def get_variable_at_cursor(cursor: int, text: str) -> str | None:
return text[start:end]


@lru_cache()
def extract_variable_name(variable_text: str) -> str:
"""
Extract the variable name from a variable reference.
Expand Down
26 changes: 23 additions & 3 deletions src/posting/widgets/request/url_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ def __init__(
self.cached_base_urls: list[str] = []
self._trace_events: set[Event] = set()

def on_env_changed(self, _: None) -> None:
self._display_variable_at_cursor()
self.url_input.refresh()

def compose(self) -> ComposeResult:
with Horizontal():
yield MethodSelector(id="method-selector")
Expand All @@ -161,6 +165,7 @@ def compose(self) -> ComposeResult:
)
yield Label(id="trace-markers")
yield SendRequestButton("Send")

variable_value_bar = Label(id="variable-value-bar")
if SETTINGS.get().url_bar.show_value_preview:
yield variable_value_bar
Expand All @@ -175,19 +180,34 @@ def on_mount(self) -> None:

self.on_theme_change(self.app.themes[self.app.theme])
self.app.theme_change_signal.subscribe(self, self.on_theme_change)
self.app.env_changed_signal.subscribe(self, self.on_env_changed)

@on(Input.Changed)
def on_change(self, event: Input.Changed) -> None:
self.variable_value_bar.update("")
try:
self.variable_value_bar.update("")
except NoMatches:
return

@on(UrlInput.Blurred)
def on_blur(self, event: UrlInput.Blurred) -> None:
self.variable_value_bar.update("")
try:
self.variable_value_bar.update("")
except NoMatches:
return

@on(UrlInput.CursorMoved)
def on_cursor_moved(self, event: UrlInput.CursorMoved) -> None:
self._display_variable_at_cursor()

def _display_variable_at_cursor(self) -> None:
url_input = self.url_input

cursor_position = url_input.cursor_position
value = url_input.value
variable_at_cursor = get_variable_at_cursor(cursor_position, value)

variables = get_variables()
variable_at_cursor = get_variable_at_cursor(event.cursor_position, event.value)
try:
variable_bar = self.variable_value_bar
except NoMatches:
Expand Down
10 changes: 8 additions & 2 deletions src/posting/widgets/variable_input.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from textual import on
from textual.widgets import Input
from textual_autocomplete import DropdownItem, TargetState
from posting.help_screen import HelpData
from posting.highlighters import VariableHighlighter
from posting.themes import Theme
from posting.variables import get_variables
from posting.widgets.input import PostingInput

from posting.widgets.variable_autocomplete import VariableAutoComplete
Expand All @@ -22,12 +26,11 @@ def on_mount(self) -> None:
self.highlighter = VariableHighlighter()
self.auto_complete = VariableAutoComplete(
candidates=[],
variable_candidates=self._get_variable_candidates,
target=self,
)
self.screen.mount(self.auto_complete)

# Trigger the callback to set the initial highlighter

def on_theme_change(self, theme: Theme) -> None:
"""Callback which fires when the app-level theme changes in order
to update the color scheme of the variable highlighter.
Expand All @@ -39,3 +42,6 @@ def on_theme_change(self, theme: Theme) -> None:
if theme.variable:
self.highlighter.variable_styles = theme.variable.fill_with_defaults(theme)
self.refresh()

def _get_variable_candidates(self, target_state: TargetState) -> list[DropdownItem]:
return [DropdownItem(main=f"${variable}") for variable in get_variables()]
2 changes: 2 additions & 0 deletions tests/sample-configs/custom_theme.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ heading:
show_version: false
text_input:
blinking_cursor: false
watch_env_files: false

1 change: 1 addition & 0 deletions tests/sample-configs/custom_theme2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ heading:
show_version: false
text_input:
blinking_cursor: false
watch_env_files: false
1 change: 1 addition & 0 deletions tests/sample-configs/general.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ heading:
show_version: false
text_input:
blinking_cursor: false
watch_env_files: false
3 changes: 2 additions & 1 deletion tests/sample-configs/modified_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ focus:
heading:
visible: false
text_input:
blinking_cursor: false
blinking_cursor: false
watch_env_files: false
Loading
Loading