diff options
34 files changed, 2945 insertions, 0 deletions
diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..2332963 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + port_for/_download_ranges.py + port_for/docopt.py diff --git a/.github/PULL_REQUEST_TEMPLATE.rst b/.github/PULL_REQUEST_TEMPLATE.rst new file mode 100644 index 0000000..910a482 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.rst @@ -0,0 +1,5 @@ +Chore that needs to be done: + +* [ ] Add newsfragment `pipenv run towncrier create [issue_number].[type].rst` + +Types are defined in the pyproject.toml, issue_numer either from issue tracker or the Pull request number
\ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1173e0c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily + time: "04:00" + open-pull-requests-limit: 1 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + time: "02:00" + open-pull-requests-limit: 1
\ No newline at end of file diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..aa0e102 --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,39 @@ +name: Merge me test dependencies! + +on: + workflow_run: + types: + - completed + workflows: + # List all required workflow names here. + - 'Run linters' + - 'Run tests' + - 'Run tests on macos' + - 'Test build package' + - 'Run test commands' + +jobs: + merge-me: + name: Merge me! + runs-on: ubuntu-latest + steps: + - # It is often a desired behavior to merge only when a workflow execution + # succeeds. This can be changed as needed. + if: ${{ github.event.workflow_run.conclusion == 'success' }} + name: Merge me! + uses: ridedott/merge-me-action@v2 + with: + # Depending on branch protection rules, a manually populated + # `GITHUB_TOKEN_WORKAROUND` secret with permissions to push to + # a protected branch must be used. This secret can have an arbitrary + # name, as an example, this repository uses `DOTTBOTT_TOKEN`. + # + # When using a custom token, it is recommended to leave the following + # comment for other developers to be aware of the reasoning behind it: + # + # This must be used as GitHub Actions token does not support pushing + # to protected branches. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MERGE_METHOD: MERGE + PRESET: DEPENDABOT_MINOR + ENABLED_FOR_MANUAL_CHANGES: 'true' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ebaa45f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,11 @@ +name: Test build package + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + uses: fizyk/actions-reuse/.github/workflows/pypi.yml@v2.1.2 diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 0000000..dec9e2e --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,18 @@ +name: Run linters + +on: + push: + branches: [ master ] + paths: + - '**.py' + - .github/workflows/linters.yml + - requirements-lint.txt + pull_request: + branches: [ master ] +jobs: + lint: + uses: fizyk/actions-reuse/.github/workflows/linters-python.yml@v2.1.2 + with: + pipenv: true + mypy: true + pydocstyle: false
\ No newline at end of file diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..758fd46 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,27 @@ +name: Run test commands + +on: + pull_request: + branches: [ master ] + +jobs: + bump: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - uses: fizyk/actions-reuse/.github/actions/pipenv@v2.1.2 + with: + python-version: "3.11" + pipenv-install-options: "--skip-lock" + command: tbump --dry-run --only-patch $(pipenv run tbump current-version)"-x" + towncrier: + runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' }} + steps: + - uses: fizyk/actions-reuse/.github/actions/pipenv@v2.1.2 + with: + python-version: "3.11" + pipenv-install-options: "--skip-lock" + command: towncrier check --compare-with origin/master + fetch-depth: 0 diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 0000000..f90d2ce --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,12 @@ +name: Package and publish +on: + push: + tags: + - v* +jobs: + build: + uses: fizyk/actions-reuse/.github/workflows/pypi.yml@v2.1.2 + with: + publish: true + secrets: + pypi_token: ${{ secrets.pypi_token }} diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml new file mode 100644 index 0000000..4732209 --- /dev/null +++ b/.github/workflows/tests-macos.yml @@ -0,0 +1,17 @@ +name: Run tests on macos + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + + +jobs: + tests: + uses: fizyk/actions-reuse/.github/workflows/tests-pytests.yml@v2.1.2 + with: + python-versions: '["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.8"]' + os: macos-latest + pipenv: true + pipenv-install-options: --skip-lock diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..adbb036 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,15 @@ +name: Run tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + tests: + uses: fizyk/actions-reuse/.github/workflows/tests-pytests.yml@v2.1.2 + with: + python-versions: '["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.8"]' + pipenv: true + pipenv-install-options: --skip-lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f46f8ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.pyc +build/ +dist/ +*.egg-info/ +.tox +.idea +htmlcov +.coverage +.cache +.ipynb_checkpoints/ +MANIFEST +coverage +venv/
\ No newline at end of file diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..3f8c3f2 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,68 @@ +CHANGELOG +========= + +.. towncrier release notes start + +0.7.1 (2023-07-14) +================== + +Features +-------- + +- Add `PortType` type alias for easier typing related code (`#149 <https://github.com/kmike/port-for/issues/149>`_) + + +0.7.0 (2023-06-15) +================== + +Features +-------- + +- get_port will now allow passing additional exclude_ports parameter - these ports will not be chosen. (`#143 <https://github.com/kmike/port-for/issues/143>`_) + + +0.6.3 (2022-12-15) +================== + +Features +-------- + +- Add python 3.11 to the list of supported python versions. (`#111 <https://github.com/kmike/port-for/issues/111>`_) + + +Miscellaneus +------------ + +- Use towncrier as a changelog management tool. (`#107 <https://github.com/kmike/port-for/issues/107>`_) +- Moved development dependencies to be managed by pipenv. + All development process can be managed with it - which means automatic isolation. (`#108 <https://github.com/kmike/port-for/issues/108>`_) +- Migrate versioning tool to tbump, and move package definition to pyproject.toml (`#109 <https://github.com/kmike/port-for/issues/109>`_) +- Moved as much of the setup.cfg settings into the pyproject.toml as possible. + Dropped pydocstyle support. (`#112 <https://github.com/kmike/port-for/issues/112>`_) + + +0.6.2 +---------- + +Misc +++++ + +- Added Python 3.10 to trove classifiers and to CI + +0.6.1 +---------- + +Bugfix +++++++ + +- Fixed typing definition for get_port function + +0.6.0 +---------- + +Feature ++++++++ + +- Added `get_port` helper that can randomly select open port out of given set, or range-tuple +- Added type annotations and compatibility with PEP 561 +- Support only python 3.7 and up diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f361884 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2012 Mikhail Korobov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9b0e932 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include *.txt +include *.rst +recursive-include docs *.txt +recursive-include scripts *
\ No newline at end of file @@ -0,0 +1,16 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] +towncrier = "==23.6.0" +pytest = "==7.4.0" +pytest-cov = "==4.1.0" +coverage = "==7.2.7" +black = "==23.7.0" +pycodestyle = "==2.10.0" +mypy = "==1.4.1" +tbump = "==6.10.0" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..9776937 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,388 @@ +{ + "_meta": { + "hash": { + "sha256": "643de800a20bbcc68b7b1d0fe879c91aab21620df5e3b74e70b7c54811746f7d" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": {}, + "develop": { + "black": { + "hashes": [ + "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3", + "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb", + "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087", + "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320", + "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6", + "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3", + "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc", + "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f", + "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587", + "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91", + "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a", + "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad", + "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926", + "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9", + "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be", + "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd", + "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96", + "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491", + "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2", + "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a", + "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f", + "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995" + ], + "index": "pypi", + "version": "==23.7.0" + }, + "cli-ui": { + "hashes": [ + "sha256:2f67e50cf474e76ad160c3e660bbad98bf8b8dfb8d847765f3a261b7e13c05fa", + "sha256:6a1ebdbbcd83a0fa06b2f63f4434082a3ba8664aebedd91f1ff86b9e4289d53e" + ], + "markers": "python_version >= '3.7' and python_version < '4.0'", + "version": "==0.17.2" + }, + "click": { + "hashes": [ + "sha256:2739815aaa5d2c986a88f1e9230c55e17f0caad3d958a5e13ad0797c166db9e3", + "sha256:b97d0c74955da062a7d4ef92fadb583806a585b2ea81958a81bd72726cbb8e37" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.4" + }, + "click-default-group": { + "hashes": [ + "sha256:d9560e8e8dfa44b3562fbc9425042a0fd6d21956fcc2db0077f63f34253ab904" + ], + "version": "==1.2.2" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, + "contextlib2": { + "hashes": [ + "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f", + "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869" + ], + "markers": "python_version >= '3.6'", + "version": "==21.6.0" + }, + "coverage": { + "hashes": [ + "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f", + "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2", + "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a", + "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a", + "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01", + "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6", + "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7", + "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f", + "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02", + "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c", + "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063", + "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a", + "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5", + "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959", + "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97", + "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6", + "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f", + "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9", + "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5", + "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f", + "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562", + "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe", + "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9", + "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f", + "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb", + "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb", + "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1", + "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb", + "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250", + "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e", + "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511", + "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5", + "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59", + "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2", + "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d", + "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3", + "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4", + "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de", + "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9", + "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833", + "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0", + "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9", + "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d", + "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050", + "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d", + "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6", + "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353", + "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb", + "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e", + "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8", + "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495", + "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2", + "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd", + "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27", + "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1", + "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818", + "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4", + "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e", + "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850", + "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3" + ], + "index": "pypi", + "version": "==7.2.7" + }, + "docopt": { + "hashes": [ + "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" + ], + "version": "==0.6.2" + }, + "incremental": { + "hashes": [ + "sha256:912feeb5e0f7e0188e6f42241d2f450002e11bbc0937c65865045854c24c0bd0", + "sha256:b864a1f30885ee72c5ac2835a761b8fe8aa9c28b9395cacf27286602688d3e51" + ], + "version": "==22.10.0" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "jinja2": { + "hashes": [ + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.2" + }, + "markupsafe": { + "hashes": [ + "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e", + "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e", + "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", + "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", + "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", + "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc", + "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c", + "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0", + "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4", + "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9", + "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", + "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba", + "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d", + "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3", + "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", + "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155", + "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac", + "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52", + "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", + "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8", + "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b", + "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24", + "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea", + "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198", + "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0", + "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee", + "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be", + "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2", + "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707", + "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", + "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58", + "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", + "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636", + "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", + "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad", + "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee", + "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc", + "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2", + "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48", + "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7", + "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e", + "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b", + "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa", + "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5", + "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e", + "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb", + "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", + "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", + "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", + "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.3" + }, + "mypy": { + "hashes": [ + "sha256:01fd2e9f85622d981fd9063bfaef1aed6e336eaacca00892cd2d82801ab7c042", + "sha256:0dde1d180cd84f0624c5dcaaa89c89775550a675aff96b5848de78fb11adabcd", + "sha256:141dedfdbfe8a04142881ff30ce6e6653c9685b354876b12e4fe6c78598b45e2", + "sha256:16f0db5b641ba159eff72cff08edc3875f2b62b2fa2bc24f68c1e7a4e8232d01", + "sha256:190b6bab0302cec4e9e6767d3eb66085aef2a1cc98fe04936d8a42ed2ba77bb7", + "sha256:2460a58faeea905aeb1b9b36f5065f2dc9a9c6e4c992a6499a2360c6c74ceca3", + "sha256:34a9239d5b3502c17f07fd7c0b2ae6b7dd7d7f6af35fbb5072c6208e76295816", + "sha256:43b592511672017f5b1a483527fd2684347fdffc041c9ef53428c8dc530f79a3", + "sha256:43d24f6437925ce50139a310a64b2ab048cb2d3694c84c71c3f2a1626d8101dc", + "sha256:45d32cec14e7b97af848bddd97d85ea4f0db4d5a149ed9676caa4eb2f7402bb4", + "sha256:470c969bb3f9a9efcedbadcd19a74ffb34a25f8e6b0e02dae7c0e71f8372f97b", + "sha256:566e72b0cd6598503e48ea610e0052d1b8168e60a46e0bfd34b3acf2d57f96a8", + "sha256:5703097c4936bbb9e9bce41478c8d08edd2865e177dc4c52be759f81ee4dd26c", + "sha256:7549fbf655e5825d787bbc9ecf6028731973f78088fbca3a1f4145c39ef09462", + "sha256:8207b7105829eca6f3d774f64a904190bb2231de91b8b186d21ffd98005f14a7", + "sha256:8c4d8e89aa7de683e2056a581ce63c46a0c41e31bd2b6d34144e2c80f5ea53dc", + "sha256:98324ec3ecf12296e6422939e54763faedbfcc502ea4a4c38502082711867258", + "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b", + "sha256:9d40652cc4fe33871ad3338581dca3297ff5f2213d0df345bcfbde5162abf0c9", + "sha256:a2746d69a8196698146a3dbe29104f9eb6a2a4d8a27878d92169a6c0b74435b6", + "sha256:ae704dcfaa180ff7c4cfbad23e74321a2b774f92ca77fd94ce1049175a21c97f", + "sha256:bfdca17c36ae01a21274a3c387a63aa1aafe72bff976522886869ef131b937f1", + "sha256:c482e1246726616088532b5e964e39765b6d1520791348e6c9dc3af25b233828", + "sha256:ca637024ca67ab24a7fd6f65d280572c3794665eaf5edcc7e90a866544076878", + "sha256:e02d700ec8d9b1859790c0475df4e4092c7bf3272a4fd2c9f33d87fac4427b8f", + "sha256:e5952d2d18b79f7dc25e62e014fe5a23eb1a3d2bc66318df8988a01b1a037c5b" + ], + "index": "pypi", + "version": "==1.4.1" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", + "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + ], + "markers": "python_version >= '3.7'", + "version": "==23.1" + }, + "pathspec": { + "hashes": [ + "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687", + "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293" + ], + "markers": "python_version >= '3.7'", + "version": "==0.11.1" + }, + "platformdirs": { + "hashes": [ + "sha256:cec7b889196b9144d088e4c57d9ceef7374f6c39694ad1577a0aab50d27ea28c", + "sha256:f87ca4fcff7d2b0f81c6a748a77973d7af0f4d526f98f308477c3c436c74d528" + ], + "markers": "python_version >= '3.7'", + "version": "==3.8.1" + }, + "pluggy": { + "hashes": [ + "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", + "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", + "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" + ], + "index": "pypi", + "version": "==2.10.0" + }, + "pytest": { + "hashes": [ + "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32", + "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a" + ], + "index": "pypi", + "version": "==7.4.0" + }, + "pytest-cov": { + "hashes": [ + "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", + "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" + ], + "index": "pypi", + "version": "==4.1.0" + }, + "schema": { + "hashes": [ + "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197", + "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c" + ], + "version": "==0.7.5" + }, + "tabulate": { + "hashes": [ + "sha256:0ba055423dbaa164b9e456abe7920c5e8ed33fcc16f6d1b2f2d152c8e1e8b4fc", + "sha256:436f1c768b424654fce8597290d2764def1eea6a77cfa5c33be00b1bc0f4f63d", + "sha256:6c57f3f3dd7ac2782770155f3adb2db0b1a269637e42f27599925e64b114f519" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.8.10" + }, + "tbump": { + "hashes": [ + "sha256:170a4395d167daee357cb96af5e874119c470feaba9f605e73f3426e768c2542", + "sha256:9ebf5d69bc92ca8be1afb13a80f51e374526cb9988f4c3b167036a9e8a10a684" + ], + "index": "pypi", + "version": "==6.10.0" + }, + "tomlkit": { + "hashes": [ + "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171", + "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3" + ], + "markers": "python_version >= '3.7'", + "version": "==0.11.8" + }, + "towncrier": { + "hashes": [ + "sha256:da552f29192b3c2b04d630133f194c98e9f14f0558669d427708e203fea4d0a5", + "sha256:fc29bd5ab4727c8dacfbe636f7fb5dc53b99805b62da1c96b214836159ff70c1" + ], + "index": "pypi", + "version": "==23.6.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", + "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" + ], + "markers": "python_version >= '3.7'", + "version": "==4.7.1" + }, + "unidecode": { + "hashes": [ + "sha256:547d7c479e4f377b430dd91ac1275d593308dce0fc464fb2ab7d41f82ec653be", + "sha256:fed09cf0be8cf415b391642c2a5addfc72194407caee4f98719e40ec2a72b830" + ], + "markers": "python_version >= '3.5'", + "version": "==1.3.6" + } + } +} diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..d863f61 --- /dev/null +++ b/README.rst @@ -0,0 +1,170 @@ +======== +port-for +======== + +.. image:: https://img.shields.io/pypi/v/port-for.svg + :target: https://pypi.python.org/pypi/port-for + :alt: PyPI Version + +.. image:: http://codecov.io/github/kmike/port-for/coverage.svg?branch=master + :target: http://codecov.io/github/kmike/port-for?branch=master + :alt: Code Coverage + + +``port-for`` is a command-line utility and a python library that +helps with local TCP ports management. + +It can find an unused TCP localhost port and remember the association:: + + $ sudo port-for foo + 37987 + +This can be useful when you are installing a stack of software +with multiple parts needing port numbers. + +.. note:: + + If you're looking for a temporary port then ``socket.bind((host, 0))`` + is your best bet:: + + >>> import socket + >>> s = socket.socket() + >>> s.bind(("", 0)) + >>> s.getsockname() + ('0.0.0.0', 54485) + + ``port-for`` is necessary when you need *persistent* free local port number. + + ``port-for`` is the exact opposite of ``s.bind((host, 0))`` + in the sense that it shouldn't return ports that ``s.bind((host, 0))`` + may return (because such ports are likely to be temporary used by OS). + + +There are several rules ``port-for`` is trying to follow to find and +return a new unused port: + +1) Port must be unused: ``port-for`` checks this by trying to connect + to the port and to bind to it. + +2) Port must be IANA unassigned and otherwise not well-known: + this is acheived by maintaining unassigned ports list + (parsed from IANA and Wikipedia). + +3) Port shouldn't be inside ephemeral port range. + This is important because ports from ephemeral port range can + be assigned temporary by OS (e.g. by machine's IP stack) and + this may prevent service restart in some circumstances. + ``port-for`` doesn't return ports from ephemeral port ranges + configured at the current machine. + +4) Other heuristics are also applied: ``port-for`` tries to return + a port from larger port ranges; it also doesn't return ports that are + too close to well-known ports. + +Installation +============ + +System-wide using easy_install (something like ``python-setuptools`` +should be installed):: + + sudo pip install port-for + +or:: + + sudo easy_install port-for + +or inside a virtualenv:: + + pip install port-for + +Script usage +============ + +``port-for <foo>`` script finds an unused port and associates +it with ``<foo>``. Subsequent calls return the same port number. + +This utility doesn't actually bind the port or otherwise prevents the +port from being taken by another software. It tries to select +a port that is less likely to be used by another software +(and that is unused at the time of calling of course). Utility also makes +sure that ``port-for bar`` won't return the same port as ``port-for foo`` +on the same machine. + +:: + + $ sudo port-for foo + 37987 + + $ port-for foo + 37987 + +You may want to develop some naming conventions (e.g. prefix your app names) +in order to enable multiple sites on the same server:: + + $ sudo port-for example.com/apache + 35456 + +Please note that ``port-for`` script requires read and write access +to ``/etc/port-for.conf``. This usually means regular users can read +port values but sudo is required to associate a new port. + +List all associated ports:: + + $ port-for --list + foo: 37987 + example.com/apache: 35456 + +Remove an association:: + + $ sudo port-for --unbind foo + $ port-for --list + example.com/apache: 35456 + + +Library usage +============= + +:: + + >>> import port_for + >>> port_for.select_random() + 37774 + + >>> port_for.select_random() + 48324 + + >>> 80 in port_for.available_good_ports() + False + + >>> port_for.get_port() + 34455 + + >>> port_for.get_port("1234") + 1234 + + >>> port_for.get_port((2000, 3000)) + 2345 + + >>> port_for.get_port({4001, 4003, 4005}) + 4005 + + >>> port_for.get_port([{4000, 4001}, (4100, 4200)]) + 4111 + +Dig into source code for more. + +Contributing +============ + +Development happens at github: https://github.com/kmike/port-for/ + +Issue tracker: https://github.com/kmike/port-for/issues/new + +Release +======= + +Install pipenv and --dev dependencies first, Then run: + +.. code-block:: + + pipenv run tbump [NEW_VERSION] diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..cd300cd --- /dev/null +++ b/mypy.ini @@ -0,0 +1,25 @@ +[mypy] +allow_redefinition = False +allow_untyped_globals = False +check_untyped_defs = True +disallow_incomplete_defs = True +disallow_subclassing_any = True +disallow_untyped_calls = True +disallow_untyped_decorators = True +disallow_untyped_defs = True +follow_imports = silent +ignore_missing_imports = False +implicit_reexport = False +no_implicit_optional = True +pretty = True +show_error_codes = True +strict_equality = True +warn_no_return = True +warn_return_any = True +warn_unreachable = True +warn_unused_ignores = True + +# Bundled third-party package. +[mypy-port_for.docopt.*] +check_untyped_defs = False +disallow_untyped_defs = False diff --git a/newsfragments/.gitignore b/newsfragments/.gitignore new file mode 100644 index 0000000..b722e9e --- /dev/null +++ b/newsfragments/.gitignore @@ -0,0 +1 @@ +!.gitignore
\ No newline at end of file diff --git a/port_for/__init__.py b/port_for/__init__.py new file mode 100644 index 0000000..15c664d --- /dev/null +++ b/port_for/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +"""port_for package.""" +__version__ = "0.7.1" + +from ._ranges import UNASSIGNED_RANGES +from .api import ( + available_good_ports, + available_ports, + is_available, + good_port_ranges, + port_is_used, + select_random, + get_port, +) +from .store import PortStore +from .exceptions import PortForException + +__all__ = ( + "UNASSIGNED_RANGES", + "available_good_ports", + "available_ports", + "is_available", + "good_port_ranges", + "port_is_used", + "select_random", + "get_port", + "PortStore", + "PortForException", +) diff --git a/port_for/_download_ranges.py b/port_for/_download_ranges.py new file mode 100644 index 0000000..5e6a8fb --- /dev/null +++ b/port_for/_download_ranges.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +This module/script is for updating port_for._ranges with recent information +from IANA and Wikipedia. +""" +import sys +import os +import re +import datetime +from urllib.request import Request, urlopen +from xml.etree import ElementTree +from typing import Set, Iterator, Iterable, Tuple + +from port_for.utils import to_ranges, ranges_to_set + +name = os.path.abspath( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) +sys.path.insert(0, name) + +IANA_DOWNLOAD_URL = ( + "https://www.iana.org/assignments" + "/service-names-port-numbers/service-names-port-numbers.xml" +) +IANA_NS = "http://www.iana.org/assignments" +WIKIPEDIA_PAGE = "http://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers" + + +def _write_unassigned_ranges(out_filename: str) -> None: + """ + Downloads ports data from IANA & Wikipedia and converts + it to a python module. This function is used to generate _ranges.py. + """ + with open(out_filename, "wt") as f: + f.write( + "# auto-generated by port_for._download_ranges (%s)\n" + % datetime.date.today() + ) + f.write("UNASSIGNED_RANGES = [\n") + for range in to_ranges(sorted(list(_unassigned_ports()))): + f.write(" (%d, %d),\n" % range) + f.write("]\n") + + +def _unassigned_ports() -> Set[int]: + """Return a set of all unassigned ports (according to IANA and Wikipedia)""" + free_ports = ranges_to_set(_parse_ranges(_iana_unassigned_port_ranges())) + known_ports = ranges_to_set(_wikipedia_known_port_ranges()) + return free_ports.difference(known_ports) + + +def _wikipedia_known_port_ranges() -> Iterator[Tuple[int, int]]: + """ + Returns used port ranges according to Wikipedia page. + This page contains unofficial well-known ports. + """ + req = Request(WIKIPEDIA_PAGE, headers={"User-Agent": "Magic Browser"}) + page = urlopen(req).read().decode("utf8") + + # just find all numbers in table cells + ports = re.findall(r"<td>((\d+)(\W(\d+))?)</td>", page, re.U) + return ((int(p[1]), int(p[3] if p[3] else p[1])) for p in ports) + + +def _iana_unassigned_port_ranges() -> Iterator[str]: + """ + Returns unassigned port ranges according to IANA. + """ + page = urlopen(IANA_DOWNLOAD_URL).read() + xml = ElementTree.fromstring(page) + records = xml.findall("{%s}record" % IANA_NS) + for record in records: + description_el = record.find("{%s}description" % IANA_NS) + assert description_el is not None + description = description_el.text + if description == "Unassigned": + number_el = record.find("{%s}number" % IANA_NS) + assert number_el is not None + numbers = number_el.text + assert numbers is not None + yield numbers + + +def _parse_ranges(ranges: Iterable[str]) -> Iterator[Tuple[int, int]]: + """Converts a list of string ranges to a list of [low, high] tuples.""" + for txt in ranges: + if "-" in txt: + low, high = txt.split("-") + else: + low, high = txt, txt + yield int(low), int(high) + + +if __name__ == "__main__": + _write_unassigned_ranges("_ranges.py") diff --git a/port_for/_ranges.py b/port_for/_ranges.py new file mode 100644 index 0000000..abe5435 --- /dev/null +++ b/port_for/_ranges.py @@ -0,0 +1,689 @@ +# auto-generated by port_for._download_ranges (2017-07-19) +UNASSIGNED_RANGES = [ + (28, 28), + (30, 30), + (32, 32), + (34, 34), + (36, 36), + (60, 60), + (258, 258), + (272, 279), + (285, 285), + (288, 299), + (301, 307), + (325, 332), + (334, 343), + (703, 703), + (708, 708), + (717, 728), + (732, 740), + (743, 743), + (745, 746), + (755, 757), + (766, 766), + (768, 768), + (778, 779), + (781, 781), + (784, 799), + (803, 807), + (809, 809), + (811, 827), + (834, 842), + (844, 846), + (849, 852), + (855, 859), + (863, 872), + (874, 885), + (889, 896), + (899, 899), + (904, 909), + (1002, 1007), + (1009, 1009), + (1491, 1491), + (2194, 2194), + (2259, 2259), + (2378, 2378), + (2693, 2693), + (2794, 2794), + (2873, 2873), + (3092, 3092), + (3126, 3126), + (3546, 3546), + (3694, 3694), + (3994, 3994), + (4048, 4048), + (4120, 4120), + (4144, 4144), + (4194, 4196), + (4315, 4315), + (4317, 4319), + (4332, 4332), + (4337, 4339), + (4363, 4365), + (4367, 4367), + (4380, 4388), + (4397, 4399), + (4424, 4424), + (4434, 4440), + (4459, 4483), + (4489, 4499), + (4501, 4501), + (4539, 4544), + (4561, 4562), + (4564, 4565), + (4571, 4572), + (4574, 4589), + (4606, 4609), + (4641, 4657), + (4693, 4699), + (4705, 4710), + (4712, 4712), + (4714, 4724), + (4734, 4736), + (4748, 4748), + (4757, 4773), + (4775, 4783), + (4792, 4799), + (4805, 4826), + (4828, 4836), + (4852, 4866), + (4872, 4875), + (4886, 4893), + (4895, 4898), + (4903, 4911), + (4916, 4935), + (4938, 4939), + (4943, 4948), + (4954, 4968), + (4971, 4979), + (4981, 4983), + (4992, 4998), + (5016, 5019), + (5035, 5036), + (5038, 5041), + (5076, 5077), + (5088, 5089), + (5095, 5098), + (5108, 5110), + (5113, 5113), + (5118, 5119), + (5122, 5123), + (5126, 5132), + (5138, 5144), + (5147, 5149), + (5158, 5160), + (5169, 5171), + (5173, 5189), + (5204, 5208), + (5210, 5214), + (5216, 5220), + (5238, 5241), + (5244, 5244), + (5255, 5263), + (5266, 5268), + (5273, 5279), + (5283, 5297), + (5311, 5311), + (5316, 5316), + (5319, 5319), + (5322, 5342), + (5345, 5348), + (5365, 5393), + (5395, 5396), + (5438, 5442), + (5444, 5444), + (5446, 5449), + (5451, 5452), + (5460, 5460), + (5466, 5467), + (5469, 5469), + (5476, 5479), + (5482, 5494), + (5496, 5497), + (5508, 5516), + (5518, 5549), + (5551, 5552), + (5558, 5564), + (5570, 5572), + (5576, 5578), + (5587, 5596), + (5606, 5617), + (5619, 5626), + (5640, 5645), + (5647, 5655), + (5657, 5665), + (5668, 5669), + (5685, 5686), + (5690, 5692), + (5694, 5695), + (5697, 5699), + (5701, 5704), + (5706, 5712), + (5731, 5740), + (5749, 5749), + (5751, 5754), + (5756, 5756), + (5758, 5765), + (5772, 5776), + (5778, 5779), + (5788, 5792), + (5795, 5799), + (5801, 5812), + (5815, 5840), + (5843, 5858), + (5860, 5862), + (5864, 5867), + (5869, 5880), + (5882, 5882), + (5884, 5899), + (5901, 5909), + (5914, 5930), + (5932, 5937), + (5939, 5962), + (5964, 5967), + (5970, 5983), + (5994, 5998), + (6067, 6067), + (6078, 6079), + (6089, 6098), + (6119, 6120), + (6125, 6128), + (6131, 6132), + (6134, 6135), + (6137, 6139), + (6150, 6158), + (6164, 6199), + (6202, 6208), + (6210, 6221), + (6223, 6224), + (6226, 6226), + (6228, 6229), + (6231, 6239), + (6245, 6250), + (6254, 6254), + (6256, 6256), + (6258, 6259), + (6261, 6261), + (6263, 6266), + (6270, 6299), + (6302, 6305), + (6307, 6314), + (6318, 6319), + (6323, 6323), + (6327, 6342), + (6345, 6345), + (6348, 6349), + (6351, 6354), + (6356, 6359), + (6361, 6362), + (6364, 6369), + (6371, 6378), + (6380, 6381), + (6383, 6388), + (6391, 6399), + (6411, 6416), + (6422, 6431), + (6433, 6435), + (6438, 6441), + (6447, 6454), + (6457, 6463), + (6465, 6470), + (6472, 6479), + (6490, 6499), + (6504, 6504), + (6512, 6512), + (6516, 6521), + (6524, 6540), + (6545, 6546), + (6552, 6555), + (6557, 6557), + (6559, 6559), + (6562, 6565), + (6569, 6570), + (6572, 6578), + (6584, 6599), + (6603, 6618), + (6630, 6631), + (6637, 6639), + (6641, 6652), + (6654, 6654), + (6658, 6659), + (6674, 6677), + (6680, 6686), + (6691, 6695), + (6698, 6698), + (6700, 6702), + (6707, 6713), + (6717, 6766), + (6772, 6776), + (6779, 6782), + (6792, 6800), + (6802, 6816), + (6818, 6830), + (6832, 6840), + (6843, 6849), + (6851, 6867), + (6870, 6880), + (10011, 10019), + (10021, 10023), + (10026, 10041), + (10043, 10049), + (10052, 10054), + (10056, 10079), + (10082, 10099), + (10105, 10106), + (10108, 10109), + (10112, 10112), + (10118, 10124), + (10126, 10127), + (10130, 10159), + (10163, 10171), + (10173, 10199), + (10205, 10211), + (10213, 10251), + (10254, 10259), + (10262, 10287), + (10289, 10300), + (10303, 10307), + (10309, 10320), + (10322, 10438), + (10440, 10479), + (10481, 10499), + (10501, 10504), + (10506, 10513), + (10515, 10539), + (10545, 10547), + (10549, 10630), + (10632, 10799), + (10801, 10804), + (10806, 10808), + (10811, 10822), + (10824, 10859), + (10861, 10879), + (10881, 10890), + (10892, 10932), + (10934, 10989), + (10991, 10999), + (11002, 11022), + (11024, 11094), + (11096, 11102), + (11107, 11107), + (11113, 11154), + (11156, 11160), + (11166, 11170), + (11176, 11200), + (11203, 11207), + (11209, 11210), + (11212, 11213), + (11216, 11234), + (11236, 11310), + (11312, 11318), + (11322, 11366), + (11368, 11370), + (11372, 11429), + (11431, 11488), + (11490, 11575), + (11577, 11599), + (11601, 11622), + (11624, 11719), + (11721, 11722), + (11724, 11750), + (11752, 11752), + (11754, 11795), + (11797, 11875), + (11878, 11949), + (11952, 11966), + (11968, 11996), + (12014, 12029), + (12033, 12108), + (12110, 12120), + (12122, 12167), + (12169, 12171), + (12173, 12200), + (12202, 12221), + (12224, 12299), + (12301, 12301), + (12303, 12320), + (12323, 12344), + (12346, 12442), + (12444, 12488), + (12490, 12752), + (12754, 12864), + (12866, 12974), + (12976, 13000), + (13002, 13007), + (13009, 13074), + (13076, 13159), + (13161, 13194), + (13197, 13215), + (13219, 13222), + (13225, 13399), + (13401, 13719), + (13723, 13723), + (13725, 13781), + (13784, 13784), + (13787, 13817), + (13824, 13893), + (13895, 13928), + (13931, 13999), + (14003, 14032), + (14035, 14140), + (14144, 14144), + (14146, 14148), + (14151, 14153), + (14155, 14249), + (14251, 14413), + (14415, 14499), + (14501, 14549), + (14551, 14566), + (14568, 14899), + (14901, 14935), + (14938, 14999), + (15001, 15001), + (15003, 15117), + (15119, 15344), + (15346, 15362), + (15364, 15554), + (15557, 15566), + (15568, 15659), + (15661, 15739), + (15741, 15997), + (16004, 16019), + (16022, 16079), + (16081, 16160), + (16163, 16199), + (16201, 16224), + (16226, 16249), + (16251, 16260), + (16262, 16299), + (16301, 16308), + (16312, 16359), + (16362, 16366), + (16369, 16383), + (16388, 16392), + (16473, 16481), + (16483, 16566), + (16568, 16618), + (16620, 16664), + (16667, 16788), + (16790, 16899), + (16901, 16949), + (16951, 16990), + (16996, 17006), + (17008, 17010), + (17012, 17183), + (17186, 17218), + (17226, 17233), + (17236, 17499), + (17501, 17554), + (17556, 17728), + (17730, 17753), + (17757, 17776), + (17778, 17999), + (18001, 18090), + (18093, 18103), + (18105, 18135), + (18137, 18180), + (18188, 18199), + (18202, 18205), + (18207, 18240), + (18244, 18261), + (18263, 18299), + (18302, 18305), + (18307, 18332), + (18334, 18399), + (18402, 18462), + (18464, 18504), + (18507, 18604), + (18607, 18633), + (18636, 18667), + (18669, 18768), + (18770, 18880), + (18882, 18887), + (18889, 18999), + (19002, 19006), + (19008, 19019), + (19021, 19131), + (19133, 19149), + (19151, 19190), + (19192, 19193), + (19195, 19219), + (19221, 19225), + (19227, 19282), + (19284, 19293), + (19296, 19301), + (19303, 19314), + (19316, 19397), + (19399, 19409), + (19413, 19538), + (19542, 19787), + (19789, 19811), + (19815, 19997), + (20004, 20004), + (20006, 20011), + (20015, 20033), + (20035, 20045), + (20047, 20047), + (20050, 20056), + (20058, 20166), + (20168, 20201), + (20203, 20221), + (20223, 20479), + (20481, 20559), + (20561, 20594), + (20596, 20669), + (20671, 20701), + (20703, 20719), + (20721, 20789), + (20791, 20807), + (20809, 20998), + (21001, 21009), + (21011, 21024), + (21026, 21220), + (21222, 21552), + (21555, 21589), + (21591, 21799), + (21801, 21844), + (21850, 21999), + (22006, 22124), + (22126, 22127), + (22129, 22135), + (22137, 22221), + (22223, 22272), + (22274, 22304), + (22306, 22334), + (22336, 22342), + (22344, 22346), + (22348, 22348), + (22352, 22536), + (22538, 22554), + (22556, 22762), + (22764, 22799), + (22801, 22950), + (22952, 22999), + (23006, 23052), + (23054, 23072), + (23074, 23271), + (23273, 23283), + (23285, 23293), + (23295, 23332), + (23334, 23398), + (23403, 23455), + (23458, 23512), + (23514, 23545), + (23547, 23999), + (24007, 24241), + (24243, 24248), + (24250, 24320), + (24323, 24385), + (24387, 24440), + (24442, 24443), + (24445, 24464), + (24466, 24553), + (24555, 24576), + (24578, 24665), + (24667, 24675), + (24679, 24679), + (24681, 24753), + (24755, 24799), + (24801, 24841), + (24843, 24849), + (24851, 24921), + (24923, 24999), + (25011, 25104), + (25106, 25470), + (25472, 25559), + (25561, 25564), + (25566, 25569), + (25571, 25574), + (25577, 25603), + (25605, 25792), + (25794, 25825), + (25827, 25827), + (25841, 25887), + (25889, 25899), + (25904, 25953), + (25956, 25998), + (26001, 26132), + (26134, 26207), + (26209, 26256), + (26258, 26259), + (26265, 26485), + (26488, 26488), + (26490, 26899), + (26902, 26949), + (26951, 26999), + (27051, 27344), + (27346, 27373), + (27375, 27441), + (27443, 27499), + (27911, 27949), + (27951, 27959), + (27970, 27998), + (28002, 28014), + (28016, 28118), + (28120, 28199), + (28201, 28239), + (28241, 28588), + (28590, 28769), + (28772, 28784), + (28787, 28851), + (28853, 28909), + (28911, 28959), + (28961, 28999), + (29001, 29069), + (29071, 29117), + (29119, 29166), + (29170, 29899), + (29902, 29919), + (29921, 29998), + (30005, 30099), + (30101, 30259), + (30261, 30563), + (30565, 30831), + (30833, 30998), + (31000, 31015), + (31017, 31019), + (31021, 31028), + (31030, 31336), + (31338, 31399), + (31401, 31415), + (31417, 31437), + (31439, 31456), + (31458, 31619), + (31621, 31684), + (31686, 31764), + (31766, 31947), + (31950, 32033), + (32035, 32136), + (32138, 32248), + (32250, 32399), + (32401, 32482), + (32484, 32634), + (32637, 32763), + (32765, 32766), + (32778, 32800), + (32802, 32810), + (32812, 32886), + (32888, 32895), + (32897, 32975), + (32977, 33059), + (33061, 33122), + (33124, 33330), + (33332, 33332), + (33335, 33433), + (33435, 33655), + (33657, 33847), + (33849, 33999), + (34001, 34248), + (34250, 34377), + (34380, 34566), + (34568, 34961), + (34965, 34979), + (34981, 34999), + (35007, 35099), + (35101, 35353), + (35358, 36000), + (36002, 36410), + (36413, 36421), + (36425, 36442), + (36445, 36461), + (36463, 36523), + (36525, 36601), + (36603, 36699), + (36701, 36864), + (36866, 37474), + (37476, 37482), + (37484, 37600), + (37602, 37653), + (37655, 37999), + (38003, 38200), + (38204, 38411), + (38413, 38421), + (38423, 38471), + (38473, 38799), + (38801, 38864), + (38866, 39680), + (39682, 39999), + (40001, 40022), + (40024, 40403), + (40405, 40840), + (40844, 40852), + (40854, 41110), + (41112, 41120), + (41122, 41229), + (41231, 41793), + (41798, 42507), + (42511, 42999), + (43001, 43593), + (43596, 44320), + (44323, 44404), + (44406, 44443), + (44445, 44543), + (44545, 44552), + (44554, 44599), + (44601, 44817), + (44819, 44899), + (44901, 44999), + (45003, 45044), + (45046, 45053), + (45055, 45513), + (45515, 45677), + (45679, 45823), + (45826, 45965), + (45967, 46335), + (46337, 46997), + (47002, 47099), + (47101, 47556), + (47558, 47623), + (47625, 47805), + (47807, 47807), + (47810, 47999), + (48006, 48048), + (48051, 48127), + (48130, 48555), + (48557, 48618), + (48620, 48652), + (48654, 48999), + (49002, 49150), +] diff --git a/port_for/api.py b/port_for/api.py new file mode 100644 index 0000000..4ecf9f8 --- /dev/null +++ b/port_for/api.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +import contextlib +import socket +import errno +import random +from itertools import chain +from typing import Optional, Set, List, Tuple, Iterable, TypeVar, Type, Union +from port_for import ephemeral, utils +from ._ranges import UNASSIGNED_RANGES +from .exceptions import PortForException + + +SYSTEM_PORT_RANGE = (0, 1024) + + +def select_random( + ports: Optional[Set[int]] = None, + exclude_ports: Optional[Iterable[int]] = None, +) -> int: + """ + Returns random unused port number. + """ + if ports is None: + ports = available_good_ports() + + if exclude_ports is None: + exclude_ports = set() + + ports.difference_update(set(exclude_ports)) + + for port in random.sample(tuple(ports), min(len(ports), 100)): + if not port_is_used(port): + return port + raise PortForException("Can't select a port") + + +def is_available(port: int) -> bool: + """ + Returns if port is good to choose. + """ + return port in available_ports() and not port_is_used(port) + + +def available_ports( + low: int = 1024, + high: int = 65535, + exclude_ranges: Optional[List[Tuple[int, int]]] = None, +) -> Set[int]: + """ + Returns a set of possible ports (excluding system, + ephemeral and well-known ports). + Pass ``high`` and/or ``low`` to limit the port range. + """ + if exclude_ranges is None: + exclude_ranges = [] + available = utils.ranges_to_set(UNASSIGNED_RANGES) + exclude = utils.ranges_to_set( + # Motivation behind excluding ephemeral port ranges: + # let's say you decided to use an ephemeral local port + # as a persistent port, and "reserve" it to your software. + # OS won't know about it, and still can try to use this port. + # This is not a problem if your service is always running and occupying + # this port (OS would pick next one). But if the service is temporarily + # not using the port (because of restart of other reason), + # OS might reuse the same port, + # which might prevent the service from starting. + ephemeral.port_ranges() + + exclude_ranges + + [SYSTEM_PORT_RANGE, (SYSTEM_PORT_RANGE[1], low), (high, 65536)] + ) + return available.difference(exclude) + + +def good_port_ranges( + ports: Optional[Set[int]] = None, min_range_len: int = 20, border: int = 3 +) -> List[Tuple[int, int]]: + """ + Returns a list of 'good' port ranges. + Such ranges are large and don't contain ephemeral or well-known ports. + Ranges borders are also excluded. + """ + min_range_len += border * 2 + if ports is None: + ports = available_ports() + ranges = utils.to_ranges(list(ports)) + lenghts = sorted([(r[1] - r[0], r) for r in ranges], reverse=True) + long_ranges = [ + length[1] for length in lenghts if length[0] >= min_range_len + ] + without_borders = [ + (low + border, high - border) for low, high in long_ranges + ] + return without_borders + + +def available_good_ports(min_range_len: int = 20, border: int = 3) -> Set[int]: + return utils.ranges_to_set( + good_port_ranges(min_range_len=min_range_len, border=border) + ) + + +def port_is_used(port: int, host: str = "127.0.0.1") -> bool: + """ + Returns if port is used. Port is considered used if the current process + can't bind to it or the port doesn't refuse connections. + """ + unused = _can_bind(port, host) and _refuses_connection(port, host) + return not unused + + +def _can_bind(port: int, host: str) -> bool: + sock = socket.socket() + with contextlib.closing(sock): + try: + sock.bind((host, port)) + except socket.error: + return False + return True + + +def _refuses_connection(port: int, host: str) -> bool: + sock = socket.socket() + with contextlib.closing(sock): + sock.settimeout(1) + err = sock.connect_ex((host, port)) + return err == errno.ECONNREFUSED + + +T = TypeVar("T") + + +def filter_by_type(lst: Iterable, type_of: Type[T]) -> List[T]: + """Returns a list of elements with given type.""" + return [e for e in lst if isinstance(e, type_of)] + + +PortType = Union[ + str, + int, + Tuple[int, int], + Set[int], + List[str], + List[int], + List[Tuple[int, int]], + List[Set[int]], + List[Union[Set[int], Tuple[int, int]]], + List[Union[str, int, Tuple[int, int], Set[int]]], +] + + +def get_port( + ports: Optional[PortType], + exclude_ports: Optional[Iterable[int]] = None, +) -> Optional[int]: + """ + Retuns a random available port. If there's only one port passed + (e.g. 5000 or '5000') function does not check if port is available. + If there's -1 passed as an argument, function returns None. + + :param ports: + exact port (e.g. '8000', 8000) + randomly selected port (None) - any random available port + [(2000,3000)] or (2000,3000) - random available port from a given range + [{4002,4003}] or {4002,4003} - random of 4002 or 4003 ports + [(2000,3000), {4002,4003}] -random of given range and set + :param exclude_ports: A set of known ports that can not be selected. + :returns: a random free port + :raises: ValueError + """ + if ports == -1: + return None + elif not ports: + return select_random(None, exclude_ports) + + try: + return int(ports) # type: ignore[arg-type] + except TypeError: + pass + + ports_set: Set[int] = set() + + try: + if not isinstance(ports, list): + ports = [ports] + ranges: Set[int] = utils.ranges_to_set( + filter_by_type(ports, tuple) # type: ignore[arg-type] + ) + nums: Set[int] = set(filter_by_type(ports, int)) + sets: Set[int] = set( + chain( + *filter_by_type( + ports, (set, frozenset) # type: ignore[arg-type] + ) + ) + ) + ports_set = ports_set.union(ranges, sets, nums) + except ValueError: + raise PortForException( + "Unknown format of ports: %s.\n" + 'You should provide a ports range "[(4000,5000)]"' + 'or "(4000,5000)" or a comma-separated ports set' + '"[{4000,5000,6000}]" or list of ints "[400,5000,6000,8000]"' + 'or all of them "[(20000, 30000), {48889, 50121}, 4000, 4004]"' + % (ports,) + ) + + return select_random(ports_set, exclude_ports) diff --git a/port_for/cmd.py b/port_for/cmd.py new file mode 100644 index 0000000..be28902 --- /dev/null +++ b/port_for/cmd.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +""" +cmd.py is a command-line utility that helps with local TCP ports management. + +It finds 'good' unused TCP localhost port and remembers the association. + +Usage: + port-for <NAME> + port-for --bind <NAME> + port-for --bind <NAME> --port <NUMBER> + port-for <NAME> --port <NUMBER> + port-for --unbind <NAME> + port-for --list + port-for --version + port-for --help + +Options: + -h --help Show this screen. + -v, --version Show version. + -b FOO, --bind FOO Find and return a port for FOO; this is an alias for + 'port-for FOO'. + -p PORT, --port PORT (Optional) specific port number for the --bind command. + -u FOO, --unbind FOO Remove association for FOO. + -l, --list List all associated ports. +""" + +import sys +from typing import Optional +import port_for +from port_for.docopt import docopt + +store = port_for.PortStore() + + +def _list() -> None: + for app, port in store.bound_ports(): + sys.stdout.write("%s: %s\n" % (app, port)) + + +def _bind(app: str, port: Optional[str] = None) -> None: + bound_port = store.bind_port(app, port) + sys.stdout.write("%s\n" % bound_port) + + +def _unbind(app: str) -> None: + store.unbind_port(app) + + +def main() -> None: + """port-for executable entrypoint.""" + args = docopt( + __doc__, + version="port-for %s" % port_for.__version__, + ) # type: ignore[no-untyped-call] + if args["<NAME>"]: + _bind(args["<NAME>"], args["--port"]) + elif args["--bind"]: + _bind(args["--bind"], args["--port"]) + elif args["--list"]: + _list() + elif args["--unbind"]: + _unbind(args["--unbind"]) + + +if __name__ == "__main__": + main() diff --git a/port_for/docopt.py b/port_for/docopt.py new file mode 100644 index 0000000..eff4e2f --- /dev/null +++ b/port_for/docopt.py @@ -0,0 +1,471 @@ +from copy import copy +import sys +import re + + +class DocoptLanguageError(Exception): + """Error in construction of usage-message by developer.""" + + +class DocoptExit(SystemExit): + """Exit in case user invoked program with incorrect arguments.""" + + usage = "" + + def __init__(self, message=""): + SystemExit.__init__(self, (message + "\n" + self.usage).strip()) + + +class Pattern(object): + def __init__(self, *children): + self.children = list(children) + + def __eq__(self, other): + return repr(self) == repr(other) + + def __hash__(self): + return hash(repr(self)) + + def __repr__(self): + return "%s(%s)" % ( + self.__class__.__name__, + ", ".join(repr(a) for a in self.children), + ) + + @property + def flat(self): + if not hasattr(self, "children"): + return [self] + return sum([c.flat for c in self.children], []) + + def fix(self): + self.fix_identities() + self.fix_list_arguments() + return self + + def fix_identities(self, uniq=None): + """Make pattern-tree tips point to same object if they are equal.""" + if not hasattr(self, "children"): + return self + uniq = list(set(self.flat)) if uniq is None else uniq + for i, c in enumerate(self.children): + if not hasattr(c, "children"): + assert c in uniq + self.children[i] = uniq[uniq.index(c)] + else: + c.fix_identities(uniq) + + def fix_list_arguments(self): + """Find arguments that should accumulate values and fix them.""" + either = [list(c.children) for c in self.either.children] + for case in either: + case = [c for c in case if case.count(c) > 1] + for a in [e for e in case if type(e) == Argument]: + a.value = [] + return self + + @property + def either(self): + """Transform pattern into an equivalent, with only top-level Either.""" + # Currently the pattern will not be equivalent, but more "narrow", + # although good enough to reason about list arguments. + if not hasattr(self, "children"): + return Either(Required(self)) + else: + ret = [] + groups = [[self]] + while groups: + children = groups.pop(0) + types = [type(c) for c in children] + if Either in types: + either = [c for c in children if type(c) is Either][0] + children.pop(children.index(either)) + for c in either.children: + groups.append([c] + children) + elif Required in types: + required = [c for c in children if type(c) is Required][0] + children.pop(children.index(required)) + groups.append(list(required.children) + children) + elif Optional in types: + optional = [c for c in children if type(c) is Optional][0] + children.pop(children.index(optional)) + groups.append(list(optional.children) + children) + elif OneOrMore in types: + oneormore = [c for c in children if type(c) is OneOrMore][0] + children.pop(children.index(oneormore)) + groups.append(list(oneormore.children) * 2 + children) + else: + ret.append(children) + return Either(*[Required(*e) for e in ret]) + + +class Argument(Pattern): + def __init__(self, name, value=None): + self.name = name + self.value = value + + def match(self, left, collected=None): + collected = [] if collected is None else collected + args = [arg_left for arg_left in left if type(arg_left) is Argument] + if not len(args): + return False, left, collected + left.remove(args[0]) + if type(self.value) is not list: + return True, left, collected + [Argument(self.name, args[0].value)] + same_name = [ + a for a in collected if type(a) is Argument and a.name == self.name + ] + if len(same_name): + same_name[0].value += [args[0].value] + return True, left, collected + else: + return ( + True, + left, + collected + [Argument(self.name, [args[0].value])], + ) + + def __repr__(self): + return "Argument(%r, %r)" % (self.name, self.value) + + +class Command(Pattern): + def __init__(self, name, value=False): + self.name = name + self.value = value + + def match(self, left, collected=None): + collected = [] if collected is None else collected + args = [arg_left for arg_left in left if type(arg_left) is Argument] + if not len(args) or args[0].value != self.name: + return False, left, collected + left.remove(args[0]) + return True, left, collected + [Command(self.name, True)] + + def __repr__(self): + return "Command(%r, %r)" % (self.name, self.value) + + +class Option(Pattern): + def __init__(self, short=None, long=None, argcount=0, value=False): + assert argcount in (0, 1) + self.short, self.long = short, long + self.argcount, self.value = argcount, value + self.value = None if not value and argcount else value # HACK + + @classmethod + def parse(class_, option_description): + short, long, argcount, value = None, None, 0, False + options, _, description = option_description.strip().partition(" ") + options = options.replace(",", " ").replace("=", " ") + for s in options.split(): + if s.startswith("--"): + long = s + elif s.startswith("-"): + short = s + else: + argcount = 1 + if argcount: + matched = re.findall(r"\[default: (.*)\]", description, flags=re.I) + value = matched[0] if matched else None + return class_(short, long, argcount, value) + + def match(self, left, collected=None): + collected = [] if collected is None else collected + left_ = [] + for arg_left in left: + # if this is so greedy, how to handle OneOrMore then? + if not ( + type(arg_left) is Option + and (self.short, self.long) == (arg_left.short, arg_left.long) + ): + left_.append(arg_left) + return (left != left_), left_, collected + + @property + def name(self): + return self.long or self.short + + def __repr__(self): + return "Option(%r, %r, %r, %r)" % ( + self.short, + self.long, + self.argcount, + self.value, + ) + + +class AnyOptions(Pattern): + def match(self, left, collected=None): + collected = [] if collected is None else collected + left_ = [opt_left for opt_left in left if not type(opt_left) == Option] + return (left != left_), left_, collected + + +class Required(Pattern): + def match(self, left, collected=None): + collected = [] if collected is None else collected + copied_left = copy(left) + c = copy(collected) + for p in self.children: + matched, copied_left, c = p.match(copied_left, c) + if not matched: + return False, left, collected + return True, copied_left, c + + +class Optional(Pattern): + def match(self, left, collected=None): + collected = [] if collected is None else collected + left = copy(left) + for p in self.children: + m, left, collected = p.match(left, collected) + return True, left, collected + + +class OneOrMore(Pattern): + def match(self, left, collected=None): + assert len(self.children) == 1 + collected = [] if collected is None else collected + pattern_left = copy(left) + c = copy(collected) + l_ = None + matched = True + times = 0 + while matched: + # could it be that something didn't match but + # changed pattern_left or c? + matched, pattern_left, c = self.children[0].match(pattern_left, c) + times += 1 if matched else 0 + if l_ == pattern_left: + break + l_ = copy(pattern_left) + if times >= 1: + return True, pattern_left, c + return False, left, collected + + +class Either(Pattern): + def match(self, left, collected=None): + collected = [] if collected is None else collected + outcomes = [] + for p in self.children: + matched, _, _ = outcome = p.match(copy(left), copy(collected)) + if matched: + outcomes.append(outcome) + if outcomes: + return min(outcomes, key=lambda outcome: len(outcome[1])) + return False, left, collected + + +class TokenStream(list): + def __init__(self, source, error): + self += source.split() if type(source) is str else source + self.error = error + + def move(self): + return self.pop(0) if len(self) else None + + def current(self): + return self[0] if len(self) else None + + +def parse_long(tokens, options): + raw, eq, value = tokens.move().partition("=") + value = None if eq == value == "" else value + opt = [o for o in options if o.long and o.long.startswith(raw)] + if len(opt) < 1: + if tokens.error is DocoptExit: + raise tokens.error("%s is not recognized" % raw) + else: + o = Option(None, raw, (1 if eq == "=" else 0)) + options.append(o) + return [o] + if len(opt) > 1: + raise tokens.error( + "%s is not a unique prefix: %s?" + % (raw, ", ".join("%s" % o.long for o in opt)) + ) + opt = copy(opt[0]) + if opt.argcount == 1: + if value is None: + if tokens.current() is None: + raise tokens.error("%s requires argument" % opt.name) + value = tokens.move() + elif value is not None: + raise tokens.error("%s must not have an argument" % opt.name) + opt.value = value or True + return [opt] + + +def parse_shorts(tokens, options): + raw = tokens.move()[1:] + parsed = [] + while raw != "": + opt = [ + o + for o in options + if o.short and o.short.lstrip("-").startswith(raw[0]) + ] + if len(opt) > 1: + raise tokens.error( + "-%s is specified ambiguously %d times" % (raw[0], len(opt)) + ) + if len(opt) < 1: + if tokens.error is DocoptExit: + raise tokens.error("-%s is not recognized" % raw[0]) + else: + o = Option("-" + raw[0], None) + options.append(o) + parsed.append(o) + raw = raw[1:] + continue + opt = copy(opt[0]) + raw = raw[1:] + if opt.argcount == 0: + value = True + else: + if raw == "": + if tokens.current() is None: + raise tokens.error("-%s requires argument" % opt.short[0]) + raw = tokens.move() + value, raw = raw, "" + opt.value = value + parsed.append(opt) + return parsed + + +def parse_pattern(source, options): + tokens = TokenStream( + re.sub(r"([\[\]\(\)\|]|\.\.\.)", r" \1 ", source), DocoptLanguageError + ) + result = parse_expr(tokens, options) + if tokens.current() is not None: + raise tokens.error("unexpected ending: %r" % " ".join(tokens)) + return Required(*result) + + +def parse_expr(tokens, options): + """expr ::= seq ( '|' seq )* ;""" + seq = parse_seq(tokens, options) + if tokens.current() != "|": + return seq + result = [Required(*seq)] if len(seq) > 1 else seq + while tokens.current() == "|": + tokens.move() + seq = parse_seq(tokens, options) + result += [Required(*seq)] if len(seq) > 1 else seq + return [Either(*result)] if len(result) > 1 else result + + +def parse_seq(tokens, options): + """seq ::= ( atom [ '...' ] )* ;""" + result = [] + while tokens.current() not in [None, "]", ")", "|"]: + atom = parse_atom(tokens, options) + if tokens.current() == "...": + atom = [OneOrMore(*atom)] + tokens.move() + result += atom + return result + + +def parse_atom(tokens, options): + """atom ::= '(' expr ')' | '[' expr ']' | 'options' + | long | shorts | argument | command ; + """ + token = tokens.current() + result = [] + if token == "(": + tokens.move() + result = [Required(*parse_expr(tokens, options))] + if tokens.move() != ")": + raise tokens.error("Unmatched '('") + return result + elif token == "[": + tokens.move() + result = [Optional(*parse_expr(tokens, options))] + if tokens.move() != "]": + raise tokens.error("Unmatched '['") + return result + elif token == "options": + tokens.move() + return [AnyOptions()] + elif token.startswith("--") and token != "--": + return parse_long(tokens, options) + elif token.startswith("-") and token not in ("-", "--"): + return parse_shorts(tokens, options) + elif token.startswith("<") and token.endswith(">") or token.isupper(): + return [Argument(tokens.move())] + else: + return [Command(tokens.move())] + + +def parse_args(source, options): + tokens = TokenStream(source, DocoptExit) + options = copy(options) + parsed = [] + while tokens.current() is not None: + if tokens.current() == "--": + return parsed + [Argument(None, v) for v in tokens] + elif tokens.current().startswith("--"): + parsed += parse_long(tokens, options) + elif tokens.current().startswith("-") and tokens.current() != "-": + parsed += parse_shorts(tokens, options) + else: + parsed.append(Argument(None, tokens.move())) + return parsed + + +def parse_doc_options(doc): + return [Option.parse("-" + s) for s in re.split("^ *-|\n *-", doc)[1:]] + + +def printable_usage(doc): + usage_split = re.split(r"([Uu][Ss][Aa][Gg][Ee]:)", doc) + if len(usage_split) < 3: + raise DocoptLanguageError('"usage:" (case-insensitive) not found.') + if len(usage_split) > 3: + raise DocoptLanguageError('More than one "usage:" (case-insensitive).') + return re.split(r"\n\s*\n", "".join(usage_split[1:]))[0].strip() + + +def formal_usage(printable_usage): + pu = printable_usage.split()[1:] # split and drop "usage:" + return " ".join("|" if s == pu[0] else s for s in pu[1:]) + + +def extras(help, version, options, doc): + if help and any((o.name in ("-h", "--help")) and o.value for o in options): + print(doc.strip()) + exit() + if version and any(o.name == "--version" and o.value for o in options): + print(version) + exit() + + +class Dict(dict): + """Dictionary with custom repr bbehaviour.""" + + def __repr__(self): + """Dictionary representation for docopt.""" + return "{%s}" % ",\n ".join("%r: %r" % i for i in sorted(self.items())) + + +def docopt(doc, argv=sys.argv[1:], help=True, version=None): + DocoptExit.usage = docopt.usage = usage = printable_usage(doc) + pot_options = parse_doc_options(doc) + formal_pattern = parse_pattern(formal_usage(usage), options=pot_options) + argv = parse_args(argv, options=pot_options) + extras(help, version, argv, doc) + matched, left, arguments = formal_pattern.fix().match(argv) + if matched and left == []: # better message if left? + options = [o for o in argv if type(o) is Option] + pot_arguments = [ + a for a in formal_pattern.flat if type(a) in [Argument, Command] + ] + return Dict( + (a.name, a.value) + for a in (pot_options + options + pot_arguments + arguments) + ) + raise DocoptExit() diff --git a/port_for/ephemeral.py b/port_for/ephemeral.py new file mode 100644 index 0000000..a3c03ec --- /dev/null +++ b/port_for/ephemeral.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +""" +This module provide utilities to find ephemeral port ranges for the current OS. +See http://www.ncftp.com/ncftpd/doc/misc/ephemeral_ports.html for more info +about ephemeral port ranges. + +Currently only Linux and BSD (including OS X) are supported. +""" +import subprocess +from typing import List, Tuple, Dict + +DEFAULT_EPHEMERAL_PORT_RANGE = (32768, 65535) + + +def port_ranges() -> List[Tuple[int, int]]: + """ + Returns a list of ephemeral port ranges for current machine. + """ + try: + return _linux_ranges() + except (OSError, IOError): # not linux, try BSD + try: + ranges = _bsd_ranges() + if ranges: + return ranges + except (OSError, IOError): + pass + + # fallback + return [DEFAULT_EPHEMERAL_PORT_RANGE] + + +def _linux_ranges() -> List[Tuple[int, int]]: + with open("/proc/sys/net/ipv4/ip_local_port_range") as f: + # use readline() instead of read() for linux + musl + low, high = f.readline().split() + return [(int(low), int(high))] + + +def _bsd_ranges() -> List[Tuple[int, int]]: + pp = subprocess.Popen( + ["sysctl", "net.inet.ip.portrange"], stdout=subprocess.PIPE + ) + stdout, stderr = pp.communicate() + lines = stdout.decode("ascii").split("\n") + out: Dict[str, str] = dict( + [ + [x.strip().rsplit(".")[-1] for x in line.split(":")] + for line in lines + if line + ] + ) + + ranges = [ + # FreeBSD & Mac + ("first", "last"), + ("lowfirst", "lowlast"), + ("hifirst", "hilast"), + # OpenBSD + ("portfirst", "portlast"), + ("porthifirst", "porthilast"), + ] + + res = [] + for rng in ranges: + try: + low, high = int(out[rng[0]]), int(out[rng[1]]) + if low <= high: + res.append((low, high)) + except KeyError: + pass + return res diff --git a/port_for/exceptions.py b/port_for/exceptions.py new file mode 100644 index 0000000..256a255 --- /dev/null +++ b/port_for/exceptions.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +class PortForException(Exception): + pass diff --git a/port_for/py.typed b/port_for/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/port_for/py.typed diff --git a/port_for/store.py b/port_for/store.py new file mode 100644 index 0000000..a07b737 --- /dev/null +++ b/port_for/store.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +import os +from configparser import ConfigParser, DEFAULTSECT +from typing import Optional, List, Tuple, Union + +from .api import select_random +from .exceptions import PortForException + + +DEFAULT_CONFIG_PATH = "/etc/port-for.conf" + + +class PortStore(object): + def __init__(self, config_filename: str = DEFAULT_CONFIG_PATH): + self._config = config_filename + + def bind_port( + self, app: str, port: Optional[Union[int, str]] = None + ) -> int: + if "=" in app or ":" in app: + raise Exception('invalid app name: "%s"' % app) + + requested_port: Optional[str] = None + if port is not None: + requested_port = str(port) + + parser = self._get_parser() + + # this app already use some port; return it + if parser.has_option(DEFAULTSECT, app): + actual_port = parser.get(DEFAULTSECT, app) + if requested_port is not None and requested_port != actual_port: + msg = ( + "Can't bind to port %s: %s is already associated " + "with port %s" % (requested_port, app, actual_port) + ) + raise PortForException(msg) + return int(actual_port) + + # port is already used by an another app + app_by_port = dict((v, k) for k, v in parser.items(DEFAULTSECT)) + bound_port_numbers = map(int, app_by_port.keys()) + + if requested_port is None: + requested_port = str( + select_random(exclude_ports=bound_port_numbers) + ) + + if requested_port in app_by_port: + binding_app = app_by_port[requested_port] + if binding_app != app: + raise PortForException( + "Port %s is already used by %s!" + % (requested_port, binding_app) + ) + + # new app & new port + parser.set(DEFAULTSECT, app, requested_port) + self._save(parser) + + return int(requested_port) + + def unbind_port(self, app: str) -> None: + parser = self._get_parser() + parser.remove_option(DEFAULTSECT, app) + self._save(parser) + + def bound_ports(self) -> List[Tuple[str, int]]: + return [ + (app, int(port)) + for app, port in self._get_parser().items(DEFAULTSECT) + ] + + def _ensure_config_exists(self) -> None: + if not os.path.exists(self._config): + with open(self._config, "wb"): + pass + + def _get_parser(self) -> ConfigParser: + self._ensure_config_exists() + parser = ConfigParser() + parser.read(self._config) + return parser + + def _save(self, parser: ConfigParser) -> None: + with open(self._config, "wt") as f: + parser.write(f) diff --git a/port_for/utils.py b/port_for/utils.py new file mode 100644 index 0000000..361d5c0 --- /dev/null +++ b/port_for/utils.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +import itertools +from typing import Iterable, Iterator, Tuple, Set + + +def ranges_to_set(lst: Iterable[Tuple[int, int]]) -> Set[int]: + """ + Convert a list of ranges to a set of numbers:: + + >>> ranges = [(1,3), (5,6)] + >>> sorted(list(ranges_to_set(ranges))) + [1, 2, 3, 5, 6] + + """ + return set(itertools.chain(*(range(x[0], x[1] + 1) for x in lst))) + + +def to_ranges(lst: Iterable[int]) -> Iterator[Tuple[int, int]]: + """ + Convert a list of numbers to a list of ranges:: + + >>> numbers = [1,2,3,5,6] + >>> list(to_ranges(numbers)) + [(1, 3), (5, 6)] + + """ + for a, b in itertools.groupby(enumerate(lst), lambda t: t[1] - t[0]): + c = list(b) + yield c[0][1], c[-1][1] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..608e240 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,143 @@ +[project] +name = "port-for" +version = "0.7.1" +description = "Utility that helps with local TCP ports management. It can find an unused TCP localhost port and remember the association." +readme = "README.rst" +keywords = ["port", "posix"] +license = {file = "LICENSE.txt"} +authors = [ + {name = "Mikhail Korobov", email = "kmike84@gmail.com"} +] +maintainers = [ + {name = "Grzegorz Śliwiński", email = "fizyk+pypi@fizyk.dev"} +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Operating System :: POSIX", + "Topic :: System :: Installation/Setup", + "Topic :: System :: Systems Administration", + "Topic :: Internet :: WWW/HTTP :: Site Management", +] +requires-python = ">= 3.7" + +[project.urls] +"Source" = "https://github.com/kmike/port-for/" +"Bug Tracker" = "https://github.com/kmike/port-for/issues" +"Changelog" = "https://github.com/kmike/port-for/blob/v0.7.1/CHANGES.rst" + +[project.scripts] +port-for = "port_for.cmd:main" + +[build-system] +requires = ["setuptools >= 61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +zip-safe = true +packages = ["port_for"] + +[tool.setuptools.package-data] +port_for = ["py.typed"] + +[tool.pytest.ini_options] +xfail_strict=true +testpaths = "tests" +filterwarnings = "error" + +[tool.towncrier] +directory = "newsfragments" +single_file=true +filename="CHANGES.rst" +issue_format="`#{issue} <https://github.com/kmike/port-for/issues/{issue}>`_" + +[tool.towncrier.fragment.feature] +name = "Features" +showcontent = true + +[tool.towncrier.fragment.bugfix] +name = "Bugfixes" +showcontent = true + +[tool.towncrier.fragment.break] +name = "Breaking changes" +showcontent = true + +[tool.towncrier.fragment.misc] +name = "Miscellaneus" +showcontent = true + +[tool.black] +line-length = 80 +target-version = ['py38'] +include = '.*\.pyi?$' + + +[tool.tbump] +# Uncomment this if your project is hosted on GitHub: +# github_url = "https://github.com/<user or organization>/<project>/" + +[tool.tbump.version] +current = "0.7.1" + +# Example of a semver regexp. +# Make sure this matches current_version before +# using tbump +regex = ''' + (?P<major>\d+) + \. + (?P<minor>\d+) + \. + (?P<patch>\d+) + (\- + (?P<extra>.+) + )? + ''' + +[tool.tbump.git] +message_template = "Release {new_version}" +tag_template = "v{new_version}" + +[[tool.tbump.field]] +# the name of the field +name = "extra" +# the default value to use, if there is no match +default = "" + + +# For each file to patch, add a [[file]] config +# section containing the path of the file, relative to the +# tbump.toml location. +[[tool.tbump.file]] +src = "port_for/__init__.py" + +[[tool.tbump.file]] +src = "pyproject.toml" +search = 'version = "{current_version}"' + +[[tool.tbump.file]] +src = "pyproject.toml" +search = '"Changelog" = "https://github.com/kmike/port-for/blob/v{current_version}/CHANGES.rst"' + +# You can specify a list of commands to +# run after the files have been patched +# and before the git commit is made + +[[tool.tbump.before_commit]] +name = "Build changelog" +cmd = "pipenv run towncrier build --version {new_version} --yes" + +# Or run some commands after the git tag and the branch +# have been pushed: +# [[tool.tbump.after_push]] +# name = "publish" +# cmd = "./publish.sh"
\ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2627357 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[pycodestyle] +max-line-length = 80 +exclude = docs/*,build/*,venv/* diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0ea089d --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test's module.""" diff --git a/tests/test_cases.py b/tests/test_cases.py new file mode 100644 index 0000000..02772a8 --- /dev/null +++ b/tests/test_cases.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +import unittest +import tempfile +from unittest import mock +import socket +import os +from typing import Union, List, Set, Tuple + +import pytest + +import port_for +from port_for.api import get_port +from port_for.utils import ranges_to_set + + +def test_common_ports() -> None: + assert not port_for.is_available(80) + assert not port_for.is_available(11211) + + +def test_good_port_ranges() -> None: + ranges = [ + (10, 15), # too short + (100, 200), # good + (220, 245), # a bit short + (300, 330), # good + (440, 495), # also good + ] + + ports = ranges_to_set(ranges) + good_ranges = port_for.good_port_ranges(ports, 20, 3) + assert good_ranges == [(103, 197), (443, 492), (303, 327)], good_ranges + + +def test_something_works() -> None: + assert len(port_for.good_port_ranges()) > 10 + assert len(port_for.available_good_ports()) > 1000 + + +def test_binding() -> None: + # low ports are not available + assert port_for.port_is_used(10) + + +def test_binding_high() -> None: + s = socket.socket() + s.bind(("", 0)) + port = s.getsockname()[1] + assert port_for.port_is_used(port) + s.close() + assert not port_for.port_is_used(port) + + +def test_get_port_random() -> None: + """Test case allowing get port to randomly select any port.""" + assert get_port(None) + + +def test_get_port_none() -> None: + """Test special case for get_port to return None.""" + assert not get_port(-1) + + +def test_get_port_exclude() -> None: + """Only one port is available at that range.""" + assert 8002 == get_port( + (8000, 8010), + [8000, 8001, 8003, 8004, 8005, 8006, 8007, 8008, 8009, 8010], + ) + + +@pytest.mark.parametrize("port", (1234, "1234")) +def test_get_port_specific(port: Union[str, int]) -> None: + """Test special case for get_port to return same value.""" + assert get_port(port) == 1234 + + +@pytest.mark.parametrize( + "port_range", + ( + [(2000, 3000)], + (2000, 3000), + ), +) +def test_get_port_from_range( + port_range: Union[List[Tuple[int, int]], Tuple[int, int]] +) -> None: + """Test getting random port from given range.""" + assert get_port(port_range) in list(range(2000, 3000 + 1)) + + +@pytest.mark.parametrize( + "port_set", + ( + [{4001, 4002, 4003}], + {4001, 4002, 4003}, + ), +) +def test_get_port_from_set(port_set: Union[List[Set[int]], Set[int]]) -> None: + """Test getting random port from given set.""" + assert get_port(port_set) in {4001, 4002, 4003} + + +def test_port_mix() -> None: + """Test getting random port from given set and range.""" + sets_and_ranges: List[Union[Tuple[int, int], Set[int]]] = [ + (2000, 3000), + {4001, 4002, 4003}, + ] + assert get_port(sets_and_ranges) in set(range(2000, 3000 + 1)) and { + 4001, + 4002, + 4003, + } + + +class SelectPortTest(unittest.TestCase): + @mock.patch("port_for.api.port_is_used") + def test_all_used(self, port_is_used: mock.MagicMock) -> None: + port_is_used.return_value = True + self.assertRaises(port_for.PortForException, port_for.select_random) + + @mock.patch("port_for.api.port_is_used") + def test_random_port(self, port_is_used: mock.MagicMock) -> None: + ports = set([1, 2, 3]) + used = {1: True, 2: False, 3: True} + port_is_used.side_effect = lambda port: used[port] + + for x in range(100): + self.assertEqual(port_for.select_random(ports), 2) + + +class StoreTest(unittest.TestCase): + def setUp(self) -> None: + fd, self.fname = tempfile.mkstemp() + self.store = port_for.PortStore(self.fname) + + def tearDown(self) -> None: + os.remove(self.fname) + + def test_store(self) -> None: + assert self.store.bound_ports() == [] + + port = self.store.bind_port("foo") + self.assertTrue(port) + self.assertEqual(self.store.bound_ports(), [("foo", port)]) + self.assertEqual(port, self.store.bind_port("foo")) + + port2 = self.store.bind_port("aar") + self.assertNotEqual(port, port2) + self.assertEqual( + self.store.bound_ports(), [("foo", port), ("aar", port2)] + ) + + self.store.unbind_port("aar") + self.assertEqual(self.store.bound_ports(), [("foo", port)]) + + def test_rebind(self) -> None: + # try to rebind an used port for an another app + port = self.store.bind_port("foo") + self.assertRaises( + port_for.PortForException, self.store.bind_port, "baz", port + ) + + def test_change_port(self) -> None: + # changing app ports is not supported. + port = self.store.bind_port("foo") + another_port = port_for.select_random() + assert port != another_port + self.assertRaises( + port_for.PortForException, self.store.bind_port, "foo", another_port + ) + + def test_bind_unavailable(self) -> None: + # it is possible to explicitly bind currently unavailable port + port = self.store.bind_port("foo", 80) + self.assertEqual(port, 80) + self.assertEqual(self.store.bound_ports(), [("foo", 80)]) + + def test_bind_non_auto(self) -> None: + # it is possible to pass a port + port = port_for.select_random() + res_port = self.store.bind_port("foo", port) + self.assertEqual(res_port, port) |