diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 654bbd0..aacb329 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -12,10 +12,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up Python 3.10 + - name: Set up Python 3.11 uses: actions/setup-python@v3 with: - python-version: "3.10" + python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip @@ -35,35 +35,40 @@ jobs: - name: Code Security Check run: | ./scripts/code-security-check.sh + - name: Get Latest tag + run: | + git fetch --tags + LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) + echo "LATEST_TAG: $LATEST_TAG" + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + - name: Extract version + run: | + NEW_VERSION=$(grep '^__version__' "pyepp/__init__.py" | sed -E 's/^__version__ = "(.*)"/v\1/') + echo "NEW_VERSION: $NEW_VERSION" + echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV + TRIMMED_VERSION=$(echo "$NEW_VERSION" | sed 's/^v//') + echo "TRIMMED_VERSION=$TRIMMED_VERSION" >> $GITHUB_ENV - name: Publish to TestPyPi - if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' && env.LATEST_TAG != env.NEW_VERSION run: | rm -rf dist python -m build twine check dist/* twine upload -r testpypi --username __token__ --password ${{ secrets.TEST_PYPI_API_TOKEN }} --skip-existing dist/* - name: Publish to PyPi - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && env.LATEST_TAG != env.NEW_VERSION run: | rm -rf dist python -m build twine check dist/* - twine upload --username __token__ --password ${{ secrets.PYPI_API_TOKEN }} dist/* - - name: Extract version - run: | - NEW_VERSION=$(grep '^__version__' "pyepp/__init__.py" | sed -E 's/^__version__ = "(.*)"/v\1/') - echo "NEW_VERSION: $NEW_VERSION" - echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV - - name: Get Previous tag - id: previous_tag - uses: "WyriHaximus/github-action-get-previous-tag@v1" + twine upload --username __token__ --password ${{ secrets.PYPI_API_TOKEN }} dist/*  - name: Create Tag - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: rickstaa/action-create-tag@v1.7.2 - with: - tag: ${{env.NEW_VERSION}} + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && env.LATEST_TAG != env.NEW_VERSION + run: | + git tag ${{env.NEW_VERSION}} + git push origin ${{env.NEW_VERSION}} - name: Generate changelog - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && env.LATEST_TAG != env.NEW_VERSION id: build_changelog uses: mikepenz/release-changelog-builder-action@v4 with: @@ -89,13 +94,16 @@ jobs: toTag: ${{env.NEW_VERSION}} token: ${{ secrets.GITHUB_TOKEN }} - name: Create release - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && env.LATEST_TAG != env.NEW_VERSION uses: ncipollo/release-action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} - tag: ${{env.NEW_VERSION}} + tag: ${{ env.NEW_VERSION }} draft: false - body: ${{steps.build_changelog.outputs.changelog}} + body: | + [PyEPP on PyPI](https://pypi.org/project/pyepp/${{env.TRIMMED_VERSION}}/) + + ${{ steps.build_changelog.outputs.changelog }} makeLatest: true artifacts: "./dist/*" - name: Cleanup workspace diff --git a/README.md b/README.md index 9ed3605..c4d2f81 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ any registry systems that support EPP and work with it. It supports bellow RFCs: - [RFC 5733 - Contact Mapping](https://datatracker.ietf.org/doc/html/rfc5733) - [RFC 5734 - Transport over TCP](https://datatracker.ietf.org/doc/html/rfc5734) ->This is an early version and not stable yet. Please use with care. ## Installation @@ -128,6 +127,31 @@ Commands: run Receive an XML file containing an EPP XML command and execute it. ``` +### Enable shell autocomplete +To enable shell autocompletion for your shell follow the below commands: + +#### Zsh +```sh +mkdir -p ~/.pyepp +_PYEPP_COMPLETE=zsh_source pyepp > ~/.pyepp/shell-complete.zsh +``` + +Source the file in `~/.zshrc`. +```sh +. ~/.pyepp/shell-complete.zsh +``` + +#### Bash +```sh +mkdir -p ~/.pyepp +_PYEPP_COMPLETE=bash_source pyepp > ~/.pyepp/shell-complete.bash +``` + +Source the file in `~/.bashrc`. +```sh +. ~/.pyepp/shell-complete.bash +``` + ## Development setup Clone this project. It's recommended to create virtual environment. Then install the dependencies and development dependencies: diff --git a/docs/cli.rst b/docs/cli.rst index e44fdc0..c2e5dc9 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -72,6 +72,36 @@ And to get help for a specific command: --client-transaction-id TEXT -h, --help Show this message and exit. +Enable shell autocomplete +------------------------- +To enable shell autocompletion for your shell follow the below commands: + +Zsh +^^^^ +.. code-block:: text + + mkdir -p ~/.pyepp + _PYEPP_COMPLETE=zsh_source pyepp > ~/.pyepp/shell-complete.zsh + +Source the file in ``~/.zshrc``. + +.. code-block:: text + + . ~/.pyepp/shell-complete.zsh + +Bash +^^^^ +.. code-block:: text + + mkdir -p ~/.pyepp + _PYEPP_COMPLETE=bash_source pyepp > ~/.pyepp/shell-complete.bash + +Source the file in ``~/.bashrc``. + +.. code-block:: text + + . ~/.pyepp/shell-complete.bash + How to configure ---------------- The epp server configuration and credentials can be passed to the cli in three different ways. It can be done either diff --git a/docs/index.rst b/docs/index.rst index 0fc7176..1940272 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,8 +11,6 @@ It supports the bellow RFCs: * `RFC 5733 - Contact Mapping `_ * `RFC 5734 - Transport over TCP `_ -.. note:: - This is an early version and not stable yet. Please use with care. Installation ------------ diff --git a/docs/pyepp.rst b/docs/pyepp.rst index e69de29..fb31f51 100644 --- a/docs/pyepp.rst +++ b/docs/pyepp.rst @@ -0,0 +1 @@ +:orphan: \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 45cfbee..90b54b1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,3 @@ sphinx==6.2.1 -sphinx-rtd-theme==1.2.2 -myst-parser==2.0.0 \ No newline at end of file +myst-parser==3.0.1 +sphinx-rtd-theme==2.0.0 \ No newline at end of file diff --git a/pyepp/__init__.py b/pyepp/__init__.py index d263934..5e7052e 100644 --- a/pyepp/__init__.py +++ b/pyepp/__init__.py @@ -1,6 +1,7 @@ """ PyEPP Package """ -__version__ = "0.0.15" + +__version__ = "0.1.0" from pyepp.epp import EppCommunicator, EppResultCode, EppCommunicatorException diff --git a/pyepp/cli/__main__.py b/pyepp/cli/__main__.py index 9493b5e..128e76a 100644 --- a/pyepp/cli/__main__.py +++ b/pyepp/cli/__main__.py @@ -76,9 +76,9 @@ def pyepp_cli(ctx, server, port, client_cert, client_key, user, password, output ctx.obj = cli.PyEppCli(server, port, client_cert, client_key, user, password, output_format, no_pretty, dry_run) if verbose: - logging.basicConfig(level=logging.INFO) + logging.getLogger().setLevel(level=logging.INFO) if debug: - logging.basicConfig(level=logging.DEBUG) + logging.getLogger().setLevel(level=logging.DEBUG) utils.OUTPUT_FILE = file diff --git a/pyepp/contact.py b/pyepp/contact.py index 51658d6..84b39a1 100644 --- a/pyepp/contact.py +++ b/pyepp/contact.py @@ -1,55 +1,61 @@ """ Contact Mapping Module. This module is used to manage contact objects in Registry. """ + from typing import Optional from dataclasses import dataclass, asdict from bs4 import BeautifulSoup from pyepp.base_command import BaseCommand -from pyepp.command_templates import CONTACT_CHECK_XML, CONTACT_INFO_XML, CONTACT_CREAT_XML, CONTACT_DELETE_XML, \ - CONTACT_UPDATE_XML +from pyepp.command_templates import ( + CONTACT_CHECK_XML, + CONTACT_INFO_XML, + CONTACT_CREAT_XML, + CONTACT_DELETE_XML, + CONTACT_UPDATE_XML, +) from pyepp.epp import EppResultCode, EppResultData @dataclass class AddressData: - """Contact address data class. - """ - street_1: Optional[str] = '' - city: Optional[str] = '' - country_code: Optional[str] = '' - street_2: Optional[str] = '' - street_3: Optional[str] = '' - province: Optional[str] = '' - postal_code: Optional[str] = '' + """Contact address data class.""" + + street_1: Optional[str] = "" + city: Optional[str] = "" + country_code: Optional[str] = "" + street_2: Optional[str] = "" + street_3: Optional[str] = "" + province: Optional[str] = "" + postal_code: Optional[str] = "" @dataclass class PostalInfoData: - """Contact postal info data class. - """ + """Contact postal info data class.""" + name: Optional[str] - organization: Optional[str] = '' + organization: Optional[str] = "" address: Optional[AddressData] = None @dataclass class ContactData: - """Contact data class. Contains the properties of the contacts associated with the domain name. - """ + """Contact data class. Contains the properties of the contacts associated with the domain name.""" + # pylint: disable=invalid-name,too-many-instance-attributes id: str - email: Optional[str] = '' + email: Optional[str] = "" postal_info: Optional[PostalInfoData] = None status: Optional[list[str]] = None - phone: Optional[str] = '' - fax: Optional[str] = '' - password: Optional[str] = '' - create_date: Optional[str] = '' - creat_client_id: Optional[str] = '' - sponsoring_client_id: Optional[str] = '' - update_client_id: Optional[str] = '' - update_date: Optional[str] = '' + phone: Optional[str] = "" + fax: Optional[str] = "" + password: Optional[str] = "" + create_date: Optional[str] = "" + creat_client_id: Optional[str] = "" + sponsoring_client_id: Optional[str] = "" + update_client_id: Optional[str] = "" + update_date: Optional[str] = "" class Contact(BaseCommand): @@ -63,8 +69,7 @@ class Contact(BaseCommand): * Technical Contact - A technical contact is an individual identified as a contact for technical information-related administration of a registered domain name. * Billing Contact - Also known as the Finance Contact, this is the individual or organization responsible for - payment of fees related to the domain name and will monitor period activity, account balances, and account - status. + payment of fees related to the domain name and will monitor period activity, account balances, and account status. """ def _data_to_dict(self, data: ContactData) -> dict: @@ -76,8 +81,8 @@ def _data_to_dict(self, data: ContactData) -> dict: :rtype: dict """ data_dict = asdict(data) - postal_info = data_dict.pop('postal_info', {}) - address = postal_info.pop('address', {}) + postal_info = data_dict.pop("postal_info", {}) + address = postal_info.pop("address", {}) if address: data_dict.update(address) @@ -86,7 +91,9 @@ def _data_to_dict(self, data: ContactData) -> dict: return data_dict - def check(self, contact_ids: list[str], client_transaction_id: Optional[str] = None) -> EppResultData: + def check( + self, contact_ids: list[str], client_transaction_id: Optional[str] = None + ) -> EppResultData: """A successful Contact Check request determines whether a Contact ID is available for use and whether a contact can be created in the Registry. When creating a new contact, the Registrar must generate a Registry-unique contact ID. A Registry Contact Check request can determine whether an ID is already in use. @@ -97,29 +104,35 @@ def check(self, contact_ids: list[str], client_transaction_id: Optional[str] = N :return: contact check result :rtype: EppResultData """ - result = self.execute(CONTACT_CHECK_XML, ids=contact_ids, client_transaction_id=client_transaction_id) + result = self.execute( + CONTACT_CHECK_XML, + ids=contact_ids, + client_transaction_id=client_transaction_id, + ) if result.code != int(EppResultCode.SUCCESS.value): return result - raw_response = BeautifulSoup(result.raw_response, 'xml') - contacts_check_data = raw_response.find_all('cd') + raw_response = BeautifulSoup(result.raw_response, "xml") + contacts_check_data = raw_response.find_all("cd") result_data = {} for contact_cd in contacts_check_data: - contact = contact_cd.find('id') - available = contact.get('avail') in ('true', '1') - reason = contact_cd.find('reason').text if not available else None + contact = contact_cd.find("id") + available = contact.get("avail") in ("true", "1") + reason = contact_cd.find("reason").text if not available else None result_data[contact.text] = { - 'avail': available, - 'reason': reason, + "avail": available, + "reason": reason, } result.result_data = result_data return result - def info(self, contact_id: str, client_transaction_id: Optional[str] = None) -> EppResultData: + def info( + self, contact_id: str, client_transaction_id: Optional[str] = None + ) -> EppResultData: """ A successful Contact Info request retrieves information associated with an existing contact. All available information is returned if the querying Registrar is the contact’s sponsor. For a non-sponsoring @@ -133,51 +146,87 @@ def info(self, contact_id: str, client_transaction_id: Optional[str] = None) -> :return: Contact details :rtype: EppResultData """ - result = self.execute(CONTACT_INFO_XML, id=contact_id, client_transaction_id=client_transaction_id) + result = self.execute( + CONTACT_INFO_XML, id=contact_id, client_transaction_id=client_transaction_id + ) if result.code != int(EppResultCode.SUCCESS.value): return result - raw_response = BeautifulSoup(result.raw_response, 'xml') + raw_response = BeautifulSoup(result.raw_response, "xml") result_data = { - 'id': raw_response.find('id').text, - 'status': [status.text for status in raw_response.find_all('status')], - 'create_date': raw_response.find('crDate').text, - 'creat_client_id': raw_response.find('crID').text, - 'sponsoring_client_id': raw_response.find('clID').text, - 'update_client_id': raw_response.find('upID').text, - 'update_date': raw_response.find('upDate').text, - 'postal_info': PostalInfoData(**{ - 'name': raw_response.find('name').text, - 'organization': raw_response.find('org').text if raw_response.find('org') else None, - 'address': AddressData(**{ - 'street_1': raw_response.find_all('street')[0].text - if len(raw_response.find_all('street')) >= 1 else None, - 'street_2': raw_response.find_all('street')[1].text - if len(raw_response.find_all('street')) >= 2 else None, - 'street_3': raw_response.find_all('street')[2].text - if len(raw_response.find_all('street')) >= 3 else None, - 'city': raw_response.find('city').text, - 'province': raw_response.find('sp').text if raw_response.find('sp') else None, - 'postal_code': raw_response.find('pc').text if raw_response.find( - 'pc') else None, - 'country_code': raw_response.find('cc').text, - }), - }), - 'phone': raw_response.find('voice').text if raw_response.find('voice') else None, - 'fax': raw_response.find('fax').text if raw_response.find('fax') else None, - 'email': raw_response.find('email').text, + "id": raw_response.find("id").text, + "status": [status.text for status in raw_response.find_all("status")], + "create_date": raw_response.find("crDate").text, + "creat_client_id": raw_response.find("crID").text, + "sponsoring_client_id": raw_response.find("clID").text, + "update_client_id": ( + raw_response.find("upID").text if raw_response.find("upID") else None + ), + "update_date": ( + raw_response.find("upDate").text + if raw_response.find("upDate") + else None + ), + "postal_info": PostalInfoData( + **{ + "name": raw_response.find("name").text, + "organization": ( + raw_response.find("org").text + if raw_response.find("org") + else None + ), + "address": AddressData( + **{ + "street_1": ( + raw_response.find_all("street")[0].text + if len(raw_response.find_all("street")) >= 1 + else None + ), + "street_2": ( + raw_response.find_all("street")[1].text + if len(raw_response.find_all("street")) >= 2 + else None + ), + "street_3": ( + raw_response.find_all("street")[2].text + if len(raw_response.find_all("street")) >= 3 + else None + ), + "city": raw_response.find("city").text, + "province": ( + raw_response.find("sp").text + if raw_response.find("sp") + else None + ), + "postal_code": ( + raw_response.find("pc").text + if raw_response.find("pc") + else None + ), + "country_code": raw_response.find("cc").text, + } + ), + } + ), + "phone": ( + raw_response.find("voice").text if raw_response.find("voice") else None + ), + "fax": raw_response.find("fax").text if raw_response.find("fax") else None, + "email": raw_response.find("email").text, } - if raw_response.find('pw'): - result_data['password'] = raw_response.find('pw').text + if raw_response.find("pw"): + result_data["password"] = raw_response.find("pw").text result.result_data = ContactData(**result_data) return result - def create(self, contact: ContactData, client_transaction_id: Optional[str] = None) -> EppResultData: + def create( + self, contact: ContactData, client_transaction_id: Optional[str] = None + ) -> EppResultData: """A successful Contact Create request creates a contact object in the Registry. To create a domain name successfully, a Registrar does not need to be the sponsor of the related hosts but must be the sponsor of all assigned contacts. @@ -189,13 +238,15 @@ def create(self, contact: ContactData, client_transaction_id: Optional[str] = No :rtype: EppResultData """ params = self._data_to_dict(contact) - params['client_transaction_id'] = client_transaction_id + params["client_transaction_id"] = client_transaction_id result = self.execute(CONTACT_CREAT_XML, **params) return result - def delete(self, contact_id: str, client_transaction_id: Optional[str] = None) -> EppResultData: + def delete( + self, contact_id: str, client_transaction_id: Optional[str] = None + ) -> EppResultData: """A successful Contact Delete request deletes a contact object from the Registry :param contact_id: Contact ID @@ -203,16 +254,21 @@ def delete(self, contact_id: str, client_transaction_id: Optional[str] = None) - :return: Result object """ - result = self.execute(CONTACT_DELETE_XML, id=contact_id, client_transaction_id=client_transaction_id) + result = self.execute( + CONTACT_DELETE_XML, + id=contact_id, + client_transaction_id=client_transaction_id, + ) return result - def update(self, - contact: ContactData, - add_status: Optional[str] = '', - remove_status: Optional[str] = '', - client_transaction_id: Optional[str] = None - ) -> EppResultData: + def update( + self, + contact: ContactData, + add_status: Optional[str] = "", + remove_status: Optional[str] = "", + client_transaction_id: Optional[str] = None, + ) -> EppResultData: """A successful Contact Update request modifies a contact object in the Registry. Updates to Registrant contacts must be valid and must be complete. @@ -227,12 +283,12 @@ def update(self, params = self._data_to_dict(contact) - params['add_status'] = add_status - params['remove_status'] = remove_status + params["add_status"] = add_status + params["remove_status"] = remove_status - params['postalinfo_change'] = bool(contact.postal_info) - params['address_change'] = bool(contact.postal_info.address) - params['client_transaction_id'] = client_transaction_id + params["postalinfo_change"] = bool(contact.postal_info) + params["address_change"] = bool(contact.postal_info.address) + params["client_transaction_id"] = client_transaction_id result = self.execute(CONTACT_UPDATE_XML, **params) diff --git a/pyepp/epp.py b/pyepp/epp.py index 84192a2..a9b3ac5 100644 --- a/pyepp/epp.py +++ b/pyepp/epp.py @@ -217,7 +217,7 @@ def connect(self) -> bytes: :raises EppCommunicatorException: When there is any errors """ try: - self._context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + self._context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT, ssl_version=ssl.TLSVersion.TLSv1_3) self._context.minimum_version = ssl.TLSVersion.TLSv1_2 self._context.load_default_certs() self._context.load_cert_chain(certfile=self._client_cert, keyfile=self._client_key) diff --git a/pyproject.toml b/pyproject.toml index 2dd09af..07b0cb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "jinja2>=3.1.2", "lxml>=4.9.3", "bs4>=0.0.1", - "click >= 8.1.7" + "click>=8.1.7" ] requires-python = ">=3.10" @@ -36,4 +36,4 @@ dev = ["bandit", "coverage", "pylint", "pytest", "safety"] "Repository" = "https://github.com/InternetNZ/pyepp" [project.scripts] -pyepp = "pyepp.cli.__main__:pyepp_cli" \ No newline at end of file +pyepp = "pyepp.cli.__main__:pyepp_cli" diff --git a/requirements.dev.txt b/requirements.dev.txt index 2714f61..32d3f3f 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1,5 +1,5 @@ -bandit==1.7.8 -coverage==7.4.4 -pylint==3.1.0 -pytest==8.1.1 -pip-audit==2.7.2 +bandit==1.7.9 +coverage==7.6.0 +pylint==3.2.6 +pytest==8.3.2 +pip-audit==2.7.3 diff --git a/requirements.txt b/requirements.txt index b9eb957..40ee0ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -jinja2==3.1.3 -lxml==5.2.1 +jinja2==3.1.4 +lxml==5.2.2 bs4==0.0.2 -click==8.1.7 \ No newline at end of file +click==8.1.7