├── .coverage ├── .github ├── FUNDING.yml └── workflows │ ├── coverage.yml │ ├── docs.yml │ └── test.yml ├── .gitignore ├── .python-version ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── docs ├── CHANGELOG.md ├── CNAME ├── assets │ ├── collection-tree.png │ ├── compact-mode-and-themes.mp4 │ ├── contextual-help.png │ ├── curl-export.png │ ├── default-collection.png │ ├── environments.mp4 │ ├── home-efficiency-video.mp4 │ ├── home-image-ad-15aug24.svg │ ├── jump-mode.png │ ├── request-method-dropdown.png │ ├── scripts-tab.png │ ├── search-requests.png │ ├── url-autocomplete.gif │ └── url-bar.png ├── faq.md ├── guide │ ├── collections.md │ ├── command_palette.md │ ├── configuration.md │ ├── environments.md │ ├── external_tools.md │ ├── help_system.md │ ├── importing.md │ ├── index.md │ ├── keymap.md │ ├── navigation.md │ ├── requests.md │ ├── scripting.md │ └── themes.md ├── home-image.afdesign ├── index.md ├── overrides │ └── home.html ├── roadmap.md └── stylesheets │ └── extra.css ├── mkdocs.yml ├── pyproject.toml ├── src └── posting │ ├── __init__.py │ ├── __main__.py │ ├── _start_time.py │ ├── app.py │ ├── auth.py │ ├── collection.py │ ├── commands.py │ ├── config.py │ ├── exit_codes.py │ ├── files.py │ ├── help_data.py │ ├── help_screen.py │ ├── highlight_url.py │ ├── highlighters.py │ ├── importing │ ├── curl.py │ ├── open_api.py │ └── postman.py │ ├── jump_overlay.py │ ├── jumper.py │ ├── locations.py │ ├── messages.py │ ├── posting.scss │ ├── request_headers.py │ ├── save_request.py │ ├── scripts.py │ ├── suggesters.py │ ├── themes.py │ ├── tuple_to_multidict.py │ ├── types.py │ ├── urls.py │ ├── user_host.py │ ├── variables.py │ ├── version.py │ ├── widgets │ ├── __init__.py │ ├── center_middle.py │ ├── collection │ │ ├── browser.py │ │ └── new_request_modal.py │ ├── confirmation.py │ ├── datatable.py │ ├── input.py │ ├── key_value.py │ ├── request │ │ ├── __init__.py │ │ ├── form_editor.py │ │ ├── header_editor.py │ │ ├── method_selection.py │ │ ├── query_editor.py │ │ ├── request_auth.py │ │ ├── request_body.py │ │ ├── request_editor.py │ │ ├── request_metadata.py │ │ ├── request_options.py │ │ ├── request_scripts.py │ │ └── url_bar.py │ ├── response │ │ ├── cookies_table.py │ │ ├── response_area.py │ │ ├── response_body.py │ │ ├── response_headers.py │ │ ├── response_trace.py │ │ └── script_output.py │ ├── rich_log.py │ ├── select.py │ ├── tabbed_content.py │ ├── text_area.py │ ├── tree.py │ ├── variable_autocomplete.py │ └── variable_input.py │ ├── xresources.py │ └── yaml.py ├── tests ├── __snapshots__ │ └── test_snapshots │ │ ├── TestCommandPalette.test_can_run_command__hide_collection_browser.svg │ │ ├── TestCommandPalette.test_can_type_to_filter_options.svg │ │ ├── TestCommandPalette.test_loads_and_shows_discovery_options[compact].svg │ │ ├── TestCommandPalette.test_loads_and_shows_discovery_options[standard].svg │ │ ├── TestConfig.test_config.svg │ │ ├── TestCurlExport.test_curl_export.svg │ │ ├── TestCurlExport.test_curl_export_no_setup.svg │ │ ├── TestCustomThemeComplex.test_highlighting_applied_from_custom_theme__json.svg │ │ ├── TestCustomThemeComplex.test_highlighting_applied_from_custom_theme__url.svg │ │ ├── TestCustomThemeSimple.test_theme_sensible_defaults__json.svg │ │ ├── TestCustomThemeSimple.test_theme_sensible_defaults__url.svg │ │ ├── TestCustomThemeSimple.test_theme_set_on_startup_and_in_command_palette.svg │ │ ├── TestDisableRowInTable.test_disable_row_in_table.svg │ │ ├── TestEditKeyValues.test_edit_mode_can_edit_header_keys_and_values_as_expected.svg │ │ ├── TestEditKeyValues.test_edit_mode_displays_correctly.svg │ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[body].svg │ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[headers].svg │ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[method].svg │ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[query].svg │ │ ├── TestFocusAutoSwitchingConfig.test_focus_on_request_open__open_body[url].svg │ │ ├── TestHeaderAutoCompletion.test_header_name_auto_completion_list_appears.svg │ │ ├── TestHeaderAutoCompletion.test_header_name_auto_completion_list_appears_followed_by_keypress[enter].svg │ │ ├── TestHeaderAutoCompletion.test_header_name_auto_completion_list_appears_followed_by_keypress[escape].svg │ │ ├── TestHeaderAutoCompletion.test_header_name_auto_completion_list_appears_followed_by_keypress[tab].svg │ │ ├── TestHeaderAutoCompletion.test_header_value_auto_completion_list_accepts_selection.svg │ │ ├── TestHeaderAutoCompletion.test_header_value_auto_completion_list_appears.svg │ │ ├── TestHelpScreen.test_help_screen_appears.svg │ │ ├── TestJumpMode.test_click_switch.svg │ │ ├── TestJumpMode.test_focus_switch.svg │ │ ├── TestJumpMode.test_loads[compact].svg │ │ ├── TestJumpMode.test_loads[standard].svg │ │ ├── TestLoadingRequest.test_request_loaded_into_view__auth.svg │ │ ├── TestLoadingRequest.test_request_loaded_into_view__body.svg │ │ ├── TestLoadingRequest.test_request_loaded_into_view__headers.svg │ │ ├── TestLoadingRequest.test_request_loaded_into_view__options.svg │ │ ├── TestLoadingRequest.test_request_loaded_into_view__query_params.svg │ │ ├── TestMethodSelection.test_select_post_method.svg │ │ ├── TestNewRequest.test_cannot_create_request_invalid_filename.svg │ │ ├── TestNewRequest.test_cannot_create_request_without_name.svg │ │ ├── TestNewRequest.test_cannot_supply_invalid_path_in_collection.svg │ │ ├── TestNewRequest.test_dialog_loads_and_can_be_used.svg │ │ ├── TestNewRequest.test_new_request_added_to_tree_correctly_and_notification_shown.svg │ │ ├── TestSave.test_no_request_selected__dialog_is_prefilled_correctly.svg │ │ ├── TestScripts.test_script_runs.svg │ │ ├── TestSendRequest.test_send_request[compact].svg │ │ ├── TestSendRequest.test_send_request[standard].svg │ │ ├── TestUrlBar.test_dropdown_appears_on_typing.svg │ │ ├── TestUrlBar.test_dropdown_completion_selected_via_enter_key.svg │ │ ├── TestUrlBar.test_dropdown_completion_selected_via_tab_key.svg │ │ ├── TestUrlBar.test_dropdown_filters_on_typing.svg │ │ ├── TestUserInterfaceShortcuts.test_expand_request_section.svg │ │ ├── TestUserInterfaceShortcuts.test_expand_then_reset.svg │ │ ├── TestUserInterfaceShortcuts.test_hide_collection_browser.svg │ │ ├── TestVariables.test_resolved_variables_highlight_and_preview[compact].svg │ │ ├── TestVariables.test_resolved_variables_highlight_and_preview[standard].svg │ │ └── TestVariables.test_unresolved_variables_highlighted.svg ├── posting_snapshot_app.py ├── resources │ └── snapshot_report_template.jinja2 ├── sample-collections │ ├── echo-post-01.posting.yaml │ ├── echo.posting.yaml │ ├── get-random-user.posting.yaml │ ├── jsonplaceholder │ │ ├── posts │ │ │ ├── comments │ │ │ │ ├── edit.posting.yaml │ │ │ │ ├── get-comments-query.posting.yaml │ │ │ │ └── get-comments.posting.yaml │ │ │ ├── create.posting.yaml │ │ │ ├── delete.posting.yaml │ │ │ ├── get-all.posting.yaml │ │ │ └── get-one.posting.yaml │ │ ├── todos │ │ │ ├── get-all.posting.yaml │ │ │ └── get-one.posting.yaml │ │ └── users │ │ │ ├── create.posting.yaml │ │ │ ├── delete.posting.yaml │ │ │ ├── get-all.posting.yaml │ │ │ ├── get-one.posting.yaml │ │ │ └── update.posting.yaml │ └── scripts │ │ └── my_script.py ├── sample-configs │ ├── custom_theme.yaml │ ├── custom_theme2.yaml │ ├── general.yaml │ └── modified_config.yaml ├── sample-envs │ ├── sample_base.env │ └── sample_extra.env ├── sample-importable-collections │ ├── Fixer.postman_collection.json │ ├── postman_collection.json │ └── test-postman-collection.json ├── sample-themes │ ├── another_test.yml │ └── serene_ocean.yaml ├── test_curl_export.py ├── test_curl_import.py ├── test_files.py ├── test_open_api_import.py ├── test_postman_import.py ├── test_snapshots.py ├── test_urls.py └── test_variables.py └── uv.lock /.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/.coverage -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [darrenburns] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | # This is a follow up job that runs after the CI job has completed. 2 | # It'll post a code coverage comment on pull requests. 3 | name: Post Coverage Comment 4 | 5 | on: 6 | workflow_run: 7 | workflows: ["Continuous Integration"] 8 | types: 9 | - completed 10 | 11 | jobs: 12 | test: 13 | name: Post Coverage Comment 14 | runs-on: ubuntu-latest 15 | if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' 16 | permissions: 17 | pull-requests: write 18 | contents: write 19 | actions: read 20 | steps: 21 | # !!! DO NOT run actions/checkout here, for security reasons !!! 22 | # For details, refer to https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 23 | - name: Post comment 24 | uses: py-cov-action/python-coverage-comment-action@v3 25 | with: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Configure Git Credentials 14 | run: | 15 | git config user.name github-actions[bot] 16 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: 3.x 20 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 21 | - uses: actions/cache@v4 22 | with: 23 | key: mkdocs-material-${{ env.cache_id }} 24 | path: .cache 25 | restore-keys: | 26 | mkdocs-material- 27 | - run: pip install mkdocs-material 28 | - run: mkdocs gh-deploy --force -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "main" 8 | 9 | env: 10 | PYTEST_ADDOPTS: "--color=yes" 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | pull-requests: write 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Install a specific version of uv 21 | uses: astral-sh/setup-uv@v5 22 | with: 23 | version: "0.6.3" 24 | enable-cache: true 25 | - run: uv python install # Will read from .python-version 26 | - name: Install Dependencies 27 | run: uv sync --all-extras --dev 28 | - name: Run Tests 29 | run: | 30 | uv run make test-ci 31 | - name: Attach Code Coverage 32 | uses: py-cov-action/python-coverage-comment-action@v3.25 33 | with: 34 | GITHUB_TOKEN: ${{ github.token }} 35 | 36 | - name: Save Snapshot Report 37 | if: always() 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: snapshot-report-posting 41 | path: snapshot_report.html 42 | 43 | - name: Store Pull Request Comment 44 | uses: actions/upload-artifact@v4 45 | if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true' 46 | with: 47 | # If you use a different name, update COMMENT_ARTIFACT_NAME accordingly 48 | name: python-coverage-comment-action 49 | # If you use a different name, update COMMENT_FILENAME accordingly 50 | path: python-coverage-comment-action.txt 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # venv 10 | .venv 11 | 12 | # editor 13 | .vscode/ 14 | .idea/ 15 | pyrightconfig.json 16 | 17 | snapshot_report.html 18 | *.afdesign 19 | *.afdesign* 20 | *.afphoto 21 | *.afphoto* 22 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.7 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Posting 2 | 3 | Posting is open to contributions! 🚀 4 | 5 | Contributions of any type, no matter the "size" and form, are welcome! 6 | Bug reports, typo fixes, ideas, questions, and code are all valuable ways of contributing. 7 | If you're new to open source and would like a bit of extra guidance, don't be afraid to ask - we all start somewhere 😌 8 | 9 | ## How do I contribute? 10 | 11 | You can suggest ideas by creating a discussion, or report bugs by creating an issue. 12 | 13 | If you wish to contribute code, and it's a change which is not an "objective improvement", please open a discussion first. 14 | An "objective improvement" is a change which _indisputably_ improves Posting. 15 | Some examples include adding test coverage, performance improvements, bug fixes, and fixing clear inconsistencies. 16 | 17 | By opening a discussion, we can make sure: 18 | 19 | - You're not working on something that someone else has already started. 20 | - The feature is within Posting's scope. 21 | - We can iron out the details before you go and commit a bunch of time to it! 22 | - Maintainers can give you tips, and the discussion will be a place you can ask for help and guidance. 23 | 24 | ## Development 25 | 26 | We use [uv](https://docs.astral.sh/uv/getting-started/installation/) to manage the development dependencies. 27 | 28 | To setup an environment for development, run: 29 | 30 | ```bash 31 | uv sync 32 | ``` 33 | 34 | This will create a virtual environment with the dependencies installed. 35 | Activate the virutal environment with: 36 | 37 | ```bash 38 | source .venv/bin/activate 39 | # or, with fish shell: 40 | source .venv/bin/activate.fish 41 | ``` 42 | 43 | Then you can run Posting with: 44 | 45 | ```bash 46 | posting 47 | ``` 48 | 49 | If you wish to connect to the Textual dev tools (strongly recommended), you can run `posting` like this: 50 | 51 | ```bash 52 | TEXTUAL=devtools,debug posting 53 | ``` 54 | 55 | The repo includes a test collection which is used in the automated tests, but you can also load it in for manual testing. 56 | 57 | ```bash 58 | TEXTUAL=devtools,debug posting --collection tests/sample-collections/ --env tests/sample-envs/sample_base.env --env tests/sample-envs/sample_extra.env 59 | ``` 60 | 61 | ### Running the tests 62 | 63 | To run the tests, you MUST use the `Makefile` commands. 64 | 65 | ```bash 66 | make test 67 | ``` 68 | 69 | This will run the tests in parallel, but isolate a few tests which need to be run serially. 70 | If you try to run the tests using a raw `pytest` command, you're gonna have a bad time. 71 | 72 | ### Snapshot testing 73 | 74 | Snapshot testing is the primary way we test the UI of Posting. 75 | 76 | It works by taking a "screenshot" of the running app at (actually an SVG!) the end of the test, and comparing it to the previous screenshot for that test. 77 | If it matches, the test passes. If it doesn't, the test fails and you'll be able to view a report which shows the differences. 78 | 79 | It's your job to look at this diff and consider whether the new output (on the left) is correct or not. 80 | 81 | If the results are all as expected, you can update the snapshots by running: 82 | 83 | ```bash 84 | make test-snapshot-update 85 | ``` 86 | 87 | This will update the snapshots saved on disk for all the tests which failed. 88 | You should commit these changes into the repo - they're essentially the "source of truth" for what the UI of Posting should look like under different circumstances. 89 | 90 | ### Update the changelog 91 | 92 | A changelog is maintained in the `docs/CHANGELOG.md` file, which follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. 93 | 94 | When you're making a change which should be recorded in the changelog. 95 | You should add your change to the `## Unreleased` section of the changelog. 96 | If the `## Unreleased` section doesn't exist, you should add it at the top. 97 | 98 | ## Feeling unsure? 99 | 100 | If you're feeling a bit stuck, just open a discussion and I'll do my best to help you out! 101 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: test 3 | test: 4 | $(run) pytest --cov=posting tests/ -n 24 -m "not serial" $(ARGS) 5 | $(run) pytest --cov-report term-missing --cov-append --cov=posting tests/ -m serial $(ARGS) 6 | 7 | .PHONY: test-snapshot-update 8 | test-snapshot-update: 9 | $(run) pytest --cov=posting tests/ -n 24 -m "not serial" --snapshot-update $(ARGS) 10 | $(run) pytest --cov-report term-missing --cov-append --cov=posting tests/ -m serial --snapshot-update $(ARGS) 11 | 12 | 13 | .PHONY: test-ci 14 | test-ci: 15 | $(run) pytest --cov=posting tests/ --cov-report term-missing $(ARGS) 16 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | This product includes software developed by [Encode OSS Ltd.](https://github.com/encode), under the BSD-3-Clause License: 2 | 3 | BSD 3-Clause License 4 | 5 | Copyright © 2019, Encode OSS Ltd. All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 10 | 11 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 12 | 13 | Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Posting 2 | 3 | **A powerful HTTP client that lives in your terminal.** 4 | 5 | Posting is an HTTP client, not unlike Postman and Insomnia. As a TUI application, it can be used over SSH and enables efficient keyboard-centric workflows. Your requests are stored locally in simple YAML files, so they're easy to read and version control. 6 | 7 | image 8 | 9 | Some notable features include: 10 | 11 | - "jump mode" navigation 12 | - environments/variables 13 | - autocompletion 14 | - syntax highlighting using tree-sitter 15 | - Vim keys 16 | - customizable keybindings 17 | - user-defined themes 18 | - run Python code before and after requests 19 | - extensive configuration 20 | - open in $EDITOR/$PAGER 21 | - import curl commands by pasting them into the URL bar 22 | - export requests as cURL commands 23 | - import from Postman and OpenAPI specs 24 | - a command palette for quickly accessing functionality 25 | 26 | Visit the [website](https://posting.sh) for more information, the roadmap, and the user guide. 27 | 28 | ## Installation 29 | 30 | Posting can be installed via [uv](https://docs.astral.sh/uv/getting-started/installation/) on MacOS, Linux, and Windows. 31 | 32 | ```bash 33 | # quickly install uv on MacOS/Linux 34 | curl -LsSf https://astral.sh/uv/install.sh | sh 35 | 36 | # install Posting (will also quickly install Python 3.13 if needed) 37 | uv tool install --python 3.13 posting 38 | ``` 39 | 40 | Now you can run Posting via the command line: 41 | 42 | ```bash 43 | posting 44 | ``` 45 | 46 | Homebrew and NixOS are not officially supported at the moment. 47 | 48 | ### Prefer `pipx`? 49 | 50 | If you'd prefer to use `pipx`, that works too: `pipx install posting`. 51 | 52 | ## Learn More 53 | 54 | Learn more about Posting at [https://posting.sh](https://posting.sh). 55 | 56 | Posting was built with [Textual](https://github.com/textualize/textual). 57 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | POSTING.SH -------------------------------------------------------------------------------- /docs/assets/collection-tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/collection-tree.png -------------------------------------------------------------------------------- /docs/assets/compact-mode-and-themes.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/compact-mode-and-themes.mp4 -------------------------------------------------------------------------------- /docs/assets/contextual-help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/contextual-help.png -------------------------------------------------------------------------------- /docs/assets/curl-export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/curl-export.png -------------------------------------------------------------------------------- /docs/assets/default-collection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/default-collection.png -------------------------------------------------------------------------------- /docs/assets/environments.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/environments.mp4 -------------------------------------------------------------------------------- /docs/assets/home-efficiency-video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/home-efficiency-video.mp4 -------------------------------------------------------------------------------- /docs/assets/jump-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/jump-mode.png -------------------------------------------------------------------------------- /docs/assets/request-method-dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/request-method-dropdown.png -------------------------------------------------------------------------------- /docs/assets/scripts-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/scripts-tab.png -------------------------------------------------------------------------------- /docs/assets/search-requests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/search-requests.png -------------------------------------------------------------------------------- /docs/assets/url-autocomplete.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/url-autocomplete.gif -------------------------------------------------------------------------------- /docs/assets/url-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/assets/url-bar.png -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## Using Posting 4 | 5 | ### How do I edit headers or query parameters? 6 | 7 | Right now, you need to delete the row and re-create it with the correct values. Inline editing is planned, but not yet implemented. 8 | 9 | ## Contributing 10 | 11 | ### How do I suggest a feature? 12 | 13 | You can suggest a feature by opening a Discussion on the [GitHub repository](https://github.com/darrenburns/posting/discussions) under the "Ideas" category. 14 | 15 | ### How do I report a bug? 16 | 17 | You can report a bug by opening an Issue on the [GitHub repository](https://github.com/darrenburns/posting/issues). 18 | 19 | ### How do I contribute code to Posting? 20 | 21 | You can contribute code to Posting by opening a Pull Request on the [GitHub repository](https://github.com/darrenburns/posting/pulls). 22 | 23 | However, reporting bugs and suggesting features is also a great way to contribute! 24 | 25 | A guide to contributing is coming soon. 26 | 27 | ## General 28 | 29 | ### How was Posting built? 30 | 31 | Posting is built using [Textual](https://textual.textualize.io/), a Python framework for building terminal-based applications. 32 | 33 | ### Who is the original creator of Posting? 34 | 35 | Posting was originally created by [Darren Burns](https://github.com/darrenburns), an open-source developer from Scotland, UK. -------------------------------------------------------------------------------- /docs/guide/collections.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | A *collection* is just a directory on your file system which may or may not contain requests in the `.posting.yaml` format. 4 | 5 | There's absolutely nothing special about a collection. 6 | It contains no "special files" or metadata -- it's just a directory. 7 | It could even be empty. 8 | "Collection" is simply the name we give to the directory which we've loaded into Posting. 9 | 10 | ## The collection browser 11 | 12 | Posting displays the currently open collection in the sidebar. 13 | This is called the *collection browser*. 14 | 15 | ![Posting collection tree](../assets/collection-tree.png){ height=300px } 16 | 17 | The name of the currently open collection is displayed in the bottom right corner of the collection browser. 18 | In the example above, the collection is named "sample-collection". 19 | 20 | You can navigate this sidebar using the keyboard or mouse. 21 | Open a request by clicking on it or pressing ++enter++ while it has focus, 22 | and it'll be loaded into the main body of the UI. 23 | A marker will also appear to the left of the request's title, indicating that the request is open. 24 | A save operation will overwrite the currently open request. 25 | 26 | !!! example "Keyboard shortcuts" 27 | 28 | The collection browser supports various keyboard shortcuts for quick navigation. For example ++shift+j++ and ++shift+k++ can be used to jump through sub-collections. 29 | Press ++f1++ while the browser has focus to view the full list of shortcuts. 30 | 31 | 32 | The collection browser can be moved to the left or right side of the screen by setting the `collection_browser.position` configuration option 33 | to either `"left"` or `"right"`. 34 | 35 | ## The default collection 36 | 37 | If you launch Posting without a `--collection` argument, it will load the *default collection*, which is stored in Posting's reserved data directory on your file system. 38 | 39 | The default collection can be thought of as a *system wide collection*. 40 | It's a place to keep useful requests that you can easily access from anywhere, without having to manually specify a `--collection` argument. 41 | 42 | You can check where this is by running `posting locate collection`. 43 | The default collection is named "default", that name will be displayed in the bottom right corner of the collection browser. 44 | 45 | ![Posting default collection](../assets/default-collection.png) 46 | 47 | This is useful to get started quickly, but you'll probably want to create your own collection directory and load it instead. 48 | This makes it easier to organize your requests and check them into version control. 49 | 50 | ## Creating a collection 51 | 52 | A collection is just a directory, so you can create a collection by simply creating an empty directory anywhere on your file system. 53 | 54 | With the directory created, it's time to load it into Posting... 55 | 56 | ## Loading a collection 57 | 58 | If you want to load a collection, you can do so by passing the path to the collection directory to Posting: 59 | 60 | ```bash 61 | posting --collection path/to/collection 62 | ``` 63 | 64 | ### Example 65 | 66 | To open a collection (a directory containing requests), use the `--collection` option: 67 | 68 | ```bash 69 | posting --collection path/to/collection 70 | ``` 71 | 72 | This will recursively find and display requests in the sidebar. 73 | If you don't supply a directory, Posting will use the default collection directory. 74 | You can check where the default collection is by running `posting locate collection`. -------------------------------------------------------------------------------- /docs/guide/command_palette.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | The *command palette* is a way to search for and execute commands in Posting. 4 | 5 | Some functionality in Posting can only be accessed through the command palette. 6 | 7 | It can be used to switch themes, show/hide parts of the UI, and more. 8 | 9 | ### Using the command palette 10 | 11 | Press ++ctrl+p++ to open the command palette. 12 | -------------------------------------------------------------------------------- /docs/guide/environments.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | You can use *variables* in input fields and text areas using the `${VARIABLE_NAME}` or `$VARIABLE_NAME` syntax. 4 | These variables will be substituted into outgoing requests. 5 | 6 |

7 | url-bar-environments-short 8 |

9 | 10 | ## Loading variables 11 | 12 | Variables are stored in `.env` files, and loaded using the `--env` option. 13 | 14 | Here's what a `.env` file might look like: 15 | 16 | ```bash 17 | # file: dev.env 18 | API_KEY="dev-api-key" 19 | ENV_NAME="dev" 20 | BASE_URL="https://${ENV_NAME}.example.com" 21 | ``` 22 | 23 | To make these variables available in the UI, you can load them using the `--env` option: 24 | 25 | ```bash 26 | posting --env dev.env 27 | ``` 28 | 29 | You can load multiple `.env` files by specifying the `--env` option multiple times: 30 | 31 | ```bash 32 | posting --env dev.env --env shared.env 33 | ``` 34 | 35 | This allows you to build up a set of variables which are common to all environments, and then override them for specific environments. 36 | 37 | ## Autoloading `.env` files 38 | 39 | If no `--env` options are provided, Posting will automatically load a `.env` file in the current directory if it exists. 40 | 41 | ## Using environment variables 42 | 43 | By default, Posting will only use variables defined in `.env` files that have been explicitly loaded using the `--env` option. 44 | 45 | If you want to permit using environment variables that exist on the host machine (i.e. those which are not defined in any `.env` files), you must set the `use_host_environment` config option to `true` (or set the environment variable `POSTING_USE_HOST_ENVIRONMENT=true`). 46 | 47 | ## Practical example 48 | 49 | Imagine you're testing an API which exists in both `dev` and `prod` environments. 50 | 51 | The `dev` and `prod` environments share some common variables, but differ in many ways too. 52 | We can model this by having a single `shared.env` file which contains variables which are shared between environments, and then a `dev.env` and `prod.env` file which contain environment specific variables. 53 | 54 | ```bash 55 | # file: shared.env 56 | API_PATH="/api/v1" 57 | ENV_NAME="shared" 58 | 59 | # file: dev.env 60 | API_KEY="dev-api-key" 61 | ENV_NAME="dev" 62 | BASE_URL="https://${ENV_NAME}.example.com" 63 | 64 | # file: prod.env 65 | API_KEY="prod-api-key" 66 | ENV_NAME="prod" 67 | BASE_URL="https://${ENV_NAME}.example.com" 68 | ``` 69 | 70 | When working in the `dev` environment, you can then load all of the shared variables and all of the development environment specific variables using the `--env` option: 71 | 72 | ```bash 73 | posting --env shared.env --env dev.env 74 | ``` 75 | 76 | This will load all of the shared variables from `shared.env`, and then load the variables from `dev.env`. Since `ENV_NAME` appears in both files, the value from the `dev.env` file will be used since that was the last one specified. 77 | 78 | Note that you do *not* need to restart to load changes made to these files, 79 | so you can open and edit your env files in an editor of your choice alongside Posting. 80 | 81 | ### Environment specific config 82 | 83 | Since all Posting configuration options can also be specified as environment variables, we can also put environment specific config inside `.env` files. There's a dedicated "Configuration" section in this document which covers this in more detail. 84 | 85 | For example, if you wanted to use a light theme in the prod environment (as a subtle reminder that you're in production!), you could set the environment variable `POSTING_THEME=solarized-light` inside the `prod.env` file. 86 | 87 | Note that configuration files take precedence over environment variables, so if you set a value in both a `.env` file and a `config.yaml`, the value from the `config.yaml` file will be used. 88 | -------------------------------------------------------------------------------- /docs/guide/external_tools.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | You can quickly switch between Posting and external editors and pagers. 4 | 5 | For example, you could edit request bodies in `vim`, and then browse the JSON response body in `less` or `fx`. 6 | 7 | You can even configure a custom pager specifically for browsing JSON. 8 | 9 | ## External Editors 10 | 11 | With a multi-line text area focused, press ++f4++ to open the file in your 12 | configured external editor. 13 | 14 | The configured external editor can be set as `editor` in your `config.yaml` 15 | file. 16 | For example: 17 | 18 | ```yaml title="config.yaml" 19 | editor: vim 20 | ``` 21 | 22 | Alternatively, you can set the `POSTING_EDITOR` environment variable. 23 | 24 | ```bash 25 | export POSTING_EDITOR=vim 26 | ``` 27 | 28 | If neither is set, Posting will try to use the `EDITOR` environment variable. 29 | 30 | ## External Pagers 31 | 32 | With a multi-line text area focused, press ++f3++ to open the file in your 33 | configured external pager. 34 | 35 | The configured external pager can be set as `pager` in your `config.yaml` 36 | file. 37 | For example: 38 | 39 | ```yaml title="config.yaml" 40 | pager: less 41 | ``` 42 | 43 | Alternatively, you can set the `POSTING_PAGER` environment variable. 44 | 45 | ```bash 46 | export POSTING_PAGER=less 47 | ``` 48 | 49 | ### JSON Pager 50 | 51 | You can use a custom pager for viewing JSON using the `pager_json` setting in 52 | your `config.yaml` file. 53 | For example: 54 | 55 | ```yaml title="config.yaml" 56 | pager_json: fx 57 | ``` 58 | 59 | Alternatively, you can set the `POSTING_PAGER_JSON` environment variable. 60 | 61 | ```bash 62 | export POSTING_PAGER_JSON=fx 63 | ``` 64 | 65 | If neither is set, Posting will try to use the default pager lookup rules discussed earlier. 66 | 67 | ## Exporting to curl 68 | 69 | > *Added in Posting 2.4.0* 70 | 71 | Open the command palette and select `export: copy as curl`. 72 | This will transform the open request into a cURL command, and copy it to your clipboard. 73 | 74 | ![Screenshot of command palette with the export: copy as curl option](../assets/curl-export.png) 75 | 76 | You can optionally supply extra arguments to pass to curl by setting the `curl_export_extra_args` setting in your `config.yaml` file. 77 | 78 | ```yaml title="config.yaml" 79 | curl_export_extra_args: "--verbose -w %{time_total} %{http_code}" 80 | ``` 81 | 82 | This will be inserted directly into the command that gets copied to your clipboard, immediately after `curl `, 83 | producing a command like the following: 84 | 85 | ```bash 86 | curl --verbose -w %{time_total} %{http_code} -X POST ... 87 | ``` 88 | 89 | -------------------------------------------------------------------------------- /docs/guide/help_system.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | Posting has a *built-in help system*, which can be used to get information about the currently focused widget. 4 | 5 | ### Getting help for the focused widget 6 | 7 | With a widget focused, press `f1` to open a help window for that widget. 8 | 9 | image 10 | 11 | Most widgets offer more keybindings and functionality than meets the eye, and more than what is shown in the application footer. 12 | 13 | The help window explains how to use the focused widget, and lists all of the keybindings offered by it. -------------------------------------------------------------------------------- /docs/guide/importing.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | Posting supports importing from external sources. 4 | 5 | ## Importing from curl 6 | 7 | !!! example "This feature is experimental." 8 | 9 | You can import a curl command by pasting it into the URL bar. 10 | 11 | This will fill out the request details in the UI based on the curl command you pasted, overwriting any existing values. 12 | 13 | ## Importing from OpenAPI 14 | 15 | !!! example "This feature is experimental." 16 | 17 | Posting can convert OpenAPI 3.x specs into collections. 18 | 19 | To import an OpenAPI Specification, use the `posting import path/to/openapi.yaml` command. 20 | 21 | You can optionally supply an output directory. 22 | 23 | If no output directory is supplied, the default collection directory will be used. 24 | 25 | Posting will attempt to build a file structure in the collection that aligns with the URL structure of the imported API. 26 | 27 | ## Importing from Postman 28 | 29 | !!! example "This feature is experimental." 30 | 31 | Collections can be imported from Postman. 32 | 33 | To import a Postman collection, use the `posting import --type postman path/to/postman_collection.json` command. 34 | 35 | You can optionally supply an output directory with the `-o` option. 36 | If no output directory is supplied, the default collection directory will be used (check where this is using `posting locate collection`). 37 | 38 | Variables will also be imported from the Postman collection and placed in a `.env` file inside the collection directory. 39 | -------------------------------------------------------------------------------- /docs/guide/keymap.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | As explained in the [Help System](./help_system.md) section, you can view the keybindings for any widget by pressing ++f1++ or ++ctrl+question-mark++ when that widget has focus. 4 | 5 | If you wish to use different keybindings, you can do so by editing the `keymap` section of your `config.yaml` file. 6 | Check the location of that file on your system by running `posting locate config` on the command line. 7 | 8 | ### Changing the keymap 9 | 10 | Actions in Posting have unique IDs which map to a keybinding (listed at the bottom of this page). 11 | For any of these IDs, you can change the keybinding by adding an entry to the `keymap` section of your `config.yaml` file: 12 | 13 | ```yaml 14 | keymap: 15 | : 16 | ``` 17 | 18 | Here's an example of changing the keybinding for the "Send Request" action: 19 | 20 | ```yaml 21 | keymap: 22 | send-request: ctrl+r 23 | ``` 24 | 25 | After adding the above entry to `config.yaml` and restarting Posting, you'll notice that that the footer of the app now shows `^r` to send a request rather than the default `^j`. 26 | 27 | Now you can press `^r` to send a request *instead of* `^j`. 28 | 29 | You can also have multiple keys map to the same action by separating them with commas: 30 | 31 | ```yaml 32 | keymap: 33 | send-request: ctrl+r,ctrl+i 34 | ``` 35 | 36 | Note that by adding an entry to the `keymap` you are overriding the default keybinding for that action, so if you wish to keep the default keybinding, you'll need to specify it again: 37 | 38 | ```yaml 39 | keymap: 40 | send-request: ctrl+r,ctrl+i,ctrl+j 41 | ``` 42 | 43 | ### Key format 44 | 45 | Support for keys in the terminal varies between terminals, multiplexers and operating systems. 46 | It's a complex topic, and one that may involve some trial and error. 47 | Some keys might be intercepted before reaching Posting, and your emulator might not support certain keys. 48 | 49 | - To specify ++ctrl+x++, use `ctrl+x`. 50 | - To specify ++ctrl+shift+x++, use `ctrl+X` (control plus uppercase "X"). 51 | - To specify multiple keys, separate them with commas: `ctrl+shift+left,ctrl+y`. 52 | - To specify a function key, use `f`. For example, ++f1++ would be `f1`. 53 | - To specify `@` (at) use `at` (*not* e.g. ++shift+2++ as this only applies to some keyboard layouts). 54 | - Arrow keys can be specified as `left`, `right`, `up` and `down`. 55 | - `shift` works as a modifier non-printable keys e.g. `shift+backspace`, `shift+enter`, `shift+right` are all acceptable. Support may vary depending on your emulator. 56 | - `alt` also works as a modifier e.g. `alt+enter`. 57 | - `ctrl+enter`, `alt+enter`,`ctrl+backspace`, `ctrl+shift+enter`, `ctrl+shift+space` etc. are supported if your terminal supports the Kitty keyboard protocol. 58 | - Other keys include (but are not limited to) `comma`, `full_stop`, `colon`, `semicolon`, `quotation_mark`, `apostrophe`, `left_bracket`, `right_square_bracket`, `left_square_bracket`, `backslash`, `vertical_line` (pipe |), `plus`, `minus`, `equals_sign`, `slash`, `asterisk`,`tilde`, `percent_sign`. 59 | 60 | The only way to know for sure which keys are supported in your particular terminal emulator is to install Textual, run `textual keys`, press the key you want to use, and look at the `key` field of the printed output. 61 | 62 | !!! example "Work in progress" 63 | In the future, I hope to make it easier to discover which keys are supported and when key presses they correspond to for a particular environment directly within Posting. This will likely take the form of a CLI command that outputs key names and their corresponding key presses. For now, if you need assistance, please open a discussion on [GitHub](https://github.com/darrenburns/posting/discussions). 64 | 65 | ### Binding IDs 66 | 67 | These are the IDs of the actions that you can change the keybinding for: 68 | 69 | - `send-request` - Send the current request. Default: `ctrl+j,alt+enter`. 70 | - `focus-method` - Focus the method selector. Default: `ctrl+t`. 71 | - `focus-url` - Focus the URL input. Default: `ctrl+l`. 72 | - `save-request` - Save the current request. Default: `ctrl+s`. 73 | - `expand-section` - Expand or shrink the section which has focus. Default: `ctrl+m`. 74 | - `toggle-collection` - Toggle the collection browser. Default: `ctrl+h`. 75 | - `new-request` - Create a new request. Default: `ctrl+n`. 76 | - `commands` - Open the command palette. Default: `ctrl+p`. 77 | - `help` - Open the help dialog for the currently focused widget. Default: `f1,ctrl+question_mark`. 78 | - `quit` - Quit the application. Default: `ctrl+c`. 79 | - `jump` - Enter jump mode. Default: `ctrl+o`. 80 | - `open-in-pager` - Open the content of the focused text area in your $PAGER/$POSTING_PAGER/$POSTING_PAGER_JSON. Default: `f3`. 81 | - `open-in-editor` - Open the content of the focused text area in your $EDITOR/$POSTING_EDITOR. Default: `f4`. 82 | - `search-requests` - Go to a request by name. Default: `ctrl+shift+p`. 83 | -------------------------------------------------------------------------------- /docs/guide/navigation.md: -------------------------------------------------------------------------------- 1 | Posting can be navigated using either mouse or keyboard. 2 | 3 | ## Jump mode 4 | 5 | Jump mode is the fastest way to get around. 6 | 7 | Press ctrl+o to enter jump mode, followed by the key corresponding to the widget you want to switch focus to (jump to). 8 | 9 | ![Jump mode preview image](../assets/jump-mode.png) 10 | 11 | With the default layout, the positioning of keys on the overlays is similar to the positioning of the keys on a QWERTY keyboard. 12 | 13 | To exit jump mode, press esc. 14 | 15 | ## Tab navigation 16 | 17 | tab and shift+tab will move focus between widgets, 18 | and j/k/up/down will move around within a widget. 19 | 20 | Some widgets have additional keybindings for navigation. 21 | You can check these by pressing f1 while it is focused. 22 | 23 | Where it makes sense, up and down will also move between widgets. 24 | 25 | ## Mouse navigation 26 | 27 | You can also navigate Posting entirely using the mouse, very much like a typical GUI application. 28 | 29 | If a widget shows a scrollbar, you can use the mouse wheel or trackpad gestures to scroll through its content. 30 | Scrollbars can also be clicked and dragged. 31 | 32 | If you hold shift and scroll using the trackpad or mousewheel, the content will scroll horizontally (if there's a horizontal scrollbar). 33 | 34 | ## Searching and jumping to requests 35 | 36 | Press ctrl+shift+p to open the fuzzy search popup (configurable using the `search-requests` keybinding, see [keymap](./keymap.md)). 37 | 38 | Type the name of the request you want to jump to and press enter to open it. 39 | 40 | ![Search requests preview image](../assets/search-requests.png) 41 | 42 | ## Contextual help 43 | 44 | Many widgets have additional bindings for navigation other than those displayed in the footer. 45 | You can view the full list of keybindings for the currently focused widget, as well as additional usage information and tips, by pressing f1 or ctrl+? (or ctrl+shift+/). 46 | 47 | ![Contextual help preview image](../assets/contextual-help.png) 48 | 49 | ## Automatic focus switching 50 | 51 | You can use the `focus.on_startup` and `focus.on_response` configuration options to control which widget is focused when the app starts and when a response is received. 52 | 53 | | Config | Default value | Description | 54 | |----------------------|---------------|-------------| 55 | | `focus.on_startup` | `"url"`, `"method", "collection"` (Default: `"url"`) | Automatically focus the URL bar, method, or collection browser when the app starts. | 56 | | `focus.on_response` | `"body"`, `"tabs"` (Default: `unset`)| Automatically focus the response tabs or response body text area when a response is received. | 57 | | `focus.on_request_open` | `"headers"`, `"body"`, `"query"`, `"info"`, `"url"`, `"method"` (Default: `unset`) | Automatically focus the specified target when a request is opened from the collection browser. | 58 | 59 | ## Exiting 60 | 61 | Quit Posting by pressing ctrl+c, or by opening the command palette and selecting "Quit". -------------------------------------------------------------------------------- /docs/guide/requests.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | Requests are stored directly on your file system as simple YAML files, suffixed with `.posting.yaml` - easy to read, understand, and version control! 4 | 5 | ## Example 6 | 7 | Here's an example of what a request file looks like: 8 | 9 | ```yaml 10 | name: Create user 11 | description: Adds a new user to the system. 12 | method: POST 13 | url: https://jsonplaceholder.typicode.com/users 14 | body: 15 | content: |- 16 | { 17 | "firstName": "John", 18 | "email": "john.doe@example.com" 19 | } 20 | headers: 21 | - name: Content-Type 22 | value: application/json 23 | params: 24 | - name: sendWelcomeEmail 25 | value: 'true' 26 | ``` 27 | 28 | ## Creating a new request 29 | 30 | Press ++ctrl+n++ to create a new request. 31 | 32 | You'll be prompted to supply a name for the request. 33 | By default, this name is used to generate the filename, but you can also choose your own filename if you wish. 34 | 35 | !!! tip 36 | If you already have a collection loaded, the path in the "New Request" dialog will be pre-filled based on the position of the cursor in the collection tree, so moving the cursor to the correct location *before* pressing ++ctrl+n++ will save you from needing to type out the path. 37 | 38 | Within the "Path in collection" field of this dialog, it's important to note that `.` refers to the currently loaded *collection* directory (that is, the directory that was loaded using the `--collection` option), and *not* necessarily the current working directory. 39 | 40 | ### Duplicating a request 41 | 42 | With a the cursor over a request in the collection tree, press ++d++ to create a duplicate of that request. This will bring up a dialog allowing you to change the name and description of the request, or move it to another location. 43 | 44 | To skip the dialog and quickly duplicate the request, press ++shift+d++, creating it as a sibling of the original request. The file name of the new request will be generated automatically. You can always modify the name and description after it's created in the `Info` tab. 45 | 46 | ## Saving a request 47 | 48 | Press ++ctrl+s++ to save the currently open request. 49 | 50 | If you haven't saved the request yet, a dialog will appear, prompting you to give the request a name, and to select a directory to save it in. 51 | 52 | !!! tip "Folders" 53 | 54 | Requests can be saved to folders - simply include a `/` in the `Path in collection` field when you save the request, 55 | and Posting will create the required directory structure for you. 56 | 57 | If the request is already saved on disk, ++ctrl+s++ will overwrite the previous version with your new changes. 58 | 59 | ## Loading requests 60 | 61 | Requests are stored on your file system as simple YAML files, suffixed with `.posting.yaml`. 62 | 63 | A directory can be loaded into Posting using the `--collection` option, and all `.posting.yaml` files in that directory will be displayed in the sidebar. 64 | 65 | ## Deleting a request 66 | 67 | You can delete a request by moving the cursor over it in the tree, and pressing ++backspace++. 68 | -------------------------------------------------------------------------------- /docs/guide/scripting.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | You can attach simple Python scripts to requests inside the `Scripts` tab, and have Posting run them at various stages of the request lifecycle. This powerful feature allows you to: 4 | 5 | - Perform setup before a request (e.g. setting variables, preparing data) 6 | - Set or modify headers, query parameters, and other request properties 7 | - Print logs and messages 8 | - Set variables to be used in later requests (e.g. authentication tokens) 9 | - Inspect request and response objects, and manipulate them 10 | - Pretty much anything else you can think of doing with Python! 11 | 12 | Scripts tab 13 | 14 | ## Script types 15 | 16 | Posting supports three types of scripts, which run at different points in the request/response lifecycle: 17 | 18 | 1. **Setup Scripts**: Runs before the request is constructed. This is useful for setting initial variables which may be substituted into the request. 19 | 2. **Pre-request Scripts**: Runs after the request has been constructed and variables have been substituted, but before the request is sent. You can directly modify the request object here. 20 | 3. **Post-response Scripts**: Runs after the response is received. This is useful for extracting data from the response, or for performing cleanup. 21 | 22 | ## Writing scripts 23 | 24 | In the context of Posting, a "script" is a regular Python function. 25 | 26 | By default, if you specify a path to a Python file, Posting will look for and execute the following functions at the appropriate times: 27 | 28 | - `setup(posting: Posting) -> None` 29 | - `on_request(request: RequestModel, posting: Posting) -> None` 30 | - `on_response(response: httpx.Response, posting: Posting) -> None` 31 | 32 | However, you can have Posting call any function you wish using the syntax `path/to/script.py:function_to_run`. 33 | 34 | Note that relative paths are relative to the collection directory. 35 | This ensures that if you place scripts inside your collection directory, 36 | they're included when you share a collection with others. 37 | 38 | Note that you do not need to specify all of the arguments when writing these functions. Posting will only pass the number of arguments that you've specified when it calls your function. For example, you could define a your `on_request` function as `def on_request(request: RequestModel) -> None` and Posting would call it with `on_request(request: RequestModel)` without passing the `posting` argument. 39 | 40 | ## Editing scripts 41 | 42 | When you edit a script, it'll automatically be reloaded. 43 | This means you can keep Posting open while editing it. 44 | 45 | Posting also allows you to quickly jump to your editor (assuming you've set the `$EDITOR` or `$POSTING_EDITOR` environment variables) to edit a script. 46 | Press ++ctrl+e++ while a script input field inside the `Scripts` tab is focused to open the path in your editor. 47 | 48 | !!! warning 49 | As of version 2.0.0, the script file must exist *before* pressing ++ctrl+e++. Posting will not create the file for you. 50 | 51 | ## Script logs 52 | 53 | If your script writes to `stdout` or `stderr`, you'll see the output in the `Scripts` tab in the Response section. 54 | This output is not persisted on disk. 55 | 56 | ### Example: Setup script 57 | 58 | The **setup script** is run before the request is built. 59 | You can set variables in the setup script that can be used in the request. 60 | For example, you could use `httpx` to fetch an access token, then set the token as a variable so that it may be substituted into the request. 61 | 62 | ```python 63 | def setup(posting: Posting) -> None: 64 | # Set a variable which may be used in this request 65 | # (or other requests to follow) 66 | posting.set_variable("auth_token", "1234567890") 67 | ``` 68 | 69 | With this setup script attached to a request, you can then reference the `auth_token` variable in the request UI by typing `$auth_token`. 70 | The `$auth_token` variable will remain for the duration of the session, 71 | so you may wish to add a check to see if it has already been set in this session: 72 | 73 | ```python 74 | def setup(posting: Posting) -> None: 75 | if not posting.get_variable("auth_token"): 76 | posting.set_variable("auth_token", "1234567890") 77 | ``` 78 | 79 | ### Example: Pre-request script 80 | 81 | The **pre-request script** is run after the request has been constructed and variables have been substituted, right before the request is sent. 82 | 83 | You can directly modify the `RequestModel` object in this function, for example to set headers, query parameters, etc. 84 | The code snippet below shows some of the API. 85 | 86 | ```python 87 | from posting import Auth, Header, RequestModel, Posting 88 | 89 | 90 | def on_request(request: RequestModel, posting: Posting) -> None: 91 | # Add a custom header to the request. 92 | request.headers.append(Header(name="X-Custom-Header", value="foo")) 93 | 94 | # Set auth on the request. 95 | request.auth = Auth.basic_auth("username", "password") 96 | # request.auth = Auth.digest_auth("username", "password") 97 | # request.auth = Auth.bearer_token_auth("token") 98 | 99 | # This will be captured and written to the log. 100 | print("Request is being sent!") 101 | 102 | # Make a notification pop-up in the UI. 103 | posting.notify("Request is being sent!") 104 | ``` 105 | 106 | ### Example: Post-response script 107 | 108 | The **post-response script** is run after the response is received. 109 | You can use this to extract data from the response, for example a JWT token, 110 | and set it as a variable to be used in later requests. 111 | 112 | ```python 113 | from posting import Posting 114 | 115 | 116 | def on_response(response: httpx.Response, posting: Posting) -> None: 117 | # Print the status code of the response to the log. 118 | print(response.status_code) 119 | 120 | # Set a variable to be used in later requests. 121 | # You can write '$auth_token' in the UI and it will be substituted with 122 | # the value of the $auth_token variable. 123 | posting.set_variable("auth_token", response.headers["Authorization"]) 124 | ``` 125 | 126 | ### The `Posting` object 127 | 128 | The `Posting` object provides access to the application context and useful methods: 129 | 130 | - `set_variable(name: str, value: object) -> None`: Set a session variable 131 | - `get_variable(name: str, default: object | None = None) -> object | None`: Get a session variable 132 | - `clear_variable(name: str) -> None`: Clear a specific session variable 133 | - `clear_all_variables() -> None`: Clear all session variables 134 | - `notify(message: str, title: str = "", severity: str = "information", timeout: float | None = None)`: Send a notification to the user 135 | 136 | Note that variables are described as "session variables" because they persist for the duration of the session (until you close Posting). 137 | 138 | ### Execution environment 139 | 140 | Scripts run in the same process and environment as Posting, so you should take care to avoid performing damaging global operations such as monkey-patching standard library modules. 141 | 142 | #### Libraries 143 | 144 | You can make use of any library that is available in the Python environment that Posting is running in. This means you can use all of the Python standard library as well as any of Posting's dependencies (such as `httpx`, `pyyaml`, `pydantic`, etc). 145 | 146 | If you install Posting with `uv`, you can easily add extra dependencies which you can then use in your scripts: 147 | 148 | ```bash 149 | uv tool install posting --with 150 | ``` 151 | -------------------------------------------------------------------------------- /docs/guide/themes.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | Posting ships with several built-in themes, and also supports custom, user-made themes. 4 | 5 | When editing a theme on disk, Posting can show a live preview of the theme in effect, making it easy to design and test themes. 6 | 7 | ### Creating a theme 8 | 9 | You can check where Posting will look for user-defined themes by running `posting locate themes` in your terminal. 10 | Place custom themes in this directory and Posting will load them on startup. 11 | Theme files must be suffixed with `.yaml`, but the rest of the filename is unused by Posting. 12 | Built-in themes are *not* in this directory, but are part of the Posting code itself. 13 | 14 | Here's an example theme file: 15 | 16 | ```yaml 17 | name: example # use this name in your config file 18 | primary: '#4e78c4' # buttons, fixed table columns 19 | secondary: '#f39c12' # method selector, some minor labels 20 | accent: '#e74c3c' # header text, scrollbars, cursors, focus highlights 21 | background: '#0e1726' # background colors 22 | surface: '#17202a' # panels, etc 23 | error: '#e74c3c' # error messages 24 | success: '#2ecc71' # success messages 25 | warning: '#f1c40f' # warning messages 26 | 27 | # Optional metadata 28 | author: Darren Burns 29 | description: A dark theme with a blue primary color. 30 | homepage: https://github.com/darrenburns/posting 31 | ``` 32 | 33 | After adding a theme, you'll need to restart Posting for it to take effect. 34 | 35 | To use the theme, you can specify it in your `config.yaml` file: 36 | 37 | ```yaml 38 | theme: example 39 | ``` 40 | 41 | Note that the theme name is *not* defined by the filename, but by the `name` field in the theme file. 42 | 43 | !!! tip 44 | 45 | If you edit a theme on disk while Posting is using it, the UI will automatically 46 | refresh to reflect the changes you've made. This is enabled by default, but if you'd 47 | like to disable it, you can set `watch_themes` to `false` in your `config.yaml`. 48 | 49 | #### Syntax highlighting 50 | 51 | Syntax highlighted elements such as the URL bar, text areas, and fields which contain variables will be colored based on the semantic colors defined in the theme (`primary`, `secondary`, etc) by default. 52 | 53 | If you'd like more control over the syntax highlighting, you can specify a custom syntax highlighting colors inside the theme file. 54 | 55 | The example below illustrates some of the options available when it comes to customizing syntax highlighting. 56 | 57 | ```yaml 58 | text_area: 59 | cursor: 'reverse' # style the block cursor 60 | cursor_line: 'underline' # style the line the cursor is on 61 | selection: 'reverse' # style the selected text 62 | gutter: 'bold #50e3c2' # style the gutter 63 | matched_bracket: 'black on green' # style the matched bracket 64 | url: 65 | base: 'italic #50e3c2' # style the 'base' of the url 66 | protocol: 'bold #b8e986' # style the protocol 67 | syntax: 68 | json_key: 'italic #4a90e2' # style json keys 69 | json_number: '#50e3c2' # style json numbers 70 | json_string: '#b8e986' # style json strings 71 | json_boolean: '#b8e986' # style json booleans 72 | json_null: 'underline #b8e986' # style json null values 73 | ``` 74 | 75 | #### Method styles 76 | 77 | You can also specify custom styles for methods in the collection tree. 78 | 79 | Here's an example: 80 | 81 | ```yaml 82 | method: 83 | get: 'underline #50e3c2' 84 | post: 'italic #b8e986' 85 | put: 'bold #b8e986' 86 | delete: 'strikethrough #b8e986' 87 | ``` 88 | 89 | ### X resources themes 90 | 91 | Posting supports using X resources for theming. To use this, enable the `use_xresources` option (see above). 92 | 93 | It requires the `xrdb` executable on your `PATH` and `xrdb -query` must return the following variables: 94 | 95 | | Xresources | Description | 96 | |-------------|-----------| 97 | | *color0 | primary color: used for button backgrounds and fixed table columns | 98 | | *color8 | secondary color: used in method selector and some minor labels | 99 | | *color1 | error color: used for error messages | 100 | | *color2 | success color: used for success messages | 101 | | *color3 | warning color: used for warning messages | 102 | | *color4 | accent color: used for header text, scrollbars, cursors, focus highlights | 103 | | *background | background color | 104 | | *color7 | surface/panel color | 105 | 106 | If these conditions are met, themes called `xresources-dark` and `xresources-light` will be available for use. 107 | -------------------------------------------------------------------------------- /docs/home-image.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/docs/home-image.afdesign -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: The API client that lives in your terminal 3 | template: home.html 4 | --- 5 | -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | ## About this document 2 | 3 | If you have any feedback or suggestions, please open a [new discussion on GitHub](https://github.com/darrenburns/posting/discussions/). This roadmap is driven by community requests, so please open a discussion if you'd like to see something added. 4 | 5 | 46 | 47 | ## Ongoing 🔄 48 | 49 | Features that are currently being worked on. 50 | 51 | - Add contributing guide Documentation 52 | 53 | ## Upcoming 🚀 54 | 55 | Features planned for the near future. 56 | 57 | - Editing key/value editor rows without having to delete/re-add them UX 58 | - Adjustable padding in UI via config file UI 59 | - Don't require user to type `http://` or `https://` in URL field UX 60 | - Documentation on changing the UI at runtime (e.g. showing/hiding sections, etc.) Documentation 61 | - Documentation on using 3rd party libraries in scripts Documentation 62 | - Transparent background support (experimentation) UI 63 | - In-app information about headers Documentation 64 | - A better footer UX UI 65 | - The footer currently contains too many bindings. There should be a way to show that it is scrollable, possibly showing grouping of keybindings. 66 | 67 | ## Longer Term 🔮 68 | 69 | Features that are planned for future development but are not immediate priorities. 70 | 71 | - Directional navigation UI UX 72 | - Jump mode 2-stage jump - if you press shift+[jump target key], then it'll jump to the target and then show a secondary overlay of available targets within that section UX 73 | - Manually resize sections (sidebar, request, response) UI 74 | - Searching in responses (this will likely be simpler with upcoming Textual changes) Requests 75 | - File watcher so that if the request changes on disk then the UI updates to reflect it Requests 76 | - Translating to other languages Documentation 77 | - I'd like to support e.g. Chinese, but need to investigate how that would render with double width characters in the terminal. 78 | - Warning when switching request when there are unsaved changes UX 79 | - Request tagging: the ability to add tags to requests, and filter by tag Requests 80 | - Making it clear which HTTP headers are set automatically UX 81 | - Collection switcher Collection 82 | - Environment switcher Environment 83 | - Viewing the currently loaded environment keys/values in a popup Environment 84 | - Changing the environment at runtime via command palette Environment 85 | - WebSocket and SSE support Realtime 86 | - Quickly open MDN links for headers UI 87 | - Add rotating logging Logging 88 | - Variable completion autocompletion in TextAreas Environment 89 | - Variable resolution highlighting in TextAreas Environment 90 | - Status bar? Showing the currently selected env, collection, current path, whether there's unsaved changes, etc. UI 91 | - Highlighting variables in *tables* to show if they've resolved or not Environment 92 | - Create a `_template.posting.yaml` file for request templates Requests 93 | - OAuth2 implementation (need to scope out what's involved) Auth 94 | - Adding test framework Testing 95 | - Uploading files Requests 96 | - Cookie editor Requests 97 | - Import from Postman (PR is open, needs further work) Import 98 | 99 | ## Completed ✓ 100 | 101 | Features that have been implemented and are available in the current version. 102 | 103 | - Keymaps UI 104 | - Pre-request and post-response scripts Scripting 105 | - Parse cURL commands Import 106 | - Watching environment files for changes & updating the UI Environment 107 | - Bearer token auth Auth 108 | - Add "quit" to command palette and footer UX 109 | - More user friendly errors UX 110 | - Duplicate request from the tree Collection 111 | - Quickly duplicate request from the tree Collection 112 | - Colour-coding for request types (i.e. GET is green, POST is blue, etc.) UI 113 | - Delete request from the tree Collection 114 | - Inserting into the collection tree in sorted order, not at the bottom Collection 115 | - External documentation Documentation 116 | - Enabling and disabling rows in tables UX 117 | - Custom themes, loaded from theme directory UI 118 | - Dynamic in-app help system Documentation 119 | - Specify certificate path via config or CLI Security 120 | 121 | 122 | ## Legend 123 | 124 | The following tags are used to categorize features: 125 | 126 |
127 |
User Interface improvements
UI
128 |
Collection management
Collection
129 |
Environment handling
Environment
130 |
Authentication methods
Auth
131 |
Import capabilities
Import
132 |
Scripting capabilities
Scripting
133 |
Documentation
Documentation
134 |
User Experience
UX
135 |
Requests
Requests
136 |
Testing capabilities
Testing
137 |
Security features
Security
138 |
Logging capabilities
Logging
139 |
-------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | h1, h2, h3, h4, h5, h6 { 2 | font-family: "Roboto Mono", monospace; 3 | font-weight: 700; 4 | font-style: normal; 5 | font-optical-sizing: auto; 6 | color: #f0f0f0 !important; 7 | } 8 | 9 | body { 10 | background-image: linear-gradient( 11 | 0deg, 12 | hsl(272deg 33% 11%) 0%, 13 | hsl(276deg 30% 9%) 20%, 14 | hsl(279deg 29% 7%) 40%, 15 | hsl(282deg 24% 4%) 60%, 16 | hsl(313deg 30% 7%) 80%, 17 | hsl(311, 35%, 12%) 90%, 18 | hsl(307, 58%, 22%) 100% 19 | ); 20 | background-repeat: no-repeat; 21 | background-attachment: fixed; 22 | background-size: auto; 23 | 24 | } 25 | 26 | code { 27 | background: hsla(287deg, 100%, 10%, 0.4) !important; 28 | border: 1px solid hsl(287deg 100% 30%) !important; 29 | } 30 | 31 | table { 32 | background: hsla(287deg, 100%, 10%, 0.4) !important; 33 | border: 1px solid hsl(287deg 100% 30%) !important; 34 | } 35 | 36 | span.filename { 37 | background: hsla(287deg, 100%, 10%, 0.4) !important; 38 | border: 1px solid hsl(287deg 100% 30%) !important; 39 | border-bottom: none !important; 40 | color: hsla(286, 42%, 73%, 0.526) !important; 41 | } 42 | 43 | .md-nav__link { 44 | background-color: transparent !important; 45 | box-shadow: none !important; 46 | } 47 | .md-nav__link--active { 48 | color: hsl(287, 72%, 66%) !important; 49 | } 50 | 51 | nav.md-nav.md-nav--secondary { 52 | border-left-color: hsl(287, 72%, 66%) !important; 53 | } 54 | 55 | nav.md-tabs { 56 | background: transparent !important; 57 | box-shadow: none !important; 58 | box-sizing: content-box; 59 | max-width: 800px; 60 | margin: 0 auto; 61 | 62 | & .md-tabs__link { 63 | color: #f0f0f0 !important; 64 | opacity: 0.93; 65 | font-size: 0.83rem; 66 | position: relative; 67 | text-decoration: none; 68 | } 69 | 70 | & .md-tabs__link::after { 71 | content: ''; 72 | position: absolute; 73 | width: 0; 74 | height: 2px; 75 | bottom: -2px; 76 | left: 50%; 77 | background-color: hsl(287, 72%, 66%); 78 | transition: width 0.16s ease-out, left 0.16s ease-out; 79 | } 80 | 81 | & .md-tabs__link:hover::after { 82 | width: 100%; 83 | left: 0; 84 | } 85 | 86 | & .md-tabs__item--active { 87 | font-weight: bold; 88 | a { 89 | text-decoration: none; 90 | position: relative; 91 | } 92 | a::after { 93 | content: ''; 94 | position: absolute; 95 | width: 100%; 96 | height: 2px; 97 | bottom: -2px; 98 | left: 0; 99 | background-color: hsl(287, 72%, 66%); 100 | transition: all 0.16s ease-in-out; 101 | } 102 | a:hover::after { 103 | height: 3px; 104 | box-shadow: 0 0 5px hsl(287, 72%, 66%); 105 | } 106 | } 107 | } 108 | .md-header{position:initial;background-color: rgba(0,0,0,0);} 109 | .md-header--shadow { 110 | box-shadow: none; 111 | } 112 | .md-content { 113 | background-color: rgba(0,0,0,0); 114 | padding-bottom: 2.5rem; 115 | } 116 | .md-nav__title { 117 | box-shadow: none !important; 118 | background-color: transparent !important; 119 | } 120 | 121 | article p { 122 | color: #f0f0f0f0; 123 | } 124 | 125 | p a { 126 | color: hsl(287, 72%, 66%) !important; 127 | } 128 | 129 | a:hover { 130 | color: hsl(287, 72%, 66%) !important; 131 | } 132 | 133 | a.posting-button { 134 | color: whitesmoke; 135 | padding: 0.7rem 1.4rem; 136 | background-color: hsl(287deg 100% 10%); 137 | border: 3px solid hsl(287deg 100% 30%); 138 | border-radius: 2px; 139 | font-family: "Roboto Mono", monospace; 140 | 141 | &:hover { 142 | color: whitesmoke; 143 | font-weight: 600; 144 | border: 3px solid rgba(255, 100, 203, 1); 145 | } 146 | } 147 | 148 | .monospace { 149 | font-family: "Roboto Mono", monospace; 150 | } 151 | 152 | .md-search__output mark { 153 | color: hsl(287, 72%, 66%); 154 | } 155 | 156 | .md-search-result__more div { 157 | color: hsl(287, 72%, 66%) !important; 158 | opacity: 0.8; 159 | } 160 | 161 | article { 162 | max-width: 840px; 163 | margin: 0 auto !important; 164 | padding: 0 1rem; 165 | 166 | & p mark { 167 | background-color: rgba(255, 100, 203, 0.25) !important; 168 | } 169 | } 170 | 171 | footer { 172 | & div.md-ellipsis { 173 | font-family: "Roboto Mono", monospace; 174 | } 175 | } 176 | 177 | @media (min-width: 1220px) { 178 | article { 179 | margin: 0 0 !important; 180 | } 181 | } 182 | 183 | :root { 184 | 185 | } -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Posting 2 | site_url: https://posting.sh 3 | repo_url: https://github.com/darrenburns/posting 4 | repo_name: darrenburns/posting 5 | theme: 6 | name: material 7 | palette: 8 | scheme: slate 9 | primary: custom 10 | custom_dir: docs/overrides 11 | icon: 12 | logo: fontawesome/solid/paper-plane 13 | font: 14 | text: Open Sans 15 | features: 16 | - search.suggest 17 | - search.highlight 18 | - header.autohide 19 | - navigation.tabs 20 | - navigation.tabs.sticky 21 | - navigation.footer 22 | - toc.integrate 23 | extra_css: 24 | - stylesheets/extra.css 25 | nav: 26 | - Home: "index.md" 27 | - Guide: 28 | - "Getting Started": "guide/index.md" 29 | - "Navigation": "guide/navigation.md" 30 | - "Collections": "guide/collections.md" 31 | - "Requests": "guide/requests.md" 32 | - "Configuration": "guide/configuration.md" 33 | - "Environments": "guide/environments.md" 34 | - "Command Palette": "guide/command_palette.md" 35 | - "Themes": "guide/themes.md" 36 | - "External Tools": "guide/external_tools.md" 37 | - "Keymaps": "guide/keymap.md" 38 | - "Importing": "guide/importing.md" 39 | - "Scripting": "guide/scripting.md" 40 | - "Help System": "guide/help_system.md" 41 | - Roadmap: "roadmap.md" 42 | - Changelog: "CHANGELOG.md" 43 | - FAQ: "faq.md" 44 | plugins: 45 | - search 46 | - tags 47 | markdown_extensions: 48 | - abbr 49 | - admonition 50 | - attr_list 51 | - def_list 52 | - toc: 53 | permalink: true 54 | - footnotes 55 | - tables 56 | - pymdownx.keys 57 | - pymdownx.highlight: 58 | use_pygments: true 59 | - pymdownx.superfences 60 | - pymdownx.details 61 | extra: 62 | social: 63 | - icon: fontawesome/brands/mastodon 64 | link: https://fosstodon.org/@darrenburns 65 | - icon: fontawesome/brands/x-twitter 66 | link: https://x.com/_darrenburns 67 | - icon: fontawesome/brands/github 68 | link: https://github.com/darrenburns 69 | copyright: Copyright © 2024 Darren Burns -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "posting" 3 | version = "2.7.0" 4 | description = "The modern API client that lives in your terminal." 5 | authors = [ 6 | { name = "Darren Burns", email = "darrenb900@gmail.com" } 7 | ] 8 | dependencies = [ 9 | "click>=8.1.7,<9.0.0", 10 | "xdg-base-dirs>=6.0.1,<7.0.0", 11 | "click-default-group>=1.2.4,<2.0.0", 12 | "httpx[brotli]==0.28.1", 13 | # pinned httpx automatically since we monkeypatch _main.py 14 | "openapi-pydantic>=0.5.0", 15 | "pyperclip>=1.9.0,<2.0.0", 16 | "pydantic>=2.9.2,<3.0.0", 17 | "pyyaml>=6.0.2,<7.0.0", 18 | "pydantic-settings>=2.4.0,<3.0.0", 19 | "python-dotenv>=1.0.1,<2.0.0", 20 | # pinned intentionally 21 | "watchfiles>=0.24.0", 22 | "textual[syntax]==3.0.0", 23 | "textual-autocomplete>=4.0.4,<5.0.0", 24 | ] 25 | readme = "README.md" 26 | requires-python = ">= 3.11" 27 | license = { file = "LICENSE" } 28 | keywords = ["tui", "http", "client", "terminal", "api", "testing", "textual", "cli", "posting", "developer-tool"] 29 | classifiers = [ 30 | "Development Status :: 5 - Production/Stable", 31 | "Environment :: Console", 32 | "Intended Audience :: Developers", 33 | "License :: OSI Approved :: Apache Software License", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | ] 38 | 39 | [project.urls] 40 | Homepage = "https://github.com/darrenburns/posting" 41 | Repository = "https://github.com/darrenburns/posting" 42 | Issues = "https://github.com/darrenburns/posting/issues" 43 | Documentation = "https://github.com/darrenburns/posting/blob/main/README.md" 44 | 45 | [build-system] 46 | requires = ["hatchling"] 47 | build-backend = "hatchling.build" 48 | 49 | [project.scripts] 50 | posting = "posting.__main__:cli" 51 | 52 | [tool.uv] 53 | dev-dependencies = [ 54 | "textual-dev>=1.5.1", 55 | "pytest>=8.3.1", 56 | "jinja2>=3.1.4", 57 | "syrupy>=4.6.1", 58 | "pytest-xdist>=3.6.1", 59 | "pytest-cov>=5.0.0", 60 | "pytest-textual-snapshot>=1.0.0", 61 | "mkdocs-material>=9.5.30", 62 | "pyinstrument>=5.0.1", 63 | ] 64 | 65 | [tool.hatch.metadata] 66 | allow-direct-references = true 67 | 68 | [tool.hatch.build.targets.wheel] 69 | packages = ["src/posting"] 70 | 71 | [tool.pytest.ini_options] 72 | markers = [ 73 | "serial", # used to indicate tests must not run in parallel 74 | ] 75 | 76 | [tool.coverage.run] 77 | relative_files = true 78 | -------------------------------------------------------------------------------- /src/posting/__init__.py: -------------------------------------------------------------------------------- 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 | 10 | from .collection import ( 11 | Auth, 12 | Cookie, 13 | Header, 14 | QueryParam, 15 | RequestBody, 16 | RequestModel, 17 | FormItem, 18 | Options, 19 | Scripts, 20 | ) 21 | from .scripts import Posting 22 | 23 | 24 | __all__ = [ 25 | "Auth", 26 | "Cookie", 27 | "Header", 28 | "QueryParam", 29 | "RequestBody", 30 | "RequestModel", 31 | "FormItem", 32 | "Options", 33 | "Scripts", 34 | "Posting", 35 | ] 36 | -------------------------------------------------------------------------------- /src/posting/__main__.py: -------------------------------------------------------------------------------- 1 | """The main entry point for the Posting CLI.""" 2 | 3 | from pathlib import Path 4 | import click 5 | import os 6 | 7 | from click_default_group import DefaultGroup 8 | from rich.console import Console 9 | 10 | from posting.app import Posting 11 | from posting.collection import Collection 12 | from posting.config import Settings 13 | from posting.locations import ( 14 | config_file, 15 | default_collection_directory, 16 | theme_directory, 17 | ) 18 | from posting.variables import load_variables 19 | 20 | 21 | def create_config_file() -> None: 22 | f = config_file() 23 | if f.exists(): 24 | return 25 | 26 | try: 27 | f.touch() 28 | except OSError: 29 | pass 30 | 31 | 32 | def create_default_collection() -> Path: 33 | f = default_collection_directory() 34 | if f.exists(): 35 | return f 36 | 37 | try: 38 | f.mkdir(parents=True) 39 | except OSError: 40 | pass 41 | 42 | return f 43 | 44 | 45 | @click.group(cls=DefaultGroup, default="default", default_if_no_args=True) 46 | def cli() -> None: 47 | """A TUI for testing HTTP APIs.""" 48 | 49 | 50 | @cli.command() 51 | @click.option( 52 | "--collection", 53 | "-c", 54 | type=click.Path(exists=True), 55 | help="Path to the collection directory", 56 | ) 57 | @click.option( 58 | "--env", 59 | "-e", 60 | type=click.Path(exists=True), 61 | help="Path to the .env environment file(s)", 62 | multiple=True, 63 | ) 64 | def default(collection: str | None = None, env: tuple[str, ...] = ()) -> None: 65 | create_config_file() 66 | default_collection = create_default_collection() 67 | collection_path = Path(collection) if collection else default_collection 68 | app = make_posting( 69 | collection=collection_path, 70 | env=env, 71 | using_default_collection=collection is None, 72 | ) 73 | app.run() 74 | 75 | 76 | @cli.command() 77 | @click.argument( 78 | "thing_to_locate", 79 | ) 80 | def locate(thing_to_locate: str) -> None: 81 | if thing_to_locate == "config": 82 | print("Config file:") 83 | print(config_file()) 84 | elif thing_to_locate == "collection": 85 | print("Default collection directory:") 86 | print(default_collection_directory()) 87 | elif thing_to_locate == "themes": 88 | print("Themes directory:") 89 | print(theme_directory()) 90 | else: 91 | # This shouldn't happen because the type annotation should enforce that 92 | # the only valid options are "config" and "collection". 93 | print(f"Unknown thing to locate: {thing_to_locate!r}") 94 | 95 | 96 | @cli.command(name="import") 97 | @click.argument("spec_path", type=click.Path(exists=True)) 98 | @click.option( 99 | "--output", 100 | "-o", 101 | type=click.Path(), 102 | help="Path to save the imported collection", 103 | default=None, 104 | ) 105 | @click.option( 106 | "--type", "-t", default="openapi", help="Specify spec type [openapi, postman]" 107 | ) 108 | def import_spec(spec_path: str, output: str | None, type: str) -> None: 109 | """Import an OpenAPI specification into a Posting collection.""" 110 | console = Console() 111 | console.print( 112 | "Importing is currently an experimental feature.", style="bold yellow" 113 | ) 114 | 115 | output_path = None 116 | if output: 117 | output_path = Path(output) 118 | 119 | try: 120 | if type.lower() == "openapi": 121 | # We defer this import as it takes 64ms on an M4 MacBook Pro, 122 | # and is only needed for a single CLI command - not for the main TUI. 123 | from posting.importing.open_api import import_openapi_spec 124 | 125 | spec_type = "OpenAPI" 126 | collection = import_openapi_spec(spec_path) 127 | if output_path is None: 128 | output_path = ( 129 | Path(default_collection_directory()) / f"{collection.name}" 130 | ) 131 | 132 | output_path.mkdir(parents=True, exist_ok=True) 133 | collection.path = output_path 134 | collection.save_to_disk(output_path) 135 | elif type.lower() == "postman": 136 | from posting.importing.postman import import_postman_spec, create_env_file 137 | 138 | spec_type = "Postman" 139 | collection, postman_collection = import_postman_spec(spec_path, output) 140 | if output_path is None: 141 | output_path = ( 142 | Path(default_collection_directory()) / f"{collection.name}" 143 | ) 144 | 145 | output_path.mkdir(parents=True, exist_ok=True) 146 | collection.path = output_path 147 | collection.save_to_disk(output_path) 148 | 149 | # Create the environment file in the collection directory. 150 | env_file = create_env_file( 151 | output_path, f"{collection.name}.env", postman_collection.variable 152 | ) 153 | console.print(f"Created environment file {str(env_file)!r}.") 154 | else: 155 | console.print(f"Unknown spec type: {type!r}", style="red") 156 | return 157 | 158 | console.print( 159 | f"Successfully imported {spec_type!r} spec to {str(output_path)!r}" 160 | ) 161 | except Exception: 162 | console.print("An error occurred during the import process.", style="red") 163 | console.print( 164 | "Ensure you're importing the correct type of collection.", style="red" 165 | ) 166 | console.print( 167 | "For Postman collections, use `posting import --type postman `", 168 | style="red", 169 | ) 170 | console.print( 171 | "No luck? Please include the traceback below in your issue report.", 172 | style="red", 173 | ) 174 | console.print_exception() 175 | 176 | 177 | @cli.command(name="sponsors") 178 | def sponsors() -> None: 179 | """Show the list of sponsors.""" 180 | print("Thanks to everyone below who contributed to the development of Posting 🚀\n") 181 | 182 | # Sponsors are added to the list below, name on the left, description on the right 183 | sponsors = [ 184 | ("Michael Howard", "https://github.com/elithper"), 185 | ] 186 | for sponsor in sponsors: 187 | print(f"{sponsor[0]} - {sponsor[1]}") 188 | 189 | print() 190 | print("If you'd like to sponsor the development of Posting, please visit:") 191 | print("https://github.com/sponsors/darrenburns") 192 | 193 | 194 | def make_posting( 195 | collection: Path, 196 | env: tuple[str, ...] = (), 197 | using_default_collection: bool = False, 198 | ) -> Posting: 199 | """Return a Posting instance with the given collection and environment.""" 200 | collection_tree = Collection.from_directory(str(collection.resolve())) 201 | 202 | # if env empty then load current directory posting.env file if it exists 203 | if not env and os.path.exists("posting.env"): 204 | env = ("posting.env",) 205 | 206 | env_paths = tuple(Path(e).resolve() for e in env) 207 | settings = Settings(_env_file=env_paths) # type: ignore[call-arg] 208 | load_variables(env_paths, settings.use_host_environment) 209 | 210 | return Posting(settings, env_paths, collection_tree, not using_default_collection) 211 | 212 | 213 | if __name__ == "__main__": 214 | cli() 215 | -------------------------------------------------------------------------------- /src/posting/_start_time.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | START_TIME = time.perf_counter_ns() 5 | -------------------------------------------------------------------------------- /src/posting/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import httpx 4 | 5 | 6 | class HttpxBearerTokenAuth(httpx.Auth): 7 | def __init__(self, token: str): 8 | self.token = token 9 | 10 | def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: 11 | request.headers["Authorization"] = f"Bearer {self.token}" 12 | yield request 13 | -------------------------------------------------------------------------------- /src/posting/exit_codes.py: -------------------------------------------------------------------------------- 1 | GENERAL_ERROR = 1 2 | -------------------------------------------------------------------------------- /src/posting/files.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from posting.save_request import FILE_SUFFIX 4 | 5 | 6 | import os 7 | import re 8 | 9 | 10 | def is_valid_filename(filename: str) -> bool: 11 | # Check if the filename is empty or None 12 | if not filename or filename.strip() == "": 13 | return False 14 | 15 | # Ensure the filename doesn't contain path separators 16 | if os.path.sep in filename or (os.path.altsep and os.path.altsep in filename): 17 | return False 18 | 19 | # Check if the filename is too long (255 characters is a common limit) 20 | if len(filename) > 255: 21 | return False 22 | 23 | # Check for reserved names (Windows) 24 | reserved_names = [ 25 | "CON", 26 | "PRN", 27 | "AUX", 28 | "NUL", 29 | "COM1", 30 | "COM2", 31 | "COM3", 32 | "COM4", 33 | "COM5", 34 | "COM6", 35 | "COM7", 36 | "COM8", 37 | "COM9", 38 | "LPT1", 39 | "LPT2", 40 | "LPT3", 41 | "LPT4", 42 | "LPT5", 43 | "LPT6", 44 | "LPT7", 45 | "LPT8", 46 | "LPT9", 47 | ] 48 | name_without_ext = os.path.splitext(filename)[0].upper() 49 | if name_without_ext in reserved_names: 50 | return False 51 | 52 | if ( 53 | re.search(r"\.\.", filename) 54 | or filename.startswith(".") 55 | or filename.endswith(".") 56 | ): 57 | return False 58 | 59 | return True 60 | 61 | 62 | def get_request_file_stem_and_suffix(file_name: str) -> tuple[str, str]: 63 | if file_name.endswith(FILE_SUFFIX) or file_name.endswith("posting.yml"): 64 | file_name_parts = file_name.split(".") 65 | file_suffix = ".".join(file_name_parts[-2:]) 66 | file_stem = ".".join(file_name_parts[:-2]) 67 | else: 68 | raise ValueError(f"Not a request file: {file_name}") 69 | return file_stem, file_suffix 70 | 71 | 72 | def request_file_exists(file_name: str, parent_directory: Path) -> bool: 73 | """Return True if a file with the same stem exists in the given parent directory, otherwise False. 74 | 75 | Args: 76 | file_name (str): The name of the file to check for. 77 | parent_directory (Path): The parent directory to check in. 78 | 79 | Returns: 80 | bool: True if a file with the same stem exists in the given parent directory, otherwise False. 81 | """ 82 | for path in parent_directory.iterdir(): 83 | if not path.name.endswith(FILE_SUFFIX) or path.name.endswith("posting.yml"): 84 | continue 85 | 86 | stem = path.stem 87 | i = stem.rfind(".") 88 | if 0 < i < len(stem) - 1: 89 | name = stem[:i] 90 | file_stem, _ = get_request_file_stem_and_suffix(file_name) 91 | if name == file_stem: 92 | return True 93 | else: 94 | continue 95 | 96 | return False 97 | 98 | 99 | def get_unique_request_filename(file_name: str, parent_directory: Path) -> str: 100 | """ 101 | Generate a unique filename by appending a number if the file already exists. 102 | 103 | Args: 104 | file_name (str): The original filename. 105 | parent_directory (Path): The directory where the file will be saved. 106 | 107 | Returns: 108 | str: A unique filename with a number appended if necessary. 109 | """ 110 | if not request_file_exists(file_name, parent_directory): 111 | return file_name 112 | 113 | file_stem, _ = get_request_file_stem_and_suffix(file_name) 114 | 115 | # Check for existing numbered files to determine pad width and next number 116 | existing_files = set(parent_directory.glob(f"{file_stem}*posting.yaml")) | set( 117 | parent_directory.glob(f"{file_stem}*posting.yml") 118 | ) 119 | existing_file_names = {f.name for f in existing_files} 120 | 121 | # Keep checking candidate file names until we find a unique one. 122 | candidate_name = file_name 123 | while candidate_name in existing_file_names: 124 | # get the highest number 125 | try: 126 | candidate_stem, _ = get_request_file_stem_and_suffix(candidate_name) 127 | except ValueError: 128 | # Not a request file. 129 | continue 130 | 131 | split_candidate_stem = candidate_stem.rsplit("-", 1) 132 | if len(split_candidate_stem) == 2: 133 | try: 134 | width = len(split_candidate_stem[1]) 135 | number = int(split_candidate_stem[1]) 136 | except ValueError: 137 | width = 2 138 | number = 0 139 | else: 140 | width = 2 141 | number = 0 142 | 143 | number = int(number) + 1 144 | candidate_name = f"{file_stem}-{number:0{width}d}.posting.yaml" 145 | 146 | return candidate_name 147 | -------------------------------------------------------------------------------- /src/posting/help_data.py: -------------------------------------------------------------------------------- 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""" 12 | -------------------------------------------------------------------------------- /src/posting/help_screen.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, runtime_checkable 2 | from rich.text import Text 3 | from textual.app import ComposeResult 4 | from textual.binding import Binding 5 | from textual.containers import Vertical, VerticalScroll 6 | from textual.screen import ModalScreen 7 | from textual.widget import Widget 8 | from textual.widgets import Label, Markdown 9 | 10 | from posting.help_data import HelpData 11 | from posting.widgets.datatable import PostingDataTable 12 | 13 | 14 | @runtime_checkable 15 | class Helpable(Protocol): 16 | """Widgets which contain information to be displayed in the HelpScreen 17 | should implement this protocol.""" 18 | 19 | help: HelpData 20 | 21 | 22 | class HelpModalHeader(Label): 23 | """The top help bar""" 24 | 25 | DEFAULT_CSS = """ 26 | HelpModalHeader { 27 | background: $background-lighten-1; 28 | color: $text-muted; 29 | } 30 | """ 31 | 32 | 33 | class HelpModalFooter(Label): 34 | """The bottom help bar""" 35 | 36 | DEFAULT_CSS = """ 37 | HelpModalFooter { 38 | background: $background-lighten-1; 39 | color: $text-muted; 40 | } 41 | """ 42 | 43 | 44 | class HelpModalFocusNote(Label): 45 | """A note below the help screen.""" 46 | 47 | 48 | class HelpScreen(ModalScreen[None]): 49 | DEFAULT_CSS = """ 50 | HelpScreen { 51 | align: center middle; 52 | & > VerticalScroll { 53 | background: $background; 54 | padding: 1 2; 55 | width: 65%; 56 | height: 80%; 57 | border: wide $background-lighten-2; 58 | border-title-color: $text; 59 | border-title-background: $background; 60 | border-title-style: bold; 61 | } 62 | 63 | & DataTable#bindings-table { 64 | width: 1fr; 65 | height: 1fr; 66 | } 67 | 68 | & HelpModalHeader { 69 | dock: top; 70 | width: 1fr; 71 | content-align: center middle; 72 | } 73 | 74 | #footer-area { 75 | dock: bottom; 76 | height: auto; 77 | margin-top: 1; 78 | & HelpModalFocusNote { 79 | width: 1fr; 80 | content-align: center middle; 81 | color: $text-muted 40%; 82 | } 83 | 84 | & HelpModalFooter { 85 | width: 1fr; 86 | content-align: center middle; 87 | } 88 | } 89 | 90 | 91 | & #bindings-title { 92 | width: 1fr; 93 | content-align: center middle; 94 | background: $background-lighten-1; 95 | color: $text-muted; 96 | } 97 | 98 | & #help-description-wrapper { 99 | dock: top; 100 | max-height: 50%; 101 | margin-top: 1; 102 | height: auto; 103 | width: 1fr; 104 | & #help-description { 105 | margin: 0; 106 | width: 1fr; 107 | height: auto; 108 | } 109 | } 110 | } 111 | """ 112 | 113 | BINDINGS = [ 114 | Binding("escape", "dismiss('')", "Close Help"), 115 | ] 116 | 117 | def __init__( 118 | self, 119 | widget: Widget, 120 | name: str | None = None, 121 | id: str | None = None, 122 | classes: str | None = None, 123 | ) -> None: 124 | super().__init__(name, id, classes) 125 | self.widget = widget 126 | 127 | def compose(self) -> ComposeResult: 128 | with VerticalScroll() as vs: 129 | vs.can_focus = False 130 | widget = self.widget 131 | # If the widget has help text, render it. 132 | if isinstance(widget, Helpable): 133 | help = widget.help 134 | help_title = help.title 135 | vs.border_title = f"[not bold]Focused Widget Help ([b]{help_title}[/])" 136 | if help_title: 137 | yield HelpModalHeader(f"[b]{help_title}[/]") 138 | help_markdown = help.description 139 | 140 | if help_markdown: 141 | help_markdown = help_markdown.strip() 142 | with VerticalScroll(id="help-description-wrapper") as vs: 143 | yield Markdown(help_markdown, id="help-description") 144 | else: 145 | yield Label( 146 | f"No help available for {help.title}", 147 | id="help-description", 148 | ) 149 | else: 150 | name = widget.__class__.__name__ 151 | vs.border_title = f"Focused Widget Help ([b]{name}[/])" 152 | yield HelpModalHeader(f"[b]{name}[/] Help") 153 | 154 | bindings = widget._bindings 155 | keys: list[tuple[str, list[Binding]]] = list( 156 | bindings.key_to_bindings.items() 157 | ) 158 | 159 | if keys: 160 | yield Label(" [b]All Keybindings[/]", id="bindings-title") 161 | table = PostingDataTable( 162 | id="bindings-table", 163 | cursor_type="row", 164 | zebra_stripes=True, 165 | ) 166 | table.cursor_vertical_escape = False 167 | table.add_columns("Key", "Description") 168 | for _key, bindings in keys: 169 | table.add_row( 170 | Text( 171 | ", ".join( 172 | binding.key_display 173 | if binding.key_display 174 | else self.app.get_key_display(binding) 175 | for binding in bindings 176 | ), 177 | style="bold", 178 | no_wrap=True, 179 | end="", 180 | ), 181 | bindings[0].description.lower(), 182 | ) 183 | yield table 184 | 185 | with Vertical(id="footer-area"): 186 | yield HelpModalFooter("Press [b]ESC[/] to dismiss.") 187 | yield HelpModalFocusNote( 188 | "[b]Note:[/] This page relates to the widget that is currently focused." 189 | ) 190 | -------------------------------------------------------------------------------- /src/posting/highlight_url.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/src/posting/highlight_url.py -------------------------------------------------------------------------------- /src/posting/highlighters.py: -------------------------------------------------------------------------------- 1 | import re 2 | from rich.highlighter import Highlighter 3 | from rich.style import Style 4 | from rich.text import Text 5 | from textual.widgets import Input 6 | from posting.themes import UrlStyles, VariableStyles 7 | 8 | from posting.variables import ( 9 | find_variable_end, 10 | find_variable_start, 11 | find_variables, 12 | get_variables, 13 | is_cursor_within_variable, 14 | ) 15 | 16 | 17 | _URL_REGEX = re.compile(r"(?Phttps?)://(?P[^/]+)(?P/[^ ]*)?") 18 | 19 | 20 | def highlight_url(text: Text, styles: UrlStyles) -> None: 21 | for match in _URL_REGEX.finditer(text.plain): 22 | protocol_start, protocol_end = match.span("protocol") 23 | base_start, base_end = match.span("base") 24 | separator_start, separator_end = protocol_end, protocol_end + 3 25 | 26 | text.stylize(styles.protocol or "#818cf8", protocol_start, protocol_end) 27 | text.stylize(styles.separator or "dim b", separator_start, separator_end) 28 | text.stylize(styles.base or "#00C168", base_start, base_end) 29 | 30 | for index, char in enumerate(text.plain): 31 | if char == "/": 32 | text.stylize(styles.separator or "dim b", index, index + 1) 33 | 34 | 35 | def highlight_variables(text: Text, styles: VariableStyles) -> None: 36 | for match in find_variables(text.plain): 37 | variable_name, start, end = match 38 | if variable_name not in get_variables(): 39 | text.stylize(Style.parse(styles.unresolved or "dim"), start, end) 40 | else: 41 | text.stylize(Style.parse(styles.resolved or ""), start, end) 42 | 43 | 44 | class VariableHighlighter(Highlighter): 45 | def __init__(self, variable_styles: VariableStyles | None = None) -> None: 46 | super().__init__() 47 | self.variable_styles = variable_styles 48 | 49 | def highlight(self, text: Text) -> None: 50 | if self.variable_styles is None: 51 | return 52 | highlight_variables(text, self.variable_styles) 53 | 54 | 55 | class VariablesAndUrlHighlighter(Highlighter): 56 | def __init__(self, input: Input) -> None: 57 | super().__init__() 58 | self.input = input 59 | self.variable_styles: VariableStyles = VariableStyles() 60 | self.url_styles: UrlStyles = UrlStyles() 61 | 62 | def highlight(self, text: Text) -> None: 63 | if text.plain == "": 64 | return 65 | 66 | highlight_url(text, self.url_styles) 67 | highlight_variables(text, self.variable_styles) 68 | 69 | input = self.input 70 | cursor_position = input.cursor_position # type:ignore 71 | value: str = input.value 72 | 73 | if is_cursor_within_variable(cursor_position, value): # type: ignore 74 | start = find_variable_start(cursor_position, value) # type: ignore 75 | end = find_variable_end(cursor_position, value) # type: ignore 76 | text.stylize("u", start, end) 77 | -------------------------------------------------------------------------------- /src/posting/importing/postman.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | import json 5 | import re 6 | from urllib.parse import urlparse, urlunparse 7 | 8 | from pydantic import BaseModel, Field 9 | 10 | from rich.console import Console 11 | 12 | from posting.collection import ( 13 | APIInfo, 14 | Collection, 15 | FormItem, 16 | Header, 17 | QueryParam, 18 | RequestBody, 19 | RequestModel, 20 | HttpRequestMethod, 21 | ) 22 | 23 | 24 | class Variable(BaseModel): 25 | key: str 26 | value: str | None = None 27 | src: str | list[str] | None = None 28 | fileNotInWorkingDirectoryWarning: str | None = None 29 | filesNotInWorkingDirectory: list[str] | None = None 30 | type: str | None = None 31 | disabled: bool | None = None 32 | 33 | 34 | class RawRequestOptions(BaseModel): 35 | language: str 36 | 37 | 38 | class RequestOptions(BaseModel): 39 | raw: RawRequestOptions 40 | 41 | 42 | class Body(BaseModel): 43 | mode: str 44 | options: RequestOptions | None = None 45 | raw: str | None = None 46 | formdata: list[Variable] | None = None 47 | 48 | 49 | class Url(BaseModel): 50 | raw: str 51 | host: list[str] | None = None 52 | path: list[str] | None = None 53 | query: list[Variable] | None = None 54 | 55 | 56 | class PostmanRequest(BaseModel): 57 | method: HttpRequestMethod 58 | url: str | Url | None = None 59 | header: list[Variable] | None = None 60 | description: str | None = None 61 | body: Body | None = None 62 | 63 | 64 | class RequestItem(BaseModel): 65 | name: str 66 | item: list["RequestItem"] | None = None 67 | request: PostmanRequest | None = None 68 | 69 | 70 | class PostmanCollection(BaseModel): 71 | info: dict[str, str] = Field(default_factory=dict) 72 | variable: list[Variable] = Field(default_factory=list) 73 | item: list[RequestItem] 74 | 75 | 76 | # Converts variable names like userId to $USER_ID, or user-id to $USER_ID 77 | def sanitize_variables(string: str) -> str: 78 | underscore_case = re.sub(r"(? str: 83 | def replace_match(match: re.Match[str]) -> str: 84 | value = match.group(1) 85 | return f"${sanitize_variables(value)}" 86 | 87 | transformed = re.sub(r"\{\{([\w-]+)\}\}", replace_match, string) 88 | return transformed 89 | 90 | 91 | def create_env_file(path: Path, env_filename: str, variables: list[Variable]) -> Path: 92 | env_content: list[str] = [] 93 | 94 | for var in variables: 95 | env_content.append(f"{sanitize_variables(var.key)}={var.value}") 96 | 97 | env_file = path / env_filename 98 | env_file.write_text("\n".join(env_content)) 99 | return env_file 100 | 101 | 102 | def format_request(name: str, request: PostmanRequest) -> RequestModel: 103 | # Extract the raw URL first 104 | raw_url_with_query: str = "" 105 | if request.url is not None: 106 | raw_url_with_query = ( 107 | request.url.raw if isinstance(request.url, Url) else request.url 108 | ) 109 | 110 | # Parse the URL and remove query parameters 111 | parsed_url = urlparse(raw_url_with_query) 112 | # Reconstruct the URL without the query string 113 | url_without_query = urlunparse( 114 | ( 115 | parsed_url.scheme, 116 | parsed_url.netloc, 117 | parsed_url.path, 118 | parsed_url.params, # Keep fragment/params if they exist 119 | "", # Empty query string 120 | parsed_url.fragment, 121 | ) 122 | ) 123 | sanitized_url = sanitize_str(url_without_query) 124 | 125 | posting_request = RequestModel( 126 | name=name, 127 | method=request.method, 128 | description=request.description if request.description is not None else "", 129 | url=sanitized_url, 130 | ) 131 | 132 | if request.header is not None: 133 | for header in request.header: 134 | posting_request.headers.append( 135 | Header( 136 | name=header.key, 137 | value=header.value if header.value is not None else "", 138 | enabled=True, 139 | ) 140 | ) 141 | 142 | # Add query params to the request (they've been removed from the URL) 143 | if ( 144 | request.url is not None 145 | and isinstance(request.url, Url) 146 | and request.url.query is not None 147 | ): 148 | for param in request.url.query: 149 | posting_request.params.append( 150 | QueryParam( 151 | name=param.key, 152 | value=param.value if param.value is not None else "", 153 | enabled=param.disabled if param.disabled is not None else True, 154 | ) 155 | ) 156 | 157 | if request.body is not None and request.body.raw is not None: 158 | if ( 159 | request.body.mode == "raw" 160 | and request.body.options is not None 161 | and request.body.options.raw.language == "json" 162 | ): 163 | posting_request.body = RequestBody(content=sanitize_str(request.body.raw)) 164 | elif request.body.mode == "formdata" and request.body.formdata is not None: 165 | form_data: list[FormItem] = [ 166 | FormItem( 167 | name=data.key, 168 | value=data.value if data.value is not None else "", 169 | enabled=data.disabled is False, 170 | ) 171 | for data in request.body.formdata 172 | ] 173 | posting_request.body = RequestBody(form_data=form_data) 174 | 175 | return posting_request 176 | 177 | 178 | def process_item( 179 | item: RequestItem, parent_collection: Collection, base_path: Path 180 | ) -> None: 181 | if item.item is not None: 182 | # This is a folder - create a subcollection 183 | child_path = base_path / item.name 184 | child_collection = Collection(path=child_path, name=item.name) 185 | parent_collection.children.append(child_collection) 186 | 187 | # Process items in this folder 188 | for sub_item in item.item: 189 | process_item(sub_item, child_collection, child_path) 190 | 191 | if item.request is not None: 192 | # This is a request - add it to the current collection 193 | file_name = "".join( 194 | word.capitalize() 195 | for word in re.sub(r"[^A-Za-z0-9\.]+", " ", item.name).split() 196 | ) 197 | request = format_request(item.name, item.request) 198 | request_path = parent_collection.path / f"{file_name}.posting.yaml" 199 | request.path = request_path 200 | parent_collection.requests.append(request) 201 | 202 | 203 | def import_postman_spec( 204 | spec_path: str | Path, output_path: str | Path | None 205 | ) -> tuple[Collection, PostmanCollection]: 206 | """Import a Postman collection from a file and save it to disk.""" 207 | console = Console() 208 | console.print(f"Importing Postman spec from {spec_path!r}.") 209 | 210 | spec_path = Path(spec_path) 211 | with open(spec_path, "r") as file: 212 | spec_dict = json.load(file) 213 | 214 | spec = PostmanCollection(**spec_dict) 215 | 216 | info = APIInfo( 217 | title=spec.info["name"], 218 | description=spec.info.get("description", "No description"), 219 | specSchema=spec.info["schema"], 220 | version="2.0.0", 221 | ) 222 | 223 | base_dir = spec_path.parent 224 | if output_path is not None: 225 | base_dir = Path(output_path) if isinstance(output_path, str) else output_path 226 | 227 | base_dir.mkdir(parents=True, exist_ok=True) 228 | main_collection = Collection(path=base_dir, name=info.title) 229 | main_collection.readme = main_collection.generate_readme(info) 230 | 231 | for item in spec.item: 232 | process_item(item, main_collection, base_dir) 233 | 234 | return main_collection, spec 235 | -------------------------------------------------------------------------------- /src/posting/jump_overlay.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from textual import events, log 3 | from textual.app import ComposeResult 4 | from textual.binding import Binding 5 | from textual.containers import Center 6 | from textual.screen import ModalScreen 7 | from textual.widget import Widget 8 | from textual.widgets import Label 9 | import asyncio 10 | import time 11 | 12 | if TYPE_CHECKING: 13 | from posting.jumper import Jumper 14 | 15 | 16 | class JumpOverlay(ModalScreen[str | Widget | None]): 17 | """Overlay showing the jump targets. 18 | Dismissed with the ID of the widget the jump was requested for on closing, 19 | or a reference to the widget. Is dismissed with None if the user dismissed 20 | the overlay without making a selection.""" 21 | 22 | DEFAULT_CSS = """\ 23 | JumpOverlay { 24 | background: black 25%; 25 | } 26 | """ 27 | 28 | BINDINGS = [ 29 | Binding("escape", "dismiss_overlay", "Dismiss", show=False), 30 | ] 31 | 32 | def __init__( 33 | self, 34 | jumper: "Jumper", 35 | name: str | None = None, 36 | id: str | None = None, 37 | classes: str | None = None, 38 | ) -> None: 39 | super().__init__(name=name, id=id, classes=classes) 40 | self.jumper: Jumper = jumper 41 | self.keys_to_widgets: dict[str, Widget | str] = {} 42 | self._resize_counter = 0 43 | self._last_resize_time: float = 0 44 | self._debounce_running: bool = False 45 | 46 | def on_key(self, key_event: events.Key) -> None: 47 | # We need to stop the bubbling of these keys, because if they 48 | # arrive at the parent after the overlay is closed, then the parent 49 | # will handle the key event, resulting in the focus being shifted 50 | # again (unexpectedly) after the jump target was focused. 51 | log.debug(f"Handling jump key press: {key_event.key}") 52 | if key_event.key == "tab" or key_event.key == "shift+tab": 53 | key_event.stop() 54 | key_event.prevent_default() 55 | 56 | if self.is_active: 57 | # If they press a key corresponding to a jump target, 58 | # then we jump to it. 59 | target = self.keys_to_widgets.get(key_event.key) 60 | if target is not None: 61 | self.dismiss(target) 62 | return 63 | 64 | def action_dismiss_overlay(self) -> None: 65 | self.dismiss(None) 66 | 67 | async def on_resize(self) -> None: 68 | self._resize_counter += 1 69 | if self._resize_counter == 1: 70 | return 71 | 72 | # Update the last resize time 73 | self._last_resize_time = time.time() 74 | 75 | # Start the debounce task if it's not already running 76 | if not self._debounce_running: 77 | self._debounce_running = True 78 | asyncio.create_task(self._debounced_recompose()) 79 | 80 | async def on_unmount(self) -> None: 81 | # Nothing to cancel since we're using a different approach 82 | self._debounce_running = False 83 | 84 | async def _debounced_recompose(self) -> None: 85 | try: 86 | while self._debounce_running: 87 | # Get the current time 88 | current_time = time.time() 89 | # Calculate time since last resize 90 | time_since_last_resize = current_time - self._last_resize_time 91 | 92 | if time_since_last_resize >= 0.05: 93 | await self.recompose() 94 | break 95 | 96 | # Otherwise, wait a bit and check again 97 | await asyncio.sleep(0.1) 98 | 99 | self._debounce_running = False 100 | except asyncio.CancelledError: 101 | self._debounce_running = False 102 | 103 | def _sync(self) -> None: 104 | self.overlays = self.jumper.get_overlays() 105 | self.keys_to_widgets = {v.key: v.widget for v in self.overlays.values()} 106 | 107 | def compose(self) -> ComposeResult: 108 | self._sync() 109 | for offset, jump_info in self.overlays.items(): 110 | key, _widget = jump_info 111 | label = Label(key, classes="textual-jump-label") 112 | x, y = offset 113 | label.styles.margin = y, x 114 | yield label 115 | with Center(id="textual-jump-info"): 116 | yield Label("Press a key to jump") 117 | with Center(id="textual-jump-dismiss"): 118 | yield Label("[b]ESC[/] to dismiss") 119 | -------------------------------------------------------------------------------- /src/posting/jumper.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Mapping, NamedTuple, Protocol, runtime_checkable 2 | from textual.errors import NoWidget 3 | 4 | from textual.geometry import Offset 5 | from textual.screen import Screen 6 | from textual.widget import Widget 7 | 8 | 9 | @runtime_checkable 10 | class Jumpable(Protocol): 11 | """A widget which we can jump focus to.""" 12 | 13 | jump_key: str 14 | 15 | 16 | class JumpInfo(NamedTuple): 17 | """Information returned by the jumper for each jump target.""" 18 | 19 | key: str 20 | """The key which should trigger the jump.""" 21 | 22 | widget: str | Widget 23 | """Either the ID or a direct reference to the widget.""" 24 | 25 | 26 | class Jumper: 27 | """An Amp-like jumping mechanism for quick spatial navigation""" 28 | 29 | def __init__(self, ids_to_keys: Mapping[str, str], screen: Screen[Any]) -> None: 30 | self.ids_to_keys = ids_to_keys 31 | self.keys_to_ids = {v: k for k, v in ids_to_keys.items()} 32 | self.screen = screen 33 | 34 | def get_overlays(self) -> dict[Offset, JumpInfo]: 35 | """Return a dictionary of all the jump targets""" 36 | screen = self.screen 37 | children: list[Widget] = screen.walk_children(Widget) 38 | overlays: dict[Offset, JumpInfo] = {} 39 | ids_to_keys = self.ids_to_keys 40 | for child in children: 41 | try: 42 | widget_x, widget_y = screen.get_offset(child) 43 | except NoWidget: 44 | # The widget might not be visible in the layout 45 | # due to it being hidden in some modes. 46 | continue 47 | 48 | widget_offset = Offset(widget_x, widget_y) 49 | if child.id and child.id in ids_to_keys: 50 | overlays[widget_offset] = JumpInfo( 51 | ids_to_keys[child.id], 52 | child.id, 53 | ) 54 | elif isinstance(child, Jumpable): 55 | overlays[widget_offset] = JumpInfo( 56 | child.jump_key, 57 | child, 58 | ) 59 | 60 | return overlays 61 | -------------------------------------------------------------------------------- /src/posting/locations.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from xdg_base_dirs import xdg_config_home, xdg_data_home 4 | 5 | 6 | def _posting_directory(root: Path) -> Path: 7 | directory = root / "posting" 8 | directory.mkdir(exist_ok=True, parents=True) 9 | return directory 10 | 11 | 12 | def data_directory() -> Path: 13 | """Return (possibly creating) the application data directory.""" 14 | return _posting_directory(xdg_data_home()) 15 | 16 | 17 | def theme_directory() -> Path: 18 | """Return (possibly creating) the themes directory.""" 19 | theme_dir = data_directory() / "themes" 20 | theme_dir.mkdir(exist_ok=True, parents=True) 21 | return theme_dir 22 | 23 | 24 | def default_collection_directory() -> Path: 25 | """Return (possibly creating) the default collection directory.""" 26 | return data_directory() / "default" 27 | 28 | 29 | def config_directory() -> Path: 30 | """Return (possibly creating) the application config directory.""" 31 | return _posting_directory(xdg_config_home()) 32 | 33 | 34 | def config_file() -> Path: 35 | return config_directory() / "config.yaml" 36 | -------------------------------------------------------------------------------- /src/posting/messages.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from httpx import Response 3 | from textual.message import Message 4 | 5 | 6 | @dataclass 7 | class HttpResponseReceived(Message): 8 | response: Response 9 | -------------------------------------------------------------------------------- /src/posting/save_request.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import re 3 | 4 | 5 | FILE_SUFFIX = ".posting.yaml" 6 | 7 | 8 | def slugify(text: str) -> str: 9 | """Slugify a string.""" 10 | text = text.lower() 11 | text = re.sub(r"[^a-z0-9]+", "-", text) 12 | return text.strip("-") 13 | 14 | 15 | def generate_request_filename(request_title: str) -> str: 16 | """Generate a filename for a request, NOT including the file suffix.""" 17 | return slugify(request_title) 18 | -------------------------------------------------------------------------------- /src/posting/scripts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from pathlib import Path 5 | from types import ModuleType 6 | from typing import TYPE_CHECKING, Callable, Any 7 | import threading 8 | 9 | from httpx import Response 10 | from textual.notifications import SeverityLevel 11 | 12 | from posting.collection import RequestModel 13 | from posting.variables import get_variables, update_variables 14 | 15 | if TYPE_CHECKING: 16 | from posting.app import Posting as PostingApp 17 | 18 | # Global cache for loaded modules 19 | _MODULE_CACHE: dict[str, ModuleType] = {} 20 | _CACHE_LOCK = threading.Lock() 21 | 22 | 23 | class Posting: 24 | """A class that provides access to Posting's API from within a script.""" 25 | 26 | def __init__(self, app: PostingApp): 27 | self._app: PostingApp = app 28 | """The Textual App instance for Posting.""" 29 | 30 | self.request: RequestModel | None = None 31 | """The request that is currently being processed.""" 32 | 33 | self.response: Response | None = None 34 | """The response received, if it's available.""" 35 | 36 | @property 37 | def variables(self) -> dict[str, object]: 38 | """Get the variables available in the environment. 39 | 40 | This includes variables loaded from the environment and 41 | any variables that have been set in scripts for this session. 42 | """ 43 | return get_variables() 44 | 45 | def get_variable(self, name: str, default: object | None = None) -> object | None: 46 | """Get a session variable. This doesn't include variables set via environment files 47 | or env vars. 48 | 49 | Use the `variables` property to get a dict containing all 50 | variables, including those set via environment files or 51 | env vars. 52 | 53 | Args: 54 | name: The name of the variable to get. 55 | default: The default value to return if the variable is not found. 56 | """ 57 | return self._app.session_env.get(name, default) 58 | 59 | def set_variable(self, name: str, value: object) -> None: 60 | """Set a session variable, which persists until the app shuts 61 | down, and overrides any variables loaded from the environment 62 | with the same name. 63 | 64 | Args: 65 | name: The name of the variable to set. 66 | value: The value of the variable to set. 67 | """ 68 | self._app.session_env[name] = value 69 | update_variables(self._app.session_env) 70 | 71 | def clear_variable(self, name: str) -> None: 72 | """Clear a session variable. 73 | 74 | Args: 75 | name: The name of the variable to clear. 76 | """ 77 | if name in self._app.session_env: 78 | del self._app.session_env[name] 79 | update_variables(self._app.session_env) 80 | 81 | def clear_all_variables(self) -> None: 82 | """Clear all session variables.""" 83 | self._app.session_env.clear() 84 | update_variables(self._app.session_env) 85 | 86 | def notify( 87 | self, 88 | message: str, 89 | *, 90 | title: str = "", 91 | severity: SeverityLevel = "information", 92 | timeout: float | None = None, 93 | ): 94 | """Send a toast message, which will appear at the bottom 95 | right corner of Posting. This is useful for grabbing the 96 | user's attention even if they're not directly viewing the 97 | Scripts tab. 98 | 99 | Args: 100 | message: The message to display in the toast body. 101 | title: The title of the toast. 102 | severity: The severity of the message. 103 | timeout: Number of seconds the toast will be displayed for. 104 | """ 105 | self._app.notify( 106 | message=message, 107 | title=title, 108 | severity=severity, 109 | timeout=timeout, 110 | ) 111 | 112 | 113 | def clear_module_cache(): 114 | """ 115 | Clear the global module cache in a thread-safe manner. 116 | """ 117 | with _CACHE_LOCK: 118 | _MODULE_CACHE.clear() 119 | 120 | 121 | def execute_script( 122 | collection_root: Path, script_path: Path, function_name: str 123 | ) -> Callable[..., Any] | None: 124 | """ 125 | Execute a Python script from the given path and extract a specified function. 126 | Uses caching to prevent multiple executions of the same script. 127 | 128 | Args: 129 | collection_root: Path to the root of the collection. 130 | script_path: Path to the Python script file, relative to the collection root. 131 | function_name: Name of the function to extract from the script. 132 | 133 | Returns: 134 | The extracted function if found, None otherwise. 135 | 136 | Raises: 137 | FileNotFoundError: If the script file does not exist or is outside the collection. 138 | Exception: If there's an error during script execution. 139 | """ 140 | # Ensure the script_path is relative to the collection root 141 | full_script_path = (collection_root / script_path).resolve() 142 | 143 | # Check if the script is within the collection root 144 | if not full_script_path.is_relative_to(collection_root): 145 | raise FileNotFoundError( 146 | f"Script path {script_path} is outside the collection root" 147 | ) 148 | 149 | if not full_script_path.is_file(): 150 | raise FileNotFoundError(f"Script not found: {full_script_path}") 151 | 152 | script_dir = full_script_path.parent 153 | module_name = full_script_path.stem 154 | module_key = str(full_script_path) 155 | 156 | try: 157 | sys.path.insert(0, str(script_dir)) 158 | module = _import_script_as_module(full_script_path, module_name, module_key) 159 | return _validate_function(getattr(module, function_name, None)) 160 | finally: 161 | sys.path.remove(str(script_dir)) 162 | 163 | 164 | def _import_script_as_module( 165 | script_path: Path, module_name: str, module_key: str 166 | ) -> ModuleType: 167 | """Import the script file as a module, using cache if available.""" 168 | with _CACHE_LOCK: 169 | if module_key in _MODULE_CACHE: 170 | return _MODULE_CACHE[module_key] 171 | 172 | import importlib.util 173 | 174 | spec = importlib.util.spec_from_file_location(module_name, script_path) 175 | if spec is None: 176 | raise ImportError( 177 | f"Could not load spec for module {module_name} from {script_path}" 178 | ) 179 | 180 | module = importlib.util.module_from_spec(spec) 181 | if spec.loader is None: 182 | raise ImportError(f"Could not load module {module_name} from {script_path}") 183 | 184 | spec.loader.exec_module(module) 185 | with _CACHE_LOCK: 186 | _MODULE_CACHE[module_key] = module 187 | 188 | return module 189 | 190 | 191 | def _validate_function(func: Any) -> Callable[..., Any] | None: 192 | """ 193 | Validate that the provided object is a callable function. 194 | 195 | Args: 196 | func: The object to validate. 197 | 198 | Returns: 199 | The function if it's callable, None otherwise. 200 | """ 201 | return func if callable(func) else None 202 | 203 | 204 | def uncache_module(script_path: str) -> None: 205 | """ 206 | Clear a specific module from the global module cache. 207 | 208 | Args: 209 | script_path: Path to the script file. 210 | """ 211 | module_key = str(script_path) 212 | with _CACHE_LOCK: 213 | if module_key in _MODULE_CACHE: 214 | del _MODULE_CACHE[module_key] 215 | -------------------------------------------------------------------------------- /src/posting/suggesters.py: -------------------------------------------------------------------------------- 1 | # Maps lowercased HTTP headers to lists of suggestions for the header's value. 2 | HEADER_SUGGESTION_LISTS = { 3 | "content-type": [ 4 | "text/", 5 | "text/html", 6 | "application/", 7 | "application/json", 8 | "application/xml", 9 | "application/x-www-form-urlencoded", 10 | "multipart/form-data", 11 | "image/", 12 | "image/png", 13 | "image/jpeg", 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /src/posting/tuple_to_multidict.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from collections import defaultdict 3 | from typing import TypeVar 4 | 5 | K = TypeVar("K") 6 | V = TypeVar("V") 7 | 8 | 9 | def tuples_to_dict(tuple_list: list[tuple[K, V]]) -> dict[K, list[V]]: 10 | result: dict[K, list[V]] = defaultdict(list) 11 | for key, value in tuple_list: 12 | result[key].append(value) 13 | return result 14 | -------------------------------------------------------------------------------- /src/posting/types.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Optional, Tuple, Union 2 | 3 | PostingLayout = Literal["horizontal", "vertical"] 4 | 5 | # From httpx - seems to not be public. 6 | CertTypes = Union[ 7 | # certfile 8 | str, 9 | # (certfile, keyfile) 10 | Tuple[str, Optional[str]], 11 | # (certfile, keyfile, password) 12 | Tuple[str, Optional[str], Optional[str]], 13 | ] 14 | -------------------------------------------------------------------------------- /src/posting/urls.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | def ensure_protocol(url: str) -> str: 4 | """Default the protocol to http:// if no protocol is present. 5 | 6 | Args: 7 | url: The URL to ensure has a protocol. 8 | 9 | Returns: 10 | The URL with a the http:// protocol if no protocol was present, 11 | otherwise the original URL. 12 | """ 13 | if re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', url): 14 | return url 15 | return f"http://{url}" -------------------------------------------------------------------------------- /src/posting/user_host.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import socket 3 | 4 | from rich.text import Text 5 | 6 | from posting.config import SETTINGS 7 | 8 | 9 | def get_user_host_string() -> Text: 10 | try: 11 | username = getpass.getuser() 12 | hostname = SETTINGS.get().heading.hostname or socket.gethostname() 13 | return Text.from_markup(f"{username}@{hostname}") 14 | except Exception: 15 | return Text("unknown@unknown") 16 | -------------------------------------------------------------------------------- /src/posting/variables.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from functools import lru_cache 3 | 4 | import re 5 | import os 6 | from pathlib import Path 7 | from dotenv import dotenv_values 8 | 9 | 10 | _VARIABLES_PATTERN = re.compile( 11 | r"(?:(?:^|[^\$])(?:\$\$)*)(\$(?:\{([a-zA-Z_]\w*)\}|([a-zA-Z_]\w*)|$))" 12 | ) 13 | 14 | 15 | class SharedVariables: 16 | def __init__(self): 17 | self._variables: dict[str, object] = {} 18 | 19 | def get(self) -> dict[str, object]: 20 | return self._variables.copy() 21 | 22 | def set(self, variables: dict[str, object]) -> None: 23 | self._variables = variables 24 | 25 | def update(self, new_variables: dict[str, object]) -> None: 26 | self._variables.update(new_variables) 27 | 28 | 29 | VARIABLES = SharedVariables() 30 | 31 | 32 | def get_variables() -> dict[str, object]: 33 | return VARIABLES.get() 34 | 35 | 36 | def load_variables( 37 | environment_files: tuple[Path, ...], 38 | use_host_environment: bool, 39 | avoid_cache: bool = False, 40 | ) -> dict[str, object]: 41 | """Load the variables that are currently available in the environment. 42 | 43 | This will likely involve reading from a set of environment files, 44 | but it could also involve reading from the host machine's environment 45 | if `use_host_environment` is True. 46 | 47 | This will make them available via the `get_variables` function. 48 | 49 | Args: 50 | environment_files: The environment files to load variables from. 51 | use_host_environment: Whether to use env vars from the host machine. 52 | avoid_cache: Whether to avoid using cached variables (so do a full lookup). 53 | overlay_variables: Additional variables to overlay on top of the result. 54 | """ 55 | 56 | existing_variables = get_variables() 57 | if existing_variables and not avoid_cache: 58 | return {key: value for key, value in existing_variables.items()} 59 | 60 | variables: dict[str, object] = { 61 | key: value 62 | for file in environment_files 63 | for key, value in dotenv_values(file).items() 64 | } 65 | if use_host_environment: 66 | host_env_variables = {key: value for key, value in os.environ.items()} 67 | variables = {**variables, **host_env_variables} 68 | 69 | VARIABLES.set(variables) 70 | return variables 71 | 72 | 73 | def update_variables(new_variables: dict[str, object]) -> None: 74 | """Update the current variables with new values. 75 | 76 | This function safely updates the shared variables with new key-value pairs. 77 | If a key already exists, its value will be updated. If it doesn't exist, it will be added. 78 | 79 | Args: 80 | new_variables: A dictionary containing the new variables to update or add. 81 | """ 82 | VARIABLES.update(new_variables) 83 | 84 | 85 | @lru_cache() 86 | def find_variables(template_str: str) -> list[tuple[str, int, int]]: 87 | return [ 88 | (m.group(2) or m.group(3), m.start(1), m.end(1)) 89 | for m in re.finditer(_VARIABLES_PATTERN, template_str) 90 | if m.group(2) or m.group(3) 91 | ] 92 | 93 | 94 | @lru_cache() 95 | def variable_range_at_cursor(cursor: int, text: str) -> tuple[int, int] | None: 96 | if not text or cursor < 0 or cursor > len(text): 97 | return None 98 | 99 | for match in _VARIABLES_PATTERN.finditer(text): 100 | start, end = match.span(1) 101 | if start < cursor and ( 102 | cursor < end or not match.group(2) and cursor == end == len(text) 103 | ): 104 | return start, end 105 | return None 106 | 107 | 108 | def is_cursor_within_variable(cursor: int, text: str) -> bool: 109 | return variable_range_at_cursor(cursor, text) is not None 110 | 111 | 112 | def find_variable_start(cursor: int, text: str) -> int: 113 | variable_range = variable_range_at_cursor(cursor, text) 114 | return variable_range[0] if variable_range is not None else cursor 115 | 116 | 117 | def find_variable_end(cursor: int, text: str) -> int: 118 | if not text: 119 | return cursor 120 | 121 | variable_range = variable_range_at_cursor(cursor, text) 122 | return variable_range[1] if variable_range is not None else len(text) 123 | 124 | 125 | def get_variable_at_cursor(cursor: int, text: str) -> str | None: 126 | variable_range = variable_range_at_cursor(cursor, text) 127 | if variable_range is None: 128 | return None 129 | 130 | return text[variable_range[0] : variable_range[1]] 131 | 132 | 133 | @lru_cache() 134 | def extract_variable_name(variable_text: str) -> str: 135 | """ 136 | Extract the variable name from a variable reference. 137 | 138 | Args: 139 | variable_text: The text containing the variable reference. 140 | 141 | Returns: 142 | str: The extracted variable name. 143 | 144 | Examples: 145 | >>> extract_variable_name("$var") 146 | 'var' 147 | >>> extract_variable_name("${MY_VAR}") 148 | 'MY_VAR' 149 | """ 150 | if variable_text.startswith("${") and variable_text.endswith("}"): 151 | return variable_text[2:-1] 152 | elif variable_text.startswith("$"): 153 | return variable_text[1:] 154 | return variable_text # Return as-is if it doesn't match expected formats 155 | 156 | 157 | class SubstitutionError(Exception): 158 | """Raised when the user refers to a variable that doesn't exist.""" 159 | -------------------------------------------------------------------------------- /src/posting/version.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | 3 | VERSION = version("posting") 4 | -------------------------------------------------------------------------------- /src/posting/widgets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/src/posting/widgets/__init__.py -------------------------------------------------------------------------------- /src/posting/widgets/center_middle.py: -------------------------------------------------------------------------------- 1 | from textual.widget import Widget 2 | 3 | 4 | class CenterMiddle(Widget, inherit_bindings=False): 5 | """A container which aligns children on both axes.""" 6 | 7 | DEFAULT_CSS = """ 8 | CenterMiddle { 9 | align: center middle; 10 | width: 1fr; 11 | height: 1fr; 12 | } 13 | """ 14 | -------------------------------------------------------------------------------- /src/posting/widgets/confirmation.py: -------------------------------------------------------------------------------- 1 | """A modal screen for confirming a destructive action.""" 2 | 3 | from typing import Literal 4 | from textual import on 5 | from textual.app import ComposeResult 6 | from textual.binding import Binding 7 | from textual.containers import Horizontal, Vertical 8 | from textual.screen import ModalScreen 9 | from textual.widgets import Button, Static 10 | 11 | 12 | class ConfirmationModal(ModalScreen[bool]): 13 | DEFAULT_CSS = """ 14 | ConfirmationModal { 15 | align: center middle; 16 | height: auto; 17 | & #confirmation-buttons { 18 | margin-top: 1; 19 | width: 100%; 20 | height: 1; 21 | align: center middle; 22 | 23 | & > Button { 24 | width: 1fr; 25 | } 26 | } 27 | } 28 | """ 29 | 30 | BINDINGS = [ 31 | Binding( 32 | "left,right,up,down,h,j,k,l", 33 | "move_focus", 34 | "Navigate", 35 | show=False, 36 | ) 37 | ] 38 | 39 | def __init__( 40 | self, 41 | message: str, 42 | confirm_text: str = "Yes \\[y]", 43 | confirm_binding: str = "y", 44 | cancel_text: str = "No \\[n]", 45 | cancel_binding: str = "n", 46 | auto_focus: Literal["confirm", "cancel"] | None = "confirm", 47 | name: str | None = None, 48 | id: str | None = None, 49 | classes: str | None = None, 50 | ) -> None: 51 | super().__init__(name=name, id=id, classes=classes) 52 | self.message = message 53 | self.confirm_text = confirm_text 54 | self.confirm_binding = confirm_binding 55 | self.cancel_text = cancel_text 56 | self.cancel_binding = cancel_binding 57 | self.auto_focus = auto_focus 58 | 59 | def on_mount(self) -> None: 60 | self._bindings.bind(self.confirm_binding, "screen.dismiss(True)") 61 | self._bindings.bind(self.cancel_binding, "screen.dismiss(False)") 62 | self._bindings.bind("escape", "screen.dismiss(False)") 63 | if self.auto_focus is not None: 64 | self.query_one(f"#{self.auto_focus}-button").focus() 65 | 66 | def compose(self) -> ComposeResult: 67 | with Vertical(id="confirmation-screen", classes="modal-body") as container: 68 | container.border_title = "Confirm" 69 | yield Static(self.message) 70 | with Horizontal(id="confirmation-buttons"): 71 | yield Button(self.confirm_text, id="confirm-button") 72 | yield Button(self.cancel_text, id="cancel-button") 73 | 74 | @on(Button.Pressed, "#confirm-button") 75 | def confirm(self) -> None: 76 | self.dismiss(True) 77 | 78 | @on(Button.Pressed, "#cancel-button") 79 | def cancel(self) -> None: 80 | self.dismiss(False) 81 | 82 | def action_move_focus(self) -> None: 83 | # It's enough to just call focus_next here as there are only two buttons. 84 | self.screen.focus_next() 85 | -------------------------------------------------------------------------------- /src/posting/widgets/input.py: -------------------------------------------------------------------------------- 1 | from rich.style import Style 2 | from textual.theme import Theme 3 | from textual.widgets import Input 4 | 5 | 6 | from posting.config import SETTINGS 7 | 8 | 9 | class PostingInput(Input): 10 | def on_mount(self) -> None: 11 | self.cursor_blink = SETTINGS.get().text_input.blinking_cursor 12 | 13 | self._theme_cursor_style: Style | None = None 14 | 15 | self.on_theme_change(self.app.current_theme) 16 | self.app.theme_changed_signal.subscribe(self, self.on_theme_change) 17 | 18 | @property 19 | def cursor_style(self) -> Style: 20 | return ( 21 | self._theme_cursor_style 22 | if self._theme_cursor_style is not None 23 | else self.get_component_rich_style("input--cursor") 24 | ) 25 | 26 | def on_theme_change(self, theme: Theme) -> None: 27 | cursor_style = theme.variables.get("input-cursor") 28 | self._theme_cursor_style = Style.parse(cursor_style) if cursor_style else None 29 | self.refresh() 30 | -------------------------------------------------------------------------------- /src/posting/widgets/request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/posting/59d0364546267021f0137808ec4ed52b5af99e1a/src/posting/widgets/request/__init__.py -------------------------------------------------------------------------------- /src/posting/widgets/request/form_editor.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | from rich.text import Text 3 | from textual.app import ComposeResult 4 | from textual.binding import Binding 5 | from textual.containers import Vertical 6 | from posting.collection import FormItem 7 | from posting.widgets.datatable import PostingDataTable 8 | from posting.widgets.key_value import KeyValueEditor, KeyValueInput 9 | from posting.widgets.variable_input import VariableInput 10 | 11 | 12 | class FormTable(PostingDataTable): 13 | BINDINGS = [ 14 | Binding("backspace", action="remove_row", description="Remove row"), 15 | Binding("space", action="toggle_row", description="Toggle row"), 16 | ] 17 | 18 | def on_mount(self): 19 | self.fixed_columns = 1 20 | self.show_header = False 21 | self.cursor_type = "row" 22 | self.zebra_stripes = True 23 | self.row_disable = True 24 | self.add_columns("Key", "Value") 25 | 26 | def to_model(self) -> list[FormItem]: 27 | form_data: list[FormItem] = [] 28 | for row_index in range(self.row_count): 29 | row = self.get_row_at(row_index) 30 | form_data.append( 31 | FormItem( 32 | name=row[0].plain if isinstance(row[0], Text) else row[0], 33 | value=row[1].plain if isinstance(row[1], Text) else row[1], 34 | enabled=self.is_row_enabled_at(row_index), 35 | ) 36 | ) 37 | return form_data 38 | 39 | 40 | class FormEditor(Vertical): 41 | """An editor for form body data.""" 42 | 43 | def compose(self) -> ComposeResult: 44 | yield KeyValueEditor( 45 | FormTable(), 46 | KeyValueInput( 47 | VariableInput(placeholder="Key"), 48 | VariableInput(placeholder="Value"), 49 | ), 50 | empty_message="There is no form data.", 51 | ) 52 | 53 | def to_model(self) -> list[FormItem]: 54 | return self.query_one(FormTable).to_model() 55 | 56 | def replace_all_rows( 57 | self, rows: Iterable[Iterable[str]], enableStates: Iterable[bool] | None = None 58 | ) -> None: 59 | self.query_one(FormTable).replace_all_rows(rows, enableStates) 60 | -------------------------------------------------------------------------------- /src/posting/widgets/request/method_selection.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from rich.console import RenderableType 3 | 4 | from textual import on 5 | from textual.binding import Binding 6 | from textual.message import Message 7 | from textual.widgets import Select 8 | from posting.collection import HttpRequestMethod 9 | from posting.help_data import HelpData 10 | 11 | from posting.widgets.select import PostingSelect 12 | 13 | 14 | class MethodSelector(PostingSelect[str]): 15 | help = HelpData( 16 | title="Method Selector", 17 | description="""\ 18 | Select the HTTP method for the request. 19 | You can select a method by typing a single letter. For example, pressing `g` 20 | will set the method to `GET`. 21 | The dropdown does not need to be expanded in order to select a method. 22 | """, 23 | ) 24 | 25 | BINDING_GROUP_TITLE = "HTTP Method Selector" 26 | 27 | BINDINGS = [ 28 | Binding("g", "select_method('GET')", "GET", show=False), 29 | Binding("p", "select_method('POST')", "POST", show=False), 30 | Binding("a", "select_method('PATCH')", "PATCH", show=False), 31 | Binding("u", "select_method('PUT')", "PUT", show=False), 32 | Binding("d", "select_method('DELETE')", "DELETE", show=False), 33 | Binding("o", "select_method('OPTIONS')", "OPTIONS", show=False), 34 | Binding("h", "select_method('HEAD')", "HEAD", show=False), 35 | ] 36 | 37 | def __init__( 38 | self, 39 | *, 40 | prompt: str = "Method", 41 | value: str = "GET", 42 | name: str | None = None, 43 | id: str | None = None, 44 | classes: str | None = None, 45 | disabled: bool = False, 46 | tooltip: RenderableType | None = None, 47 | ): 48 | super().__init__( 49 | [ 50 | ("[u]G[/]ET", "GET"), 51 | ("[u]P[/]OST", "POST"), 52 | ("P[u]U[/]T", "PUT"), 53 | ("[u]D[/]ELETE", "DELETE"), 54 | ("P[u]A[/]TCH", "PATCH"), 55 | ("[u]H[/]EAD", "HEAD"), 56 | ("[u]O[/]PTIONS", "OPTIONS"), 57 | ], 58 | prompt=prompt, 59 | allow_blank=False, 60 | value=value, 61 | name=name, 62 | id=id, 63 | classes=classes, 64 | disabled=disabled, 65 | tooltip=tooltip, 66 | ) 67 | 68 | @dataclass 69 | class MethodChanged(Message): 70 | value: HttpRequestMethod 71 | select: "MethodSelector" 72 | 73 | @property 74 | def control(self) -> "MethodSelector": 75 | return self.select 76 | 77 | @on(Select.Changed) 78 | def method_selected(self, event: Select.Changed) -> None: 79 | event.stop() 80 | if event.value is not Select.BLANK: 81 | self.post_message( 82 | MethodSelector.MethodChanged(value=event.value, select=self) 83 | ) 84 | 85 | def action_select_method(self, method: str) -> None: 86 | self.value = method 87 | -------------------------------------------------------------------------------- /src/posting/widgets/request/query_editor.py: -------------------------------------------------------------------------------- 1 | from rich.text import Text 2 | from textual.app import ComposeResult 3 | from textual.binding import Binding 4 | from textual.containers import Vertical 5 | from textual.widgets import Input 6 | from posting.collection import QueryParam 7 | 8 | from posting.widgets.datatable import PostingDataTable 9 | from posting.widgets.key_value import KeyValueEditor, KeyValueInput 10 | from posting.widgets.variable_input import VariableInput 11 | 12 | 13 | class ParamsTable(PostingDataTable): 14 | """ 15 | The parameters table. 16 | """ 17 | 18 | BINDINGS = [ 19 | Binding("backspace", action="remove_row", description="Remove row"), 20 | Binding("space", action="toggle_row", description="Toggle row"), 21 | ] 22 | 23 | def on_mount(self): 24 | self.fixed_columns = 1 25 | self.show_header = False 26 | self.cursor_type = "row" 27 | self.zebra_stripes = True 28 | self.row_disable = True 29 | self.add_columns("Key", "Value") 30 | 31 | def watch_has_focus(self, value: bool) -> None: 32 | self._scroll_cursor_into_view() 33 | return super().watch_has_focus(value) 34 | 35 | def to_model(self) -> list[QueryParam]: 36 | params: list[QueryParam] = [] 37 | for row_index in range(self.row_count): 38 | row = self.get_row_at(row_index) 39 | params.append( 40 | QueryParam( 41 | name=row[0].plain if isinstance(row[0], Text) else row[0], 42 | value=row[1].plain if isinstance(row[1], Text) else row[1], 43 | enabled=self.is_row_enabled_at(row_index), 44 | ) 45 | ) 46 | return params 47 | 48 | 49 | class QueryStringEditor(Vertical): 50 | """ 51 | The query string editor. 52 | """ 53 | 54 | def compose(self) -> ComposeResult: 55 | yield KeyValueEditor( 56 | ParamsTable(), 57 | KeyValueInput( 58 | VariableInput(placeholder="Key", id="query-key-input"), 59 | VariableInput(placeholder="Value"), 60 | button_label="Add parameter", 61 | ), 62 | empty_message="No query parameters", 63 | ) 64 | 65 | @property 66 | def query_key_input(self) -> Input: 67 | return self.query_one("#query-key-input", Input) 68 | -------------------------------------------------------------------------------- /src/posting/widgets/request/request_body.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.containers import Horizontal, Vertical 3 | from textual.widgets import ContentSwitcher, Label 4 | from posting.help_data import HelpData 5 | 6 | from posting.widgets.center_middle import CenterMiddle 7 | from posting.widgets.request.form_editor import FormEditor 8 | from posting.widgets.select import PostingSelect 9 | from posting.widgets.text_area import PostingTextArea, TextAreaFooter, TextEditor 10 | 11 | 12 | class RequestBodyEditor(Vertical): 13 | """ 14 | A container for the request body text area and the request body type selector. 15 | """ 16 | 17 | def compose(self) -> ComposeResult: 18 | with Horizontal(id="request-body-type-select-container"): 19 | yield PostingSelect( 20 | # These values are also referred to inside MainScreen. 21 | # When we load a request, we need to set the correct 22 | # value in the select. 23 | options=[ 24 | ("None", "no-body-label"), 25 | ("Raw (json, text, etc.)", "text-body-editor"), 26 | ("Form data (x-www-form-urlencoded)", "form-body-editor"), 27 | ], 28 | id="request-body-type-select", 29 | allow_blank=False, 30 | ) 31 | with ContentSwitcher( 32 | initial="no-body-label", 33 | id="request-body-type-content-switcher", 34 | ): 35 | yield CenterMiddle( 36 | Label("No request body"), 37 | id="no-body-label", 38 | ) 39 | text_area = RequestBodyTextArea(language="json") 40 | yield TextEditor( 41 | text_area=text_area, 42 | footer=TextAreaFooter(text_area), 43 | id="text-body-editor", 44 | ) 45 | yield FormEditor( 46 | id="form-body-editor", 47 | ) 48 | 49 | 50 | class RequestBodyTextArea(PostingTextArea): 51 | """ 52 | For editing request bodies. 53 | """ 54 | 55 | BINDING_GROUP_TITLE = "Request Body Text Area" 56 | 57 | help = HelpData( 58 | title="Request Body Text Area", 59 | description="""\ 60 | A text area for entering the request body. 61 | Press `ESC` to focus the text area footer bar. 62 | 63 | Hold `shift` and move the cursor or click and drag to select text. 64 | """, 65 | ) 66 | 67 | def on_mount(self): 68 | self.tab_behavior = "indent" 69 | self.show_line_numbers = True 70 | -------------------------------------------------------------------------------- /src/posting/widgets/request/request_editor.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, cast 2 | from textual import on 3 | from textual.app import ComposeResult 4 | from textual.containers import Vertical 5 | from textual.lazy import Lazy 6 | from textual.widgets import ContentSwitcher, Select, TabPane 7 | from posting.collection import RequestBody 8 | from posting.widgets.request.form_editor import FormEditor 9 | 10 | from posting.widgets.request.header_editor import HeaderEditor 11 | from posting.widgets.request.query_editor import QueryStringEditor 12 | from posting.widgets.request.request_auth import RequestAuth 13 | from posting.widgets.request.request_body import RequestBodyEditor 14 | from posting.widgets.request.request_metadata import RequestMetadata 15 | from posting.widgets.request.request_options import RequestOptions 16 | from posting.widgets.request.request_scripts import RequestScripts 17 | from posting.widgets.tabbed_content import PostingTabbedContent 18 | from posting.widgets.text_area import TextEditor 19 | 20 | 21 | if TYPE_CHECKING: 22 | from posting.app import Posting 23 | 24 | 25 | class RequestEditorTabbedContent(PostingTabbedContent): 26 | pass 27 | 28 | 29 | class RequestEditor(Vertical): 30 | """ 31 | The request editor. 32 | """ 33 | 34 | def compose(self) -> ComposeResult: 35 | app = cast("Posting", self.app) 36 | with Vertical() as vertical: 37 | vertical.border_title = "Request" 38 | with RequestEditorTabbedContent(): 39 | with TabPane("Headers", id="headers-pane"): 40 | yield HeaderEditor() 41 | with TabPane("Body", id="body-pane"): 42 | yield Lazy(RequestBodyEditor()) 43 | with TabPane("Query", id="query-pane"): 44 | yield Lazy(QueryStringEditor()) 45 | with TabPane("Auth", id="auth-pane"): 46 | yield Lazy(RequestAuth()) 47 | with TabPane("Info", id="info-pane"): 48 | yield Lazy(RequestMetadata()) 49 | with TabPane("Scripts", id="scripts-pane"): 50 | yield Lazy(RequestScripts(collection_root=app.collection.path)) 51 | with TabPane("Options", id="options-pane"): 52 | yield Lazy(RequestOptions()) 53 | 54 | def on_mount(self): 55 | self.border_title = "Request" 56 | self.add_class("section") 57 | 58 | @on(Select.Changed, selector="#request-body-type-select") 59 | def request_body_type_changed(self, event: Select.Changed) -> None: 60 | content_switcher = self.request_body_content_switcher 61 | content_switcher.current = event.value 62 | 63 | @property 64 | def request_body_type_select(self) -> Select[str]: 65 | return self.query_one("#request-body-type-select", Select) 66 | 67 | @property 68 | def request_body_content_switcher(self) -> ContentSwitcher: 69 | return self.query_one("#request-body-type-content-switcher", ContentSwitcher) 70 | 71 | @property 72 | def text_editor(self) -> TextEditor: 73 | return self.query_one("#text-body-editor", TextEditor) 74 | 75 | @property 76 | def form_editor(self) -> FormEditor: 77 | return self.query_one("#form-body-editor", FormEditor) 78 | 79 | @property 80 | def query_editor(self) -> QueryStringEditor: 81 | return self.query_one(QueryStringEditor) 82 | 83 | def to_request_model_args(self) -> dict[str, Any]: 84 | """Returns a dictionary containing the arguments that should be 85 | passed to the httpx.Request object. The keys will depend on the 86 | content type that the user has selected.""" 87 | content_switcher = self.request_body_content_switcher 88 | current = content_switcher.current 89 | text_editor = self.text_editor 90 | if current == "no-body-label": 91 | return {"body": None} 92 | elif current == "text-body-editor": 93 | # We need to check the chosen content type in the TextEditor 94 | # We can look at the language to determine the content type. 95 | return { 96 | "body": RequestBody( 97 | content=text_editor.text, 98 | content_type=text_editor.content_type, 99 | ) 100 | } 101 | elif current == "form-body-editor": 102 | return { 103 | "body": RequestBody( 104 | form_data=self.form_editor.to_model(), 105 | content_type="application/x-www-form-urlencoded", 106 | ) 107 | } 108 | return {} 109 | -------------------------------------------------------------------------------- /src/posting/widgets/request/request_metadata.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.containers import VerticalScroll 3 | from textual.reactive import Reactive, reactive 4 | from textual.widgets import Input, Label 5 | 6 | from posting.collection import RequestModel 7 | from posting.widgets.text_area import PostingTextArea, ReadOnlyTextArea 8 | from posting.widgets.variable_input import VariableInput 9 | 10 | 11 | class RequestMetadata(VerticalScroll): 12 | request: Reactive[RequestModel | None] = reactive(None, init=False) 13 | 14 | def watch_request(self, request: RequestModel | None) -> None: 15 | """When the request changes, update the form.""" 16 | if request is None: 17 | self.request_name_input.value = "" 18 | self.request_description_textarea.text = "" 19 | self.request_path_text_area.text = "Request not saved to disk." 20 | else: 21 | self.request_name_input.value = request.name or "" 22 | self.request_description_textarea.text = request.description 23 | self.request_path_text_area.text = ( 24 | str(request.path) if request.path else "Request not saved to disk." 25 | ) 26 | 27 | def compose(self) -> ComposeResult: 28 | self.can_focus = False 29 | yield Label("Name [dim]optional[/dim]") 30 | yield VariableInput(placeholder="Enter a name…", id="name-input") 31 | yield Label("Description [dim]optional[/dim]") 32 | yield PostingTextArea(id="description-textarea") 33 | yield Label("Path [dim]read-only[/dim]") 34 | yield ReadOnlyTextArea( 35 | "Request not saved to disk.", select_on_focus=True, id="request-path" 36 | ) 37 | 38 | @property 39 | def request_name_input(self) -> Input: 40 | return self.query_one("#name-input", Input) 41 | 42 | @property 43 | def request_description_textarea(self) -> PostingTextArea: 44 | return self.query_one("#description-textarea", PostingTextArea) 45 | 46 | @property 47 | def request_path_text_area(self) -> ReadOnlyTextArea: 48 | return self.query_one("#request-path", ReadOnlyTextArea) 49 | 50 | @property 51 | def request_name(self) -> str: 52 | return self.request_name_input.value 53 | 54 | @property 55 | def description(self) -> str: 56 | return self.request_description_textarea.text 57 | -------------------------------------------------------------------------------- /src/posting/widgets/request/request_options.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.app import ComposeResult 3 | from textual.binding import Binding 4 | from textual.containers import Vertical, VerticalScroll 5 | from textual.events import DescendantFocus 6 | from textual.widgets import Checkbox, Input, Label, Static 7 | 8 | from posting.collection import Options 9 | from posting.widgets.variable_input import VariableInput 10 | 11 | 12 | class RequestOptions(VerticalScroll): 13 | DEFAULT_CSS = """\ 14 | RequestOptions { 15 | 16 | Checkbox { 17 | height: 1; 18 | padding: 0 1; 19 | border: none; 20 | background: transparent; 21 | &:focus { 22 | border: none; 23 | & .toggle--label { 24 | text-style: not underline; 25 | } 26 | } 27 | } 28 | 29 | #proxy-option { 30 | padding: 0 1 0 2; 31 | height: 2; 32 | } 33 | 34 | #timeout-option { 35 | padding: 0 1 0 2; 36 | height: 2; 37 | } 38 | 39 | & #option-description { 40 | dock: right; 41 | height: 1fr; 42 | width: 50%; 43 | max-width: 50%; 44 | background: transparent; 45 | color: $text-muted; 46 | padding: 1 2; 47 | display: none; 48 | border-left: $accent 30%; 49 | 50 | &.show { 51 | display: block; 52 | } 53 | } 54 | } 55 | """ 56 | 57 | BINDINGS = [ 58 | Binding("down,j", "screen.focus_next", "Next"), 59 | Binding("up,k", "screen.focus_previous", "Previous"), 60 | ] 61 | 62 | def __init__(self): 63 | super().__init__() 64 | self.can_focus = False 65 | 66 | self.options = Options() 67 | 68 | self.descriptions = { 69 | "follow-redirects": "Follow redirects when the server responds with a 3xx status code.", 70 | "verify": "Verify SSL certificates when making requests.", 71 | "attach-cookies": "Attach cookies to outgoing requests to the same domain.", 72 | "proxy-url": "Proxy URL to use for requests.\ne.g. http://user:password@localhost:8080", 73 | "timeout": "Timeout for the request in seconds.", 74 | } 75 | 76 | def compose(self) -> ComposeResult: 77 | yield Checkbox( 78 | "Follow redirects", 79 | value=self.options.follow_redirects, 80 | id="follow-redirects", 81 | ) 82 | yield Checkbox( 83 | "Verify SSL certificates", 84 | value=self.options.verify_ssl, 85 | id="verify", 86 | ) 87 | yield Checkbox( 88 | "Attach cookies", 89 | value=self.options.attach_cookies, 90 | id="attach-cookies", 91 | ) 92 | 93 | with Vertical(id="proxy-option"): 94 | yield Label("Proxy URL:") 95 | yield VariableInput(id="proxy-url") 96 | 97 | with Vertical(id="timeout-option"): 98 | yield Label("Timeout:") 99 | yield VariableInput( 100 | value=str(self.options.timeout), 101 | id="timeout", 102 | type="number", 103 | validate_on={"changed"}, 104 | ) 105 | 106 | # A panel which the description of the option will be 107 | # displayed inside. 108 | yield Static("", id="option-description") 109 | 110 | @on(Checkbox.Changed) 111 | def on_checkbox_change(self, event: Checkbox.Changed) -> None: 112 | """Handle the checkbox change event.""" 113 | match event.checkbox.id: 114 | case "follow-redirects": 115 | self.options.follow_redirects = event.value 116 | case "verify": 117 | self.options.verify_ssl = event.value 118 | case "attach-cookies": 119 | self.options.attach_cookies = event.value 120 | case _: 121 | pass 122 | 123 | @on(Input.Changed, selector="#proxy-url") 124 | def on_proxy_url_changed(self, event: Input.Changed) -> None: 125 | """Handle the input change event.""" 126 | self.options.proxy_url = event.value 127 | 128 | @on(Input.Changed, selector="#timeout") 129 | def on_timeout_changed(self, event: Input.Changed) -> None: 130 | """Handle the input change event.""" 131 | try: 132 | self.options.timeout = float(event.value) 133 | except ValueError: 134 | self.options.timeout = 5.0 135 | 136 | @on(DescendantFocus) 137 | def on_descendant_focus(self, event: DescendantFocus) -> None: 138 | """Show the description of the option when the user focuses on it.""" 139 | focused_id = event.control.id 140 | description_label = self.query_one("#option-description", Static) 141 | has_description = focused_id in self.descriptions 142 | description_label.set_class(has_description, "show") 143 | if has_description: 144 | description_label.update(self.descriptions[focused_id]) 145 | else: 146 | description_label.update("") 147 | 148 | def to_model(self) -> Options: 149 | """Export the options to a model.""" 150 | return self.options 151 | 152 | def load_options(self, options: Options) -> None: 153 | """Load the options into the widget. 154 | 155 | Note that this is just setting the values on the widget. 156 | 157 | The change events emitted from the widgets are the things that 158 | result in the internal Options model state being updated. 159 | """ 160 | self.follow_redirects_checkbox.value = options.follow_redirects 161 | self.verify_ssl_checkbox.value = options.verify_ssl 162 | self.attach_cookies_checkbox.value = options.attach_cookies 163 | self.proxy_url_input.value = options.proxy_url 164 | self.timeout_input.value = str(options.timeout) 165 | 166 | @property 167 | def follow_redirects_checkbox(self) -> Checkbox: 168 | return self.query_one("#follow-redirects", Checkbox) 169 | 170 | @property 171 | def verify_ssl_checkbox(self) -> Checkbox: 172 | return self.query_one("#verify", Checkbox) 173 | 174 | @property 175 | def attach_cookies_checkbox(self) -> Checkbox: 176 | return self.query_one("#attach-cookies", Checkbox) 177 | 178 | @property 179 | def proxy_url_input(self) -> Input: 180 | return self.query_one("#proxy-url", Input) 181 | 182 | @property 183 | def timeout_input(self) -> Input: 184 | return self.query_one("#timeout", Input) 185 | -------------------------------------------------------------------------------- /src/posting/widgets/response/cookies_table.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.app import ComposeResult 3 | from textual.containers import Vertical 4 | from textual.widgets import Label 5 | from posting.widgets.center_middle import CenterMiddle 6 | from posting.widgets.datatable import PostingDataTable 7 | 8 | 9 | class CookiesSection(Vertical): 10 | def compose(self) -> ComposeResult: 11 | yield CenterMiddle(Label("No cookies"), id="empty-message") 12 | yield PostingDataTable(id="cookies-table") 13 | 14 | def on_mount(self) -> None: 15 | self.table.show_header = False 16 | self.table.cursor_type = "row" 17 | self.table.zebra_stripes = True 18 | self.table.fixed_columns = 1 19 | self.table.add_columns(*["Name", "Value"]) 20 | 21 | @on(PostingDataTable.RowsRemoved) 22 | def rows_removed(self, event: PostingDataTable.RowsRemoved) -> None: 23 | rows = event.data_table.row_count 24 | self.set_class(rows == 0, "empty") 25 | 26 | @on(PostingDataTable.RowsAdded) 27 | def rows_added(self, event: PostingDataTable.RowsAdded) -> None: 28 | rows = event.data_table.row_count 29 | self.set_class(rows == 0, "empty") 30 | 31 | @property 32 | def table(self) -> PostingDataTable: 33 | return self.query_one("#cookies-table", PostingDataTable) 34 | -------------------------------------------------------------------------------- /src/posting/widgets/response/response_area.py: -------------------------------------------------------------------------------- 1 | import json 2 | import httpx 3 | from textual.lazy import Lazy 4 | from posting.config import SETTINGS 5 | 6 | from posting.widgets.response.response_trace import ResponseTrace 7 | from posting.widgets.response.script_output import ScriptOutput 8 | from posting.widgets.tabbed_content import PostingTabbedContent 9 | from posting.widgets.text_area import TextAreaFooter, TextEditor 10 | from posting.widgets.response.cookies_table import CookiesSection 11 | from posting.widgets.response.response_body import ResponseTextArea 12 | from posting.widgets.response.response_headers import ResponseHeadersTable 13 | 14 | from textual.app import ComposeResult 15 | from textual.containers import Vertical 16 | from textual.reactive import Reactive, reactive 17 | from textual.widgets import TabPane 18 | from textual.widgets._tabbed_content import ContentTabs 19 | 20 | 21 | class ResponseTabbedContent(PostingTabbedContent): 22 | pass 23 | 24 | 25 | class ResponseArea(Vertical): 26 | """ 27 | The response area. 28 | """ 29 | 30 | COMPONENT_CLASSES = { 31 | "border-title-status", 32 | } 33 | 34 | response: Reactive[httpx.Response | None] = reactive(None) 35 | 36 | def on_mount(self) -> None: 37 | self.border_title = "Response" 38 | self._latest_response: httpx.Response | None = None 39 | self.add_class("section") 40 | self.app.theme_changed_signal.subscribe(self, self.on_theme_change) 41 | 42 | def compose(self) -> ComposeResult: 43 | with ResponseTabbedContent(disabled=self.response is None): 44 | with TabPane("Body", id="response-body-pane"): 45 | text_area = ResponseTextArea(language="json") 46 | yield TextEditor( 47 | text_area, 48 | TextAreaFooter(text_area), 49 | ) 50 | with TabPane("Headers", id="response-headers-pane"): 51 | yield Lazy(ResponseHeadersTable()) 52 | with TabPane("Cookies", id="response-cookies-pane"): 53 | yield Lazy(CookiesSection()) 54 | with TabPane("Scripts", id="response-scripts-pane"): 55 | yield Lazy(ScriptOutput()) 56 | with TabPane("Trace", id="response-trace-pane"): 57 | yield Lazy(ResponseTrace()) 58 | 59 | def on_theme_change(self, _) -> None: 60 | if self._latest_response: 61 | self.border_title = self._make_border_title(self._latest_response) 62 | 63 | def watch_response(self, response: httpx.Response | None) -> None: 64 | self._latest_response = response 65 | if response is None: 66 | return 67 | else: 68 | self.query_one(ResponseTabbedContent).disabled = False 69 | 70 | self.add_class("response-ready") 71 | 72 | content_type = response.headers.get("content-type") 73 | if content_type: 74 | language = content_type_to_language(content_type) 75 | self.text_editor.language = language 76 | 77 | # Update the body text area with the body content. 78 | response_text_area = self.text_editor.text_area 79 | response_text = response.text 80 | response_settings = SETTINGS.get().response 81 | if response_text_area.language == "json" and response_settings.prettify_json: 82 | try: 83 | response_text = json.dumps( 84 | json.loads(response_text), indent=2, ensure_ascii=False 85 | ) 86 | except json.JSONDecodeError: 87 | pass 88 | 89 | response_text_area.text = response_text 90 | 91 | # Update the response headers table with the response headers. 92 | response_headers_table = self.headers_table 93 | response_headers_table.clear() 94 | response_headers_table.add_rows( 95 | [(name, value) for name, value in response.headers.items()] 96 | ) 97 | 98 | # Update the response cookies table with the cookies from the response. 99 | cookies_section = self.cookies_section 100 | cookies_section.table.clear() 101 | cookies_section.table.add_rows( 102 | [(name, value) for name, value in response.cookies.items()] 103 | ) 104 | 105 | self.remove_class("success", "warning", "error") 106 | if response.status_code < 300: 107 | self.add_class("success") 108 | elif response.status_code < 400: 109 | self.add_class("warning") 110 | else: 111 | self.add_class("error") 112 | 113 | self.border_title = self._make_border_title(response) 114 | 115 | settings = SETTINGS.get() 116 | if settings.response.show_size_and_time: 117 | self.border_subtitle = f"{human_readable_size(len(response.content))} in {response.elapsed.total_seconds() * 1000:.2f}[dim]ms[/]" 118 | 119 | def _make_border_title(self, response: httpx.Response) -> str: 120 | style = self.get_component_rich_style("border-title-status") 121 | return f"Response [{style}] {response.status_code} {response.reason_phrase} [/]" 122 | 123 | @property 124 | def text_editor(self) -> TextEditor: 125 | return self.query_one(TextEditor) 126 | 127 | @property 128 | def headers_table(self) -> ResponseHeadersTable: 129 | return self.query_one(ResponseHeadersTable) 130 | 131 | @property 132 | def cookies_section(self) -> CookiesSection: 133 | return self.query_one(CookiesSection) 134 | 135 | @property 136 | def tabbed_content(self) -> ResponseTabbedContent: 137 | return self.query_one(ResponseTabbedContent) 138 | 139 | @property 140 | def content_tabs(self) -> ContentTabs: 141 | return self.tabbed_content.query_one(ContentTabs) 142 | 143 | 144 | def content_type_to_language(content_type: str) -> str | None: 145 | """Given the value of an HTTP content-type header, return the name 146 | of the language to use in the response body text area.""" 147 | if content_type.startswith("application/json"): 148 | return "json" 149 | elif content_type.startswith("text/html") or content_type.startswith( 150 | "application/xml" 151 | ): 152 | return "html" 153 | elif content_type.startswith("text/css"): 154 | return "css" 155 | elif content_type.startswith("text/plain"): 156 | return None 157 | return "json" 158 | 159 | 160 | def human_readable_size(size: float, decimal_places: int = 2) -> str: # type: ignore 161 | for unit in ["B", "KB", "MB", "GB", "TB"]: 162 | if size < 1024: 163 | return f"{size:.{decimal_places}f}[dim]{unit}[/]" 164 | size /= 1024 165 | -------------------------------------------------------------------------------- /src/posting/widgets/response/response_body.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.widgets import TextArea 3 | from posting.help_data import HelpData 4 | 5 | from posting.widgets.text_area import ReadOnlyTextArea 6 | 7 | 8 | class ResponseTextArea(ReadOnlyTextArea): 9 | """ 10 | For displaying responses. 11 | """ 12 | 13 | help = HelpData( 14 | title="Response Body Text Area", 15 | description="""\ 16 | A *read-only* text area for displaying the response body. 17 | Supports several Vim keys (see table below). 18 | Hold `shift` and move the cursor or click and drag to select text. 19 | Press `v` to toggle *visual mode*, equivalent to keeping `shift` held down. 20 | Copy to the clipboard by pressing `y`. If no text is selected, the entire response body is copied. 21 | 22 | Open the response in your `$PAGER` by pressing `f3`. A custom pager (e.g. `fx`) 23 | can be used for JSON responses by setting the `pager_json` config to the command. 24 | """, 25 | ) 26 | 27 | BINDING_GROUP_TITLE = "Response Body Text Area" 28 | 29 | @on(TextArea.Changed) 30 | def on_change(self, event: TextArea.Changed) -> None: 31 | empty = len(self.text) == 0 32 | self.set_class(empty, "empty") 33 | self.show_line_numbers = not empty 34 | -------------------------------------------------------------------------------- /src/posting/widgets/response/response_headers.py: -------------------------------------------------------------------------------- 1 | from posting.widgets.datatable import PostingDataTable 2 | 3 | 4 | class ResponseHeadersTable(PostingDataTable): 5 | def on_mount(self) -> None: 6 | self.show_header = False 7 | self.cursor_type = "row" 8 | self.zebra_stripes = True 9 | self.fixed_columns = 1 10 | self.add_columns(*["Header", "Value"]) 11 | self.cursor_vertical_escape = False 12 | -------------------------------------------------------------------------------- /src/posting/widgets/response/response_trace.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, Literal 3 | from textual.app import ComposeResult 4 | from textual.containers import VerticalScroll 5 | from textual.widgets import Label 6 | 7 | 8 | Event = Literal[ 9 | "connection.connect_tcp.started", 10 | "connection.connect_tcp.complete", 11 | "connection.connect_tcp.failed", 12 | "connection.connect_unix_socket.started", 13 | "connection.connect_unix_socket.complete", 14 | "connection.connect_unix_socket.failed", 15 | "connection.start_tls.started", 16 | "connection.start_tls.complete", 17 | "connection.start_tls.failed", 18 | "http11.send_request_headers.started", 19 | "http11.send_request_headers.complete", 20 | "http11.send_request_headers.failed", 21 | "http11.send_request_body.started", 22 | "http11.send_request_body.complete", 23 | "http11.send_request_body.failed", 24 | "http11.receive_response_body.started", 25 | "http11.receive_response_body.complete", 26 | "http11.receive_response_body.failed", 27 | "http11.response_closed.started", 28 | "http11.response_closed.complete", 29 | "http11.response_closed.failed", 30 | ] 31 | 32 | 33 | class ResponseTrace(VerticalScroll): 34 | DEFAULT_CSS = """\ 35 | ResponseTrace { 36 | padding: 0 2; 37 | } 38 | """ 39 | 40 | def __init__( 41 | self, 42 | name: str | None = None, 43 | id: str | None = None, 44 | classes: str | None = None, 45 | disabled: bool = False, 46 | ) -> None: 47 | super().__init__(name=name, id=id, classes=classes, disabled=disabled) 48 | self.events: dict[str, dict[str, float]] = {} 49 | 50 | def compose(self) -> ComposeResult: 51 | self.can_focus = False 52 | if len(self.events) == 0: 53 | yield Label("Send a request to view the trace.") 54 | else: 55 | for event_name, status_times in self.events.items(): 56 | if "completed" in status_times: 57 | duration_ns = status_times["completed"] - status_times["started"] 58 | duration_ms = duration_ns / 1000000 59 | yield Label( 60 | f"[b]{event_name}[/b]: [green]{duration_ms:.2f}ms[/green]" 61 | ) 62 | elif "failed" in status_times: 63 | yield Label(f"[b]{event_name}[/b]: [red]failed[/red]") 64 | else: 65 | yield Label(f"[b]{event_name}[/b]: [yellow]waiting[/yellow]") 66 | 67 | async def log_event(self, event_name: Event, info: dict[str, Any]) -> None: 68 | event_name, status = event_name.rsplit(".", maxsplit=1) 69 | events = self.events 70 | match status: 71 | case "started": 72 | events[event_name] = {"started": time.perf_counter_ns()} 73 | case "complete": 74 | events[event_name]["completed"] = time.perf_counter_ns() 75 | case "failed": 76 | events[event_name]["failed"] = time.perf_counter_ns() 77 | case _: 78 | pass 79 | 80 | await self.recompose() 81 | 82 | def trace_complete(self) -> None: 83 | self.events = {} 84 | -------------------------------------------------------------------------------- /src/posting/widgets/response/script_output.py: -------------------------------------------------------------------------------- 1 | """Tab for displaying the output of a script. 2 | https://github.com/sergeyklay/gohugo-theme-ed/blob/main/exampleSite/hugo.toml 3 | This could be test results, logs, or other output from pre-request or 4 | post-response scripts. 5 | """ 6 | 7 | from typing import Literal 8 | from textual.app import ComposeResult 9 | from textual.binding import Binding 10 | from textual.containers import Horizontal, Vertical, VerticalScroll 11 | from textual.reactive import Reactive, reactive 12 | from textual.widgets import Label, RichLog 13 | 14 | from posting.help_data import HelpData 15 | from posting.widgets.rich_log import PostingRichLog 16 | 17 | ScriptStatus = Literal["success", "error", "no-script"] 18 | 19 | 20 | class ScriptOutput(VerticalScroll): 21 | help = HelpData( 22 | title="Script Output", 23 | description="""\ 24 | This log displays the output of scripts that executed during the last request. 25 | """, 26 | ) 27 | 28 | BINDING_GROUP_TITLE = "Script Output" 29 | 30 | setup_status: Reactive[ScriptStatus] = reactive("no-script") 31 | request_status: Reactive[ScriptStatus] = reactive("no-script") 32 | response_status: Reactive[ScriptStatus] = reactive("no-script") 33 | 34 | def compose(self) -> ComposeResult: 35 | self.can_focus = False 36 | with Horizontal(id="status-bar"): 37 | with Vertical(): 38 | yield Label("Setup") 39 | yield Label(self.setup_status, id="setup-status") 40 | with Vertical(): 41 | yield Label("Pre-request") 42 | yield Label(self.request_status, id="request-status") 43 | with Vertical(): 44 | yield Label("Post-response") 45 | yield Label(self.response_status, id="response-status") 46 | 47 | yield Label("Script output", id="script-output-title") 48 | yield PostingRichLog(markup=True, highlight=True) 49 | 50 | def set_setup_status(self, status: ScriptStatus) -> None: 51 | """Set the status of the setup script.""" 52 | self.setup_status = status 53 | self.set_label_status("setup-status", status) 54 | 55 | def set_request_status(self, status: ScriptStatus) -> None: 56 | """Set the status of the request.""" 57 | self.request_status = status 58 | self.set_label_status("request-status", status) 59 | 60 | def set_response_status(self, status: ScriptStatus) -> None: 61 | """Set the status of the response.""" 62 | self.response_status = status 63 | self.set_label_status("response-status", status) 64 | 65 | def set_label_status(self, label_id: str, status: ScriptStatus) -> None: 66 | """Set the status of a label.""" 67 | label = self.query_one(f"#{label_id}") 68 | success = status == "success" 69 | error = status == "error" 70 | no_script = status == "no-script" 71 | 72 | if isinstance(label, Label): 73 | label.set_class(success, "-success") 74 | label.set_class(error, "-error") 75 | label.set_class(no_script, "-no-script") 76 | 77 | if success: 78 | label.update("Success ✔︎") 79 | elif error: 80 | label.update("Error ⨯") 81 | elif no_script: 82 | label.update("-") 83 | 84 | def reset(self) -> None: 85 | """Reset the output.""" 86 | self.rich_log.clear() 87 | self.set_setup_status("no-script") 88 | self.set_request_status("no-script") 89 | self.set_response_status("no-script") 90 | 91 | def log_function_call_start(self, function: str) -> None: 92 | """Log the start of a function call.""" 93 | self.rich_log.write(f"[b dim]Running {function}[/]") 94 | 95 | @property 96 | def rich_log(self) -> RichLog: 97 | """Get the RichLog widget which stdout and stderr are printed to.""" 98 | return self.query_one(RichLog) 99 | -------------------------------------------------------------------------------- /src/posting/widgets/rich_log.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from typing import Literal 3 | from textual.binding import Binding 4 | from textual.widgets import RichLog 5 | 6 | 7 | class RichLogIO(StringIO): 8 | def __init__(self, rich_log: RichLog, stream_type: Literal["stdout", "stderr"]): 9 | super().__init__() 10 | self.rich_log: RichLog = rich_log 11 | self.stream_type: Literal["stdout", "stderr"] = stream_type 12 | self._buffer: str = "" 13 | 14 | def write(self, s: str) -> int: 15 | lines = (self._buffer + s).splitlines(True) 16 | self._buffer = "" 17 | for line in lines: 18 | if line.endswith("\n"): 19 | self._flush_line(line.rstrip("\n")) 20 | else: 21 | self._buffer = line 22 | return len(s) 23 | 24 | def _flush_line(self, line: str) -> None: 25 | if self.stream_type == "stdout": 26 | self.rich_log.write(f" [green]out[/green] {line}") 27 | else: 28 | self.rich_log.write(f" [red]err[/red] {line}") 29 | 30 | def flush(self) -> None: 31 | if self._buffer: 32 | self._flush_line(self._buffer) 33 | self._buffer = "" 34 | super().flush() 35 | 36 | 37 | class PostingRichLog(RichLog): 38 | BINDINGS = [ 39 | Binding("j", "scroll_down", "Scroll down"), 40 | Binding("k", "scroll_up", "Scroll up"), 41 | Binding("h", "scroll_left", "Scroll left"), 42 | Binding("l", "scroll_right", "Scroll right"), 43 | ] 44 | -------------------------------------------------------------------------------- /src/posting/widgets/select.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | from textual.binding import Binding 3 | from textual.widgets import Select 4 | from textual.widgets._select import SelectOverlay 5 | 6 | T = TypeVar("T") 7 | 8 | 9 | class PostingSelect(Select[T], inherit_bindings=False): 10 | BINDINGS = [ 11 | Binding("enter,space,l", "show_overlay", "Show Overlay", show=False), 12 | Binding("up,k", "cursor_up", "Cursor Up", show=False), 13 | Binding("down,j", "cursor_down", "Cursor Down", show=False), 14 | ] 15 | 16 | def action_cursor_up(self): 17 | if self.expanded: 18 | self.select_overlay.action_cursor_up() 19 | else: 20 | self.screen.focus_previous() 21 | 22 | def action_cursor_down(self): 23 | if self.expanded: 24 | self.select_overlay.action_cursor_down() 25 | else: 26 | self.screen.focus_next() 27 | 28 | @property 29 | def select_overlay(self) -> SelectOverlay: 30 | return self.query_one(SelectOverlay) 31 | -------------------------------------------------------------------------------- /src/posting/widgets/tabbed_content.py: -------------------------------------------------------------------------------- 1 | from textual.binding import Binding 2 | from textual.widgets import TabbedContent, Tabs 3 | 4 | 5 | class PostingTabbedContent(TabbedContent): 6 | BINDINGS = [ 7 | Binding("l", "next_tab", "Next tab", show=False), 8 | Binding("h", "previous_tab", "Previous tab", show=False), 9 | Binding("down,j", "app.focus_next", "Focus next", show=False), 10 | Binding("up,k", "app.focus_previous", "Focus previous", show=False), 11 | ] 12 | 13 | def action_next_tab(self) -> None: 14 | tabs = self.query_one(Tabs) 15 | if tabs.has_focus: 16 | tabs.action_next_tab() 17 | 18 | def action_previous_tab(self) -> None: 19 | tabs = self.query_one(Tabs) 20 | if tabs.has_focus: 21 | tabs.action_previous_tab() 22 | -------------------------------------------------------------------------------- /src/posting/widgets/tree.py: -------------------------------------------------------------------------------- 1 | from typing import Generator, TypeVar 2 | from textual.binding import Binding 3 | from textual.widgets import Tree 4 | from textual.widgets.tree import TreeNode 5 | 6 | T = TypeVar("T") 7 | 8 | 9 | class PostingTree(Tree[T]): 10 | DEFAULT_CSS = """\ 11 | PostingTree { 12 | scrollbar-size-horizontal: 0; 13 | & .node-selected { 14 | background: $primary-lighten-1; 15 | color: $text; 16 | text-style: bold; 17 | } 18 | } 19 | 20 | """ 21 | 22 | BINDINGS = [ 23 | Binding("k", "cursor_up", "Cursor Up", show=False), 24 | Binding("j", "cursor_down", "Cursor Down", show=False), 25 | Binding("K", "cursor_up_parent", "Cursor Up Parent", show=False), 26 | Binding("J", "cursor_down_parent", "Cursor Down Parent", show=False), 27 | Binding("g", "scroll_home", "Cursor To Top", show=False), 28 | Binding("G", "scroll_end", "Cursor To Bottom", show=False), 29 | Binding("enter,l,h", "select_cursor", "Select Cursor", show=False), 30 | Binding("space,r", "toggle_node", "Toggle Expand", show=False), 31 | ] 32 | 33 | def action_cursor_up_parent(self) -> None: 34 | """Move the cursor to the previous collapsible node.""" 35 | start_line = max(self.cursor_line - 1, 0) 36 | for line in range(start_line, -1, -1): 37 | node = self.get_node_at_line(line) 38 | if node and node.allow_expand: 39 | self.cursor_line = line 40 | return 41 | 42 | def action_cursor_down_parent(self) -> None: 43 | """Move the cursor to the next collapsible node.""" 44 | max_index = len(self._tree_lines) - 1 45 | start_line = min(self.cursor_line + 1, max_index) 46 | for line in range(start_line, max_index + 1): 47 | node = self.get_node_at_line(line) 48 | if node and node.allow_expand: 49 | self.cursor_line = line 50 | return 51 | 52 | def walk_nodes(self) -> Generator[TreeNode[T], None, None]: 53 | """Walk the nodes of the tree.""" 54 | for node in self._tree_nodes.values(): 55 | yield node 56 | -------------------------------------------------------------------------------- /src/posting/widgets/variable_autocomplete.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Sequence 2 | from textual.widgets import Input 3 | from textual_autocomplete import ( 4 | AutoComplete, 5 | DropdownItem, 6 | TargetState, 7 | ) 8 | 9 | from posting.variables import ( 10 | find_variable_end, 11 | find_variable_start, 12 | get_variable_at_cursor, 13 | get_variables, 14 | is_cursor_within_variable, 15 | ) 16 | 17 | 18 | class VariableAutoComplete(AutoComplete): 19 | def __init__( 20 | self, 21 | target: Input | str, 22 | candidates: Sequence[DropdownItem | str] 23 | | Callable[[TargetState], list[DropdownItem]] 24 | | None = None, 25 | variable_candidates: Sequence[DropdownItem] 26 | | Callable[[TargetState], list[DropdownItem]] 27 | | None = None, 28 | prevent_default_enter: bool = True, 29 | prevent_default_tab: bool = True, 30 | name: str | None = None, 31 | id: str | None = None, 32 | classes: str | None = None, 33 | disabled: bool = False, 34 | ) -> None: 35 | super().__init__( 36 | target=target, 37 | candidates=candidates, 38 | prevent_default_enter=prevent_default_enter, 39 | prevent_default_tab=prevent_default_tab, 40 | name=name, 41 | id=id, 42 | classes=classes, 43 | disabled=disabled, 44 | ) 45 | if variable_candidates is None: 46 | variable_candidates = [ 47 | DropdownItem(main=f"${variable}") for variable in get_variables() 48 | ] 49 | self.variable_candidates = variable_candidates 50 | 51 | def get_candidates(self, target_state: TargetState) -> list[DropdownItem]: 52 | cursor = target_state.cursor_position 53 | text = target_state.text 54 | if is_cursor_within_variable(cursor, text): 55 | candidates = self.get_variable_candidates(target_state) 56 | else: 57 | candidates = super().get_candidates(target_state) 58 | return candidates 59 | 60 | def apply_completion(self, value: str, state: TargetState) -> None: 61 | """Modify the target state to reflect the completion. 62 | 63 | Only works in Inputs for now. 64 | """ 65 | cursor = state.cursor_position 66 | text = state.text 67 | target: Input = self.target 68 | if is_cursor_within_variable(cursor, text): 69 | # Replace the text from the variable start 70 | # with the completion text. 71 | start = find_variable_start(cursor, text) 72 | end = find_variable_end(cursor, text) 73 | old_value = text 74 | new_value = old_value[:start] + value + old_value[end:] 75 | 76 | target.value = new_value 77 | target.cursor_position = start + len(value) 78 | else: 79 | target.value = value 80 | target.cursor_position = len(value) 81 | 82 | self.post_completion() 83 | 84 | def get_search_string(self, target_state: TargetState) -> str: 85 | cursor = target_state.cursor_position 86 | text = target_state.text 87 | if is_cursor_within_variable(cursor, text): 88 | variable_at_cursor = get_variable_at_cursor(cursor, text) 89 | return variable_at_cursor or "" 90 | else: 91 | return target_state.text 92 | 93 | def get_variable_candidates(self, target_state: TargetState) -> list[DropdownItem]: 94 | candidates = self.variable_candidates 95 | return candidates(target_state) if callable(candidates) else candidates 96 | -------------------------------------------------------------------------------- /src/posting/widgets/variable_input.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | from textual_autocomplete import DropdownItem, TargetState 3 | from posting.help_data import HelpData 4 | from posting.highlighters import VariableHighlighter 5 | from posting.themes import Theme, VariableStyles 6 | from posting.variables import get_variables 7 | from posting.widgets.input import PostingInput 8 | 9 | from posting.widgets.variable_autocomplete import VariableAutoComplete 10 | 11 | 12 | class VariableInput(PostingInput): 13 | help = HelpData( 14 | title="Variable-Aware Input", 15 | description="""\ 16 | An input field which supports auto-completion and highlighting of variables 17 | from the environment (loaded via `--env`). 18 | Use the `up` and `down` keys to navigate the dropdown list when it's visible. 19 | Press `enter` to insert a dropdown item. 20 | Press `tab` to both insert *and* shift focus. 21 | """, 22 | ) 23 | 24 | BINDING_GROUP_TITLE = "Variable Input" 25 | 26 | def __init__( 27 | self, 28 | *args: Any, 29 | candidates: list[DropdownItem | str] 30 | | Callable[[TargetState], list[DropdownItem]] 31 | | None = None, 32 | **kwargs: Any, 33 | ): 34 | super().__init__(*args, **kwargs) 35 | self.candidates = candidates 36 | """Non-variable candidates to show in the dropdown list.""" 37 | 38 | def on_mount(self) -> None: 39 | self.highlighter = VariableHighlighter() 40 | self.auto_complete = VariableAutoComplete( 41 | candidates=self.candidates or [], 42 | variable_candidates=self._get_variable_candidates, 43 | target=self, 44 | ) 45 | self.screen.mount(self.auto_complete) 46 | 47 | def on_theme_change(self, theme: Theme) -> None: 48 | """Callback which fires when the app-level theme changes in order 49 | to update the color scheme of the variable highlighter. 50 | 51 | Args: 52 | theme: The new app theme. 53 | """ 54 | super().on_theme_change(theme) 55 | self.highlighter.variable_styles = VariableStyles( 56 | resolved=theme.variables.get("variable-resolved"), 57 | unresolved=theme.variables.get("variable-unresolved"), 58 | ) 59 | self.refresh() 60 | 61 | def _get_variable_candidates(self, target_state: TargetState) -> list[DropdownItem]: 62 | return [DropdownItem(main=f"${variable}") for variable in get_variables()] 63 | -------------------------------------------------------------------------------- /src/posting/xresources.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import subprocess 3 | from typing import Any 4 | 5 | from posting.themes import Theme 6 | from textual.theme import Theme as TextualTheme 7 | 8 | XRDB_MAPPING = { 9 | "color0": ["primary"], 10 | "color8": ["secondary"], 11 | "color1": ["error"], 12 | "color2": ["success"], 13 | "color3": ["warning"], 14 | "color4": ["accent"], 15 | "background": ["background"], 16 | "color7": ["surface", "panel"], 17 | } 18 | 19 | 20 | def load_xresources_themes() -> dict[str, TextualTheme]: 21 | """Runs xrdb -query and returns a dictionary of theme_name -> ColorSystem objects.""" 22 | try: 23 | result = subprocess.run( 24 | ["xrdb", "-query"], capture_output=True, text=True, check=True 25 | ) 26 | except subprocess.CalledProcessError as e: 27 | raise RuntimeError(f"Error running xrdb: {e}") 28 | except OSError as e: 29 | raise RuntimeError(f"Error running xrdb: {e}") 30 | 31 | supplied_colors: dict[str, Any] = {} 32 | for line in result.stdout.splitlines(): 33 | if line.startswith("*"): 34 | name, value = line.removeprefix("*").split(":", 1) 35 | for kwarg in XRDB_MAPPING.get(name.strip(), []): 36 | supplied_colors[kwarg] = value.strip() 37 | 38 | missing_colors = ( 39 | set(itertools.chain(*XRDB_MAPPING.values())) - supplied_colors.keys() 40 | ) 41 | if missing_colors: 42 | missing_colors_string = ", ".join(missing_colors) 43 | raise RuntimeError(f"Missing colors from xrdb: {missing_colors_string}") 44 | 45 | return { 46 | "xresources-dark": Theme( 47 | name="xresources-dark", 48 | **supplied_colors, 49 | dark=True, 50 | ).to_textual_theme(), 51 | "xresources-light": Theme( 52 | name="xresources-light", 53 | **supplied_colors, 54 | dark=False, 55 | ).to_textual_theme(), 56 | } 57 | -------------------------------------------------------------------------------- /src/posting/yaml.py: -------------------------------------------------------------------------------- 1 | from yaml import load, dump 2 | import yaml 3 | 4 | try: 5 | from yaml import CLoader as Loader, Dumper as Dumper 6 | except ImportError: 7 | from yaml import Loader, Dumper 8 | 9 | 10 | def str_presenter(dumper: Dumper, data: str) -> yaml.ScalarNode: 11 | if data.count("\n") > 0: 12 | data = "\n".join( 13 | [line.rstrip() for line in data.splitlines()] 14 | ) # Remove any trailing spaces, then put it back together again 15 | return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") 16 | return dumper.represent_scalar("tag:yaml.org,2002:str", data) 17 | 18 | 19 | yaml.add_representer(str, str_presenter) 20 | yaml.representer.SafeRepresenter.add_representer(str, str_presenter) 21 | 22 | 23 | __all__ = ["load", "dump", "Loader", "Dumper"] 24 | -------------------------------------------------------------------------------- /tests/posting_snapshot_app.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from posting.__main__ import make_posting 3 | 4 | COLLECTION = Path(__file__).parent / "sample-collections" 5 | BASE_ENV = COLLECTION / "sample_base.env" 6 | EXTRA_ENV = COLLECTION / "sample_extra.env" 7 | 8 | envs = tuple(str(env) for env in [BASE_ENV, EXTRA_ENV]) 9 | app = make_posting(collection=COLLECTION, env=envs) 10 | # app.run() 11 | -------------------------------------------------------------------------------- /tests/sample-collections/echo-post-01.posting.yaml: -------------------------------------------------------------------------------- 1 | name: echo post 2 | description: Echo server for post requests. 3 | method: POST 4 | url: https://postman-echo.com/post 5 | body: 6 | content: |- 7 | { 8 | "string": "Hello, world!", 9 | "booleans": [true, false], 10 | "numbers": [1, 2, 42], 11 | "null": null 12 | } 13 | content_type: application/json 14 | auth: 15 | type: basic 16 | basic: 17 | username: darren 18 | headers: 19 | - name: Content-Type 20 | value: application/json 21 | - name: Accept 22 | value: '*' 23 | - name: Cache-Control 24 | value: no-cache 25 | - name: Accept-Encoding 26 | value: gzip 27 | params: 28 | - name: key1 29 | value: value1 30 | - name: another-key 31 | value: another-value 32 | - name: number 33 | value: '123' 34 | options: 35 | timeout: 0.2 36 | -------------------------------------------------------------------------------- /tests/sample-collections/echo.posting.yaml: -------------------------------------------------------------------------------- 1 | name: echo 2 | description: This is an echo server we can use to see exactly what request is being 3 | sent. 4 | url: https://postman-echo.com/get 5 | body: 6 | form_data: 7 | - name: something 8 | value: '123' 9 | headers: 10 | - name: X-Setup-Var 11 | value: $setup_var 12 | scripts: 13 | setup: scripts/my_script.py 14 | on_request: scripts/my_script.py 15 | on_response: scripts/my_script.py 16 | options: 17 | follow_redirects: false 18 | -------------------------------------------------------------------------------- /tests/sample-collections/get-random-user.posting.yaml: -------------------------------------------------------------------------------- 1 | name: get random user 2 | url: https://api.randomuser.me 3 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/posts/comments/edit.posting.yaml: -------------------------------------------------------------------------------- 1 | name: edit a comment 2 | description: Edit a comment 3 | method: PUT 4 | url: https://jsonplaceholder.typicode.com/comments/1 5 | body: 6 | content: |- 7 | { 8 | "postId": 1, 9 | "name": "Updated Commenter Name", 10 | "email": "updated.email@example.com", 11 | "body": "This is the updated comment body." 12 | } 13 | headers: 14 | - name: Content-Type 15 | value: application/json 16 | - name: Referer 17 | value: https://example.com/ 18 | - name: Accept-Encoding 19 | value: gzip 20 | - name: Cache-Control 21 | value: no-cache 22 | params: 23 | - name: postId 24 | value: '1' 25 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/posts/comments/get-comments-query.posting.yaml: -------------------------------------------------------------------------------- 1 | name: get comments (via param) 2 | description: Retrieve the comments for a post using the query string 3 | url: https://jsonplaceholder.typicode.com/comments 4 | headers: 5 | - name: Content-Type 6 | value: application/json 7 | - name: Referer 8 | value: https://example.com/ 9 | - name: Accept-Encoding 10 | value: gzip 11 | - name: Cache-Control 12 | value: no-cache 13 | params: 14 | - name: postId 15 | value: '1' 16 | - name: foo 17 | value: bar 18 | - name: beep 19 | value: boop 20 | - name: onetwothree 21 | value: '123' 22 | - name: areallyreallyreallyreallylongkey 23 | value: avalue 24 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/posts/comments/get-comments.posting.yaml: -------------------------------------------------------------------------------- 1 | name: get comments 2 | description: Retrieve the comments for a post 3 | url: https://jsonplaceholder.typicode.com/posts/1/comments 4 | headers: 5 | - name: Content-Type 6 | value: application/json 7 | - name: Referer 8 | value: https://example.com/ 9 | - name: Accept-Encoding 10 | value: gzip 11 | - name: Cache-Control 12 | value: no-cache 13 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/posts/create.posting.yaml: -------------------------------------------------------------------------------- 1 | name: create 2 | description: Create a new post 3 | method: POST 4 | url: https://jsonplaceholder.typicode.com/posts 5 | body: 6 | content: |- 7 | { 8 | "title": "foo", 9 | "body": "bar", 10 | "userId": 1 11 | } 12 | headers: 13 | - name: Content-Type 14 | value: application/json 15 | - name: Referer 16 | value: https://example.com/ 17 | - name: Accept-Encoding 18 | value: gzip 19 | - name: Cache-Control 20 | value: no-cache 21 | options: 22 | timeout: 0.2 23 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/posts/delete.posting.yaml: -------------------------------------------------------------------------------- 1 | name: delete a post 2 | description: Delete a single post 3 | method: DELETE 4 | url: https://jsonplaceholder.typicode.com/posts/1 5 | headers: 6 | - name: Content-Type 7 | value: application/json 8 | - name: Referer 9 | value: https://example.com/ 10 | - name: Accept-Encoding 11 | value: gzip 12 | - name: Cache-Control 13 | value: no-cache 14 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/posts/get-all.posting.yaml: -------------------------------------------------------------------------------- 1 | name: get all 2 | description: Retrieve all posts 3 | url: https://jsonplaceholder.typicode.com/posts 4 | headers: 5 | - name: Content-Type 6 | value: application/json 7 | - name: Referer 8 | value: https://example.com/ 9 | - name: Accept-Encoding 10 | value: gzip 11 | - name: Cache-Control 12 | value: no-cache 13 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/posts/get-one.posting.yaml: -------------------------------------------------------------------------------- 1 | name: get one 2 | description: Retrieve one post 3 | url: https://jsonplaceholder.typicode.com/posts/$POST_ID/ 4 | headers: 5 | - name: Content-Type 6 | value: application/json 7 | - name: Referer 8 | value: https://example.com/ 9 | - name: Accept-Encoding 10 | value: gzip 11 | - name: Cache-Control 12 | value: no-cache 13 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/todos/get-all.posting.yaml: -------------------------------------------------------------------------------- 1 | name: get all 2 | description: Retrieve all todos 3 | url: https://jsonplaceholder.typicode.com/todos 4 | headers: 5 | - name: Content-Type 6 | value: application/json 7 | - name: Referer 8 | value: https://example.com/ 9 | - name: Accept-Encoding 10 | value: gzip 11 | - name: Cache-Control 12 | value: no-cache 13 | options: 14 | follow_redirects: false 15 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/todos/get-one.posting.yaml: -------------------------------------------------------------------------------- 1 | name: get one 2 | description: Retrieve one todo 3 | url: https://jsonplaceholder.typicode.com/todos/$TODO_ID 4 | headers: 5 | - name: Content-Type 6 | value: application/json 7 | - name: Referer 8 | value: https://example.com/ 9 | - name: Accept-Encoding 10 | value: gzip 11 | - name: Cache-Control 12 | value: no-cache 13 | options: 14 | follow_redirects: false 15 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/users/create.posting.yaml: -------------------------------------------------------------------------------- 1 | name: create a user 2 | description: Create a new user 3 | method: POST 4 | url: https://jsonplaceholder.typicode.com/users 5 | body: 6 | content: |- 7 | { 8 | "name": "John Doe", 9 | "username": "john.doe", 10 | "email": "john.doe@example.com" 11 | } 12 | content_type: application/json 13 | headers: 14 | - name: Content-Type 15 | value: application/json 16 | - name: Referer 17 | value: https://example.com/ 18 | - name: Accept-Encoding 19 | value: gzip 20 | - name: Cache-Control 21 | value: no-cache 22 | scripts: 23 | on_request: scripts/my_script.py 24 | options: 25 | follow_redirects: false 26 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/users/delete.posting.yaml: -------------------------------------------------------------------------------- 1 | name: delete a user 2 | description: Delete a user 3 | method: DELETE 4 | url: https://jsonplaceholder.typicode.com/users/1 5 | headers: 6 | - name: Content-Type 7 | value: application/json 8 | - name: Referer 9 | value: https://example.com/ 10 | - name: Accept-Encoding 11 | value: gzip 12 | - name: Cache-Control 13 | value: no-cache 14 | options: 15 | follow_redirects: false 16 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/users/get-all.posting.yaml: -------------------------------------------------------------------------------- 1 | name: get all users 2 | description: Retrieve all users 3 | url: https://jsonplaceholder.typicode.com/users 4 | headers: 5 | - name: Content-Type 6 | value: application/json 7 | - name: Referer 8 | value: https://example.com/ 9 | - name: Accept-Encoding 10 | value: gzip 11 | - name: Cache-Control 12 | value: no-cache 13 | options: 14 | follow_redirects: false 15 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/users/get-one.posting.yaml: -------------------------------------------------------------------------------- 1 | name: get a user 2 | description: Retrieve a single user 3 | url: https://jsonplaceholder.typicode.com/users/${USER_ID_UNRESOLVED}/$ALSO_UNRESOLVED 4 | headers: 5 | - name: Content-Type 6 | value: application/json 7 | - name: Referer 8 | value: https://example.com/ 9 | - name: Accept-Encoding 10 | value: gzip 11 | - name: Cache-Control 12 | value: no-cache 13 | options: 14 | follow_redirects: false 15 | -------------------------------------------------------------------------------- /tests/sample-collections/jsonplaceholder/users/update.posting.yaml: -------------------------------------------------------------------------------- 1 | name: update a user 2 | description: Update a user 3 | method: PUT 4 | url: https://jsonplaceholder.typicode.com/users/1 5 | body: 6 | content: |- 7 | { 8 | "name": "James Doe", 9 | "username": "james.doe", 10 | "email": "james.doe@example.com" 11 | } 12 | headers: 13 | - name: Content-Type 14 | value: application/json 15 | - name: Referer 16 | value: https://example.com/ 17 | - name: Accept-Encoding 18 | value: gzip 19 | - name: Cache-Control 20 | value: no-cache 21 | options: 22 | follow_redirects: false 23 | -------------------------------------------------------------------------------- /tests/sample-collections/scripts/my_script.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import httpx 3 | 4 | from posting import Auth, Header, RequestModel, Posting 5 | 6 | 7 | def setup(posting: Posting) -> None: 8 | print("Hello from my_script.py:setup!") 9 | sys.stderr.write("error from setup!\n") 10 | posting.set_variable("setup_var", "ADDED IN SETUP") 11 | 12 | 13 | def on_request(request: RequestModel, posting: Posting) -> None: 14 | new_header = "Foo-Bar-Baz!!!!!" 15 | header = Header(name="X-Custom-Header", value=new_header) 16 | request.headers.append(header) 17 | print(f"Set header:\n{header}!") 18 | # request.body.content = "asdf" 19 | request.auth = Auth.basic_auth("username", "password") 20 | posting.notify( 21 | message="Hello from my_script.py!", 22 | ) 23 | posting.set_variable("set_in_script", "foo") 24 | sys.stderr.write("Hello from my_script.py:on_request - i'm an error!") 25 | 26 | 27 | def on_response(response: httpx.Response, posting: Posting) -> None: 28 | print(response.status_code) 29 | print(posting.variables["set_in_script"]) # prints "foo" 30 | sys.stderr.write("Hello from my_script.py:on_response - i'm an error!") 31 | sys.stdout.write("Hello from my_script.py!") 32 | -------------------------------------------------------------------------------- /tests/sample-configs/custom_theme.yaml: -------------------------------------------------------------------------------- 1 | # Use a user-defined theme from the themes dir. 2 | theme: serene_ocean # corresponds to two.yaml 3 | use_host_environment: false 4 | response: 5 | show_size_and_time: false 6 | heading: 7 | show_host: false 8 | show_version: false 9 | text_input: 10 | blinking_cursor: false 11 | watch_env_files: false 12 | watch_collection_files: false 13 | watch_themes: false 14 | 15 | -------------------------------------------------------------------------------- /tests/sample-configs/custom_theme2.yaml: -------------------------------------------------------------------------------- 1 | # Use a user-defined theme from the themes dir. 2 | theme: anothertest 3 | use_host_environment: false 4 | response: 5 | show_size_and_time: false 6 | heading: 7 | show_host: false 8 | show_version: false 9 | text_input: 10 | blinking_cursor: false 11 | watch_env_files: false 12 | watch_collection_files: false 13 | watch_themes: false -------------------------------------------------------------------------------- /tests/sample-configs/general.yaml: -------------------------------------------------------------------------------- 1 | use_host_environment: false 2 | load_user_themes: false 3 | response: 4 | show_size_and_time: false 5 | heading: 6 | show_host: false 7 | show_version: false 8 | text_input: 9 | blinking_cursor: false 10 | watch_env_files: false 11 | watch_collection_files: false 12 | watch_themes: false -------------------------------------------------------------------------------- /tests/sample-configs/modified_config.yaml: -------------------------------------------------------------------------------- 1 | theme: galaxy 2 | layout: horizontal 3 | use_host_environment: false 4 | load_user_themes: false 5 | response: 6 | show_size_and_time: false 7 | focus: 8 | on_startup: collection 9 | on_response: body 10 | heading: 11 | visible: false 12 | text_input: 13 | blinking_cursor: false 14 | watch_env_files: false 15 | watch_collection_files: false 16 | watch_themes: false -------------------------------------------------------------------------------- /tests/sample-envs/sample_base.env: -------------------------------------------------------------------------------- 1 | POST_ID=1 2 | USER_ID=2 3 | TODO_ID=1 4 | FILE="base" 5 | ONLY_BASE=true -------------------------------------------------------------------------------- /tests/sample-envs/sample_extra.env: -------------------------------------------------------------------------------- 1 | FILE="extra" 2 | POST_ID=2 3 | ONLY_EXTRA=true -------------------------------------------------------------------------------- /tests/sample-importable-collections/test-postman-collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "Test API", 4 | "description": "A test API", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "variable": [{"key": "baseUrl", "value": "https://api.example.com"}], 8 | "item": [ 9 | { 10 | "name": "Users", 11 | "item": [ 12 | { 13 | "name": "Get Users", 14 | "request": { 15 | "method": "GET", 16 | "url": { 17 | "raw": "{{host}}/api/users?email=example@gmail.com&relations=organization,impersonating_user", 18 | "host": ["{{host}}"], 19 | "path": ["api", "users"], 20 | "query": [ 21 | {"key": "email", "value": "example@gmail.com"}, 22 | { 23 | "key": "relations", 24 | "value": "organization,impersonating_user" 25 | } 26 | ] 27 | } 28 | } 29 | }, 30 | { 31 | "name": "User Details", 32 | "item": [ 33 | { 34 | "name": "Get User", 35 | "request": { 36 | "method": "GET", 37 | "url": { 38 | "raw": "{{baseUrl}}/users/{id}", 39 | "host": ["{{baseUrl}}"], 40 | "path": ["users", "{id}"] 41 | } 42 | } 43 | } 44 | ] 45 | } 46 | ] 47 | } 48 | ] 49 | } -------------------------------------------------------------------------------- /tests/sample-themes/another_test.yml: -------------------------------------------------------------------------------- 1 | name: anothertest 2 | primary: '#2ecc71' 3 | secondary: '#3498db' 4 | accent: '#9b59b6' 5 | background: '#ecf0f1' 6 | surface: '#bdc3c7' 7 | error: '#e74c3c' 8 | warning: '#f39c12' 9 | success: '#27ae60' 10 | panel: '#95a5a6' 11 | 12 | # Extended properties 13 | text_area: 14 | gutter: 'black on red' 15 | cursor: 'reverse' 16 | cursor_line: 'italic' 17 | cursor_line_gutter: 'black on blue' 18 | matched_bracket: 'underline #f1c40f' 19 | selection: 'on #3498db' 20 | 21 | syntax: 22 | json_key: '#8e44ad' 23 | json_string: '#27ae60' 24 | json_number: '#e67e22' 25 | json_boolean: '#e74c3c' 26 | json_null: '#95a5a6' 27 | 28 | url: 29 | base: 'italic #3498db on green' 30 | protocol: 'u bold #9b59b6 on magenta' 31 | separator: 'dim red u' 32 | 33 | variable: 34 | resolved: 'black on #00ff00' 35 | unresolved: 'black on #ff0000' 36 | 37 | # Optional metadata 38 | author: "Your Name" 39 | description: "A vibrant and colorful theme with extended properties" 40 | homepage: "https://github.com/yourusername/posting-themes" -------------------------------------------------------------------------------- /tests/sample-themes/serene_ocean.yaml: -------------------------------------------------------------------------------- 1 | name: serene_ocean 2 | primary: '#1E88E5' # Ocean Blue 3 | secondary: '#00ACC1' # Teal 4 | accent: '#D32F2F' # Crimson Red 5 | background: '#E3F2FD' # Light Sky Blue 6 | surface: '#FFFFFF' # White 7 | error: '#D32F2F' # Crimson Red 8 | success: '#43A047' # Forest Green 9 | text: '#212121' # Almost Black 10 | dark: false 11 | -------------------------------------------------------------------------------- /tests/test_files.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from posting.files import is_valid_filename 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "filename, expected", 7 | [ 8 | ("valid_filename.txt", True), 9 | ("", False), 10 | (" ", False), 11 | ("file/with/path.txt", False), 12 | ("a" * 255, True), 13 | ("a" * 256, False), 14 | ("CON", False), 15 | ("PRN.txt", False), 16 | ("AUX.log", False), 17 | ("NUL.dat", False), 18 | ("COM1.bin", False), 19 | ("LPT1.tmp", False), 20 | ("file..with..dots.txt", False), 21 | (".hidden_file.txt", False), 22 | ("normal.file.txt", True), 23 | ("file-with-dashes.txt", True), 24 | ("file_with_underscores.txt", True), 25 | ("file with spaces.txt", True), 26 | ("file.with.multiple.extensions.txt", True), 27 | # Path traversal attack tests 28 | ("../filename.txt", False), 29 | ("filename/../something.txt", False), 30 | ("foo/../bar/baz.txt", False), 31 | ("foo/./bar/baz.txt", False), 32 | # Absolute path tests 33 | ("/etc/passwd", False), 34 | ("/var/log/system.log", False), 35 | ("/home/user/file.txt", False), 36 | ("/file.txt", False), 37 | ("C:/Program Files/file.txt", False), 38 | ], 39 | ) 40 | def test_is_valid_filename(filename, expected): 41 | assert is_valid_filename(filename) == expected 42 | 43 | 44 | def test_is_valid_filename_with_none(): 45 | assert is_valid_filename(None) is False 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "os_specific_filename, expected", 50 | [ 51 | ("COM0", True), # COM0 is not in the reserved list 52 | ("LPT0", True), # LPT0 is not in the reserved list 53 | ("CON.txt", False), 54 | ("AUX.log", False), 55 | ], 56 | ) 57 | def test_is_valid_filename_os_specific(os_specific_filename, expected): 58 | assert is_valid_filename(os_specific_filename) == expected 59 | -------------------------------------------------------------------------------- /tests/test_open_api_import.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from posting.importing.open_api import import_openapi_spec 5 | 6 | 7 | def test_import(tmp_path: Path): 8 | """Test importing security schemes.""" 9 | spec = { 10 | "openapi": "3.1.0", 11 | "info": {"title": "Test", "version": "1.0", "description": "Test"}, 12 | "paths": { 13 | "/": { 14 | "get": { 15 | "parameters": [ 16 | { 17 | "name": "page", 18 | "in": "query", 19 | }, 20 | { 21 | "name": "account_id", 22 | "in": "header", 23 | "deprecated": True, 24 | }, 25 | ], 26 | "responses": {"200": {"description": "OK"}}, 27 | "security": [ 28 | {"bearerAuth": []}, 29 | ], 30 | } 31 | } 32 | }, 33 | "components": { 34 | "securitySchemes": { 35 | "bearerAuth": { 36 | "type": "http", 37 | "scheme": "bearer", 38 | }, 39 | }, 40 | }, 41 | } 42 | spec_path = tmp_path / "spec.json" 43 | spec_path.write_text(json.dumps(spec)) 44 | collection = import_openapi_spec(spec_path) 45 | 46 | assert len(collection.requests) == 1 47 | 48 | request = collection.requests[0] 49 | assert request.url == "${BASE_URL}/" 50 | assert request.method == "GET" 51 | 52 | assert len(request.params) == 1 53 | param = request.params[0] 54 | assert param.name == "page" 55 | assert param.value == "" 56 | assert param.enabled 57 | 58 | assert len(request.headers) == 1 59 | header = request.headers[0] 60 | assert header.name == "account_id" 61 | assert header.value == "" 62 | assert not header.enabled 63 | 64 | assert request.auth is not None 65 | assert request.auth.type == "bearer_token" 66 | assert request.auth.bearer_token is not None 67 | assert request.auth.bearer_token.token == "${BEARERAUTH_BEARER_TOKEN}" 68 | -------------------------------------------------------------------------------- /tests/test_postman_import.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from posting.collection import Collection, Options, QueryParam, RequestModel, Scripts 3 | from posting.importing.postman import Variable, import_postman_spec 4 | 5 | 6 | def test_import_postman_spec(): 7 | # Write mock Postman spec to a temporary file 8 | collection, postman_collection = import_postman_spec( 9 | spec_path="tests/sample-importable-collections/test-postman-collection.json", 10 | output_path="foo/bar/baz", 11 | ) 12 | 13 | assert collection == Collection( 14 | path=Path("foo/bar/baz"), 15 | name="Test API", 16 | requests=[], 17 | children=[ 18 | Collection( 19 | path=Path("foo/bar/baz/Users"), 20 | name="Users", 21 | requests=[ 22 | RequestModel( 23 | name="Get Users", 24 | description="", 25 | method="GET", 26 | url="$HOST/api/users", 27 | path=Path("foo/bar/baz/Users/GetUsers.posting.yaml"), 28 | body=None, 29 | auth=None, 30 | headers=[], 31 | params=[ 32 | QueryParam( 33 | name="email", value="example@gmail.com", enabled=True 34 | ), 35 | QueryParam( 36 | name="relations", 37 | value="organization,impersonating_user", 38 | enabled=True, 39 | ), 40 | ], 41 | cookies=[], 42 | posting_version="2.6.0", 43 | scripts=Scripts(setup=None, on_request=None, on_response=None), 44 | options=Options( 45 | follow_redirects=True, 46 | verify_ssl=True, 47 | attach_cookies=True, 48 | proxy_url="", 49 | timeout=5.0, 50 | ), 51 | ) 52 | ], 53 | children=[ 54 | Collection( 55 | path=Path("foo/bar/baz/Users/User Details"), 56 | name="User Details", 57 | requests=[ 58 | RequestModel( 59 | name="Get User", 60 | description="", 61 | method="GET", 62 | url="$BASE_URL/users/{id}", 63 | path=Path( 64 | "foo/bar/baz/Users/User Details/GetUser.posting.yaml" 65 | ), 66 | body=None, 67 | auth=None, 68 | headers=[], 69 | params=[], 70 | cookies=[], 71 | posting_version="2.6.0", 72 | scripts=Scripts( 73 | setup=None, on_request=None, on_response=None 74 | ), 75 | options=Options( 76 | follow_redirects=True, 77 | verify_ssl=True, 78 | attach_cookies=True, 79 | proxy_url="", 80 | timeout=5.0, 81 | ), 82 | ) 83 | ], 84 | children=[], 85 | readme=None, 86 | ) 87 | ], 88 | readme=None, 89 | ) 90 | ], 91 | readme="# Test API\n\nA test API\n\nVersion: 2.0.0", 92 | ) 93 | 94 | assert postman_collection.variable == [ 95 | Variable(key="baseUrl", value="https://api.example.com") 96 | ] 97 | -------------------------------------------------------------------------------- /tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from posting.urls import ensure_protocol 2 | 3 | 4 | def test_ensure_protocol(): 5 | assert ensure_protocol("example.com") == "http://example.com" 6 | assert ensure_protocol("https://example.com") == "https://example.com" 7 | assert ensure_protocol("http://example.com") == "http://example.com" 8 | assert ensure_protocol("ftp://example.com") == "ftp://example.com" 9 | assert ensure_protocol("localhost:8000") == "http://localhost:8000" 10 | assert ensure_protocol("localhost") == "http://localhost" 11 | assert ensure_protocol("localhost:8000/path") == "http://localhost:8000/path" 12 | assert ensure_protocol("localhost/path") == "http://localhost/path" 13 | assert ensure_protocol("localhost:8000/path/to/resource") == "http://localhost:8000/path/to/resource" 14 | -------------------------------------------------------------------------------- /tests/test_variables.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from posting.variables import find_variables, variable_range_at_cursor 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "text, expected", 7 | [ 8 | ("", []), # Empty string 9 | ("hello", []), # No variables 10 | ("Hello, $name!", [("name", 7, 12)]), # Single var 11 | ("Hello, $name! $age", [("name", 7, 12), ("age", 14, 18)]), # Multiple vars 12 | ("Hello, $$foo $bar", [("bar", 13, 17)]), # Escaped vars 13 | ("$hello", [("hello", 0, 6)]), # Single var at start 14 | ("$", []), # Empty var name 15 | ("$$", []), # No variables 16 | ("${hello}", [("hello", 0, 8)]), # Variable with braces 17 | ( 18 | "${hello} ${world}", 19 | [("hello", 0, 8), ("world", 9, 17)], 20 | ), # Multiple vars with braces 21 | ("$${hello}", []), # Escaped braces 22 | ("${}", []), # Empty braces 23 | ], 24 | ) 25 | def test_find_variables(text: str, expected: list[tuple[str, int, int]]): 26 | assert find_variables(text) == expected 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "text, cursor, expected", 31 | [ 32 | # Basic checks 33 | ("", 0, None), 34 | ("", 1, None), # Out of bounds 35 | ("", -1, None), # Out of bounds 36 | ("Hello, $name!", 0, None), 37 | ("Hello, $name!", 7, None), 38 | ("Hello, $name!", 8, (7, 12)), 39 | ("Hello, $name!", 9, (7, 12)), 40 | ("Hello, $name!", 10, (7, 12)), 41 | ("Hello, $name!", 11, (7, 12)), 42 | ("Hello, $name!", 12, None), 43 | ("Hello, $name!", 13, None), 44 | # Escaped variables 45 | ("Hello, $$name!", 0, None), 46 | ("Hello, $$name!", 7, None), 47 | ("Hello, $$name!", 8, None), 48 | ("Hello, $$name!", 9, None), 49 | ("Hello, $$name!", 10, None), 50 | ("Hello, $$name!", 11, None), 51 | ("Hello, $$name!", 12, None), 52 | # Braces 53 | ("${}", 0, None), 54 | ("${name}", 0, None), 55 | ("${name}", 1, (0, 7)), 56 | ("${name}", 2, (0, 7)), 57 | # Empty Braces 58 | ("${}", 0, None), 59 | ("${}", 1, None), 60 | ("${}", -1, None), 61 | ("${}", 2, None), 62 | ("${}", 3, None), 63 | # Escaped braces 64 | ("$${name}", 0, None), 65 | ("$${name}", 1, None), 66 | ("$${name}", 2, None), 67 | ("$${name}", 3, None), 68 | ("$${name}", 4, None), 69 | ("$${name}", 5, None), 70 | ], 71 | ) 72 | def test_variable_range_at_cursor( 73 | text: str, cursor: int, expected: tuple[int, int] | None 74 | ): 75 | assert variable_range_at_cursor(cursor, text) == expected 76 | --------------------------------------------------------------------------------