Skip to content

Commit

Permalink
Merge pull request #104 from darrenburns/watch-env
Browse files Browse the repository at this point in the history
Watch dotenvs and automatically refreshing
  • Loading branch information
darrenburns authored Sep 8, 2024
2 parents 2642a4e + 95ed49f commit 947dbfb
Show file tree
Hide file tree
Showing 17 changed files with 161 additions and 31 deletions.
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

0 comments on commit 947dbfb

Please sign in to comment.