├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── Makefile ├── README.md ├── docker ├── mysql.env ├── nacos-standlone-mysql.env └── standalone-mysql-8.yaml ├── examples ├── use_config.py ├── use_fastapi.py └── use_instance.py ├── pyproject.toml ├── src └── use_nacos │ ├── __init__.py │ ├── _chooser.py │ ├── cache.py │ ├── client.py │ ├── endpoints │ ├── __init__.py │ ├── config.py │ ├── endpoint.py │ ├── instance.py │ ├── namespace.py │ └── service.py │ ├── exception.py │ ├── helper.py │ ├── serializer.py │ └── typings.py └── tests ├── test_config.py └── test_instance.py /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Python 3.10 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.10" 20 | 21 | - name: Install Poetry 22 | uses: snok/install-poetry@v1 23 | with: 24 | virtualenvs-create: true 25 | virtualenvs-in-project: true 26 | installer-parallel: true 27 | 28 | - name: Build project for distribution 29 | run: poetry build 30 | 31 | - name: Check Version 32 | id: check-version 33 | run: | 34 | [[ "$(poetry version --short)" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ 35 | || echo ::set-output name=prerelease::true 36 | 37 | - name: Create Release 38 | uses: ncipollo/release-action@v1 39 | with: 40 | artifacts: "dist/*" 41 | token: ${{ secrets.GITHUB_TOKEN }} 42 | draft: false 43 | prerelease: steps.check-version.outputs.prerelease == 'true' 44 | allowUpdates: true 45 | 46 | - name: Publish to PyPI 47 | env: 48 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} 49 | run: poetry publish -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test suite 2 | 3 | on: 4 | push: 5 | paths: 6 | - "src/**" 7 | - "tests/**" 8 | pull_request: 9 | paths: 10 | - "src/**" 11 | - "tests/**" 12 | 13 | jobs: 14 | test: 15 | strategy: 16 | fail-fast: true 17 | matrix: 18 | os: [ "ubuntu-latest" ] 19 | python-version: [ "3.8" ] 20 | runs-on: ${{ matrix.os }} 21 | environment: test 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install Poetry 29 | uses: snok/install-poetry@v1 30 | with: 31 | virtualenvs-create: true 32 | virtualenvs-in-project: true 33 | installer-parallel: true 34 | - name: Load cached venv 35 | id: cached-poetry-dependencies 36 | uses: actions/cache@v2 37 | with: 38 | path: .venv 39 | key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} 40 | - name: Install dependencies 41 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 42 | run: poetry install --no-interaction --no-root 43 | - name: Install library 44 | run: poetry install --no-interaction 45 | - name: Run tests 46 | run: | 47 | source .venv/bin/activate 48 | pytest tests/ 49 | env: 50 | NACOS_SERVER_ADDR: ${{ secrets.NACOS_SERVER_ADDR }} 51 | NACOS_USERNAME: ${{ secrets.NACOS_USERNAME }} 52 | NACOS_PASSWORD: ${{ secrets.NACOS_PASSWORD }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # created by virtualenv automatically 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # pycharm 133 | .idea 134 | poetry.lock 135 | *.DS_Store 136 | 137 | # docs/node_modules 138 | docs/node_modules -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: ## Run `poetry install` 2 | poetry install --no-root 3 | 4 | lint: 5 | poetry run isort --check . 6 | poetry run black --check . 7 | poetry run flake8 src tests 8 | 9 | format: ## Formats you code with Black 10 | poetry run isort . 11 | poetry run black . 12 | 13 | run: ## run `poetry run use-nacos` 14 | poetry run use-nacos 15 | 16 | 17 | test: 18 | poetry run pytest -v tests 19 | 20 | publish: 21 | poetry publish --build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # use-nacos 2 | 3 | 4 | Test 5 | 6 | 7 | Package version 8 | 9 | 10 | 11 | Supported Python versions 12 | 13 | 14 | A python nacos client based on the official [open-api](https://nacos.io/zh-cn/docs/open-api.html). 15 | 16 | ## install 17 | 18 | ```shell 19 | pip install use-nacos 20 | ``` 21 | 22 | ## usage 23 | 24 | ### config 25 | 26 | ```python 27 | from use_nacos import NacosClient 28 | 29 | client = NacosClient(...) 30 | 31 | # publish config 32 | client.config.publish("test_config", "DEFAULT_GROUP", "test_value") 33 | # get config 34 | assert client.config.get("test_config", "DEFAULT_GROUP") == "test_value" 35 | 36 | 37 | # subscribe config 38 | 39 | def config_update(config): 40 | print(config) 41 | 42 | 43 | client.config.subscribe( 44 | "test_config", 45 | "DEFAULT_GROUP", 46 | callback=config_update 47 | ) 48 | ``` 49 | 50 | ### instance 51 | 52 | ```python 53 | from use_nacos import NacosClient 54 | 55 | nacos = NacosClient() 56 | 57 | nacos.instance.register( 58 | service_name="test", 59 | ip="10.10.10.10", 60 | port=8000, 61 | weight=10.0 62 | ) 63 | 64 | nacos.instance.heartbeat( 65 | service_name="test", 66 | ip="10.10.10.10", 67 | port=8000, 68 | ) 69 | ``` 70 | 71 | ### 😘support `async` mode 72 | 73 | ```python 74 | # example: fastapi 75 | 76 | from contextlib import asynccontextmanager 77 | 78 | import uvicorn 79 | from fastapi import FastAPI 80 | 81 | from use_nacos import NacosAsyncClient 82 | 83 | 84 | def config_update(config): 85 | print(config) 86 | 87 | 88 | @asynccontextmanager 89 | async def lifespan(app: FastAPI): 90 | nacos = NacosAsyncClient() 91 | 92 | config_subscriber = await nacos.config.subscribe( 93 | data_id="test-config", 94 | group="DEFAULT_GROUP", 95 | callback=config_update, 96 | ) 97 | yield 98 | config_subscriber.cancel() 99 | 100 | 101 | app = FastAPI(lifespan=lifespan) 102 | 103 | if __name__ == '__main__': 104 | uvicorn.run("in_fastapi:app", host="0.0.0.0", port=1081) 105 | ``` 106 | 107 | ## Developing 108 | 109 | ```text 110 | make install # Run `poetry install` 111 | make lint # Runs bandit and black in check mode 112 | make format # Formats you code with Black 113 | make test # run pytest with coverage 114 | make publish # run `poetry publish --build` to build source and wheel package and publish to pypi 115 | ``` -------------------------------------------------------------------------------- /docker/mysql.env: -------------------------------------------------------------------------------- 1 | MYSQL_ROOT_PASSWORD=root 2 | MYSQL_DATABASE=nacos_devtest 3 | MYSQL_USER=nacos 4 | MYSQL_PASSWORD=nacos 5 | LANG=C.UTF-8 6 | -------------------------------------------------------------------------------- /docker/nacos-standlone-mysql.env: -------------------------------------------------------------------------------- 1 | PREFER_HOST_MODE=hostname 2 | MODE=standalone 3 | SPRING_DATASOURCE_PLATFORM=mysql 4 | MYSQL_SERVICE_HOST=mysql 5 | MYSQL_SERVICE_DB_NAME=nacos_devtest 6 | MYSQL_SERVICE_PORT=3306 7 | MYSQL_SERVICE_USER=nacos 8 | MYSQL_SERVICE_PASSWORD=nacos 9 | MYSQL_SERVICE_DB_PARAM=characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true 10 | NACOS_AUTH_IDENTITY_KEY=2222 11 | NACOS_AUTH_IDENTITY_VALUE=2xxx 12 | NACOS_AUTH_TOKEN=SecretKey012345678901234567890123456789012345678901234567890123456789 13 | -------------------------------------------------------------------------------- /docker/standalone-mysql-8.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | nacos: 4 | image: nacos/nacos-server:${NACOS_VERSION} 5 | container_name: nacos-standalone-mysql 6 | env_file: 7 | - .nacos-standlone-mysql.env 8 | volumes: 9 | - ./standalone-logs/:/home/nacos/logs 10 | ports: 11 | - "8848:8848" 12 | - "9848:9848" 13 | depends_on: 14 | mysql: 15 | condition: service_healthy 16 | restart: always 17 | mysql: 18 | container_name: mysql 19 | build: 20 | context: . 21 | dockerfile: ./image/mysql/8/Dockerfile 22 | image: example/mysql:8.0.30 23 | env_file: 24 | - ./mysql.env 25 | volumes: 26 | - ./mysql:/var/lib/mysql 27 | ports: 28 | - "3306:3306" 29 | healthcheck: 30 | test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ] 31 | interval: 5s 32 | timeout: 10s 33 | retries: 10 34 | 35 | 36 | -------------------------------------------------------------------------------- /examples/use_config.py: -------------------------------------------------------------------------------- 1 | from use_nacos import NacosClient 2 | 3 | client = NacosClient() 4 | 5 | # publish config 6 | client.config.publish("test_config", "DEFAULT_GROUP", "test_value") 7 | 8 | # get config 9 | assert client.config.get("test_config", "DEFAULT_GROUP") == "test_value" 10 | 11 | # get config with default value 12 | assert client.config.get( 13 | "test_config_miss", "DEFAULT_GROUP", default="default_value" 14 | ) == "default_value" 15 | 16 | 17 | # subscribe config 18 | 19 | def config_update(config): 20 | print(config) 21 | 22 | 23 | client.config.subscribe( 24 | "test_config", 25 | "DEFAULT_GROUP", 26 | callback=config_update 27 | ) 28 | -------------------------------------------------------------------------------- /examples/use_fastapi.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | 3 | import uvicorn 4 | from fastapi import FastAPI 5 | 6 | from use_nacos import NacosAsyncClient 7 | 8 | 9 | def config_update(config): 10 | print(config) 11 | 12 | 13 | @asynccontextmanager 14 | async def lifespan(app: FastAPI): 15 | nacos = NacosAsyncClient() 16 | 17 | config_subscriber = await nacos.config.subscribe( 18 | data_id="test-config", 19 | group="DEFAULT_GROUP", 20 | callback=config_update, 21 | ) 22 | await nacos.instance.register( 23 | service_name=f"python-api-1", 24 | ip="10.10.10.1", 25 | port=8000, 26 | weight=1 27 | ) 28 | yield 29 | config_subscriber.cancel() 30 | 31 | 32 | app = FastAPI(lifespan=lifespan) 33 | 34 | if __name__ == '__main__': 35 | uvicorn.run("in_fastapi:app", host="0.0.0.0", port=1081) 36 | -------------------------------------------------------------------------------- /examples/use_instance.py: -------------------------------------------------------------------------------- 1 | from use_nacos import NacosClient 2 | 3 | nacos = NacosClient() 4 | 5 | nacos.instance.register( 6 | service_name="test", 7 | ip="10.10.10.10", 8 | port=8000, 9 | weight=10.0 10 | ) 11 | 12 | nacos.instance.heartbeat( 13 | service_name="test", 14 | ip="10.10.10.10", 15 | port=8000, 16 | ) 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "use-nacos" 3 | version = "0.1.5" 4 | description = "A python nacos client based on the official open-api" 5 | authors = ["MicLon "] 6 | readme = "README.md" 7 | license = "MIT" 8 | homepage = "https://github.com/use-py/use-nacos" 9 | repository = "https://github.com/use-py/use-nacos" 10 | packages = [ 11 | { include = "use_nacos", from = "src" } 12 | ] 13 | 14 | [tool.poetry.urls] 15 | "Bug Tracker" = "https://github.com/use-py/use-nacos/issues" 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.8" 19 | httpx = "^0.25.2" 20 | tomli = { version = "^2.0.0", python = "<3.11" } 21 | 22 | 23 | [tool.poetry.group.test.dependencies] 24 | pylint = "*" 25 | pytest = "*" 26 | pytest-asyncio = "*" 27 | pytest-mock = "*" 28 | black = "*" 29 | flake8 = "*" 30 | isort = "*" 31 | pre-commit = "*" 32 | pre-commit-hooks = "*" 33 | 34 | [build-system] 35 | requires = ["poetry-core"] 36 | build-backend = "poetry.core.masonry.api" 37 | -------------------------------------------------------------------------------- /src/use_nacos/__init__.py: -------------------------------------------------------------------------------- 1 | from use_nacos.client import NacosClient, NacosAsyncClient 2 | 3 | __all__ = [ 4 | "NacosClient", 5 | "NacosAsyncClient" 6 | ] 7 | -------------------------------------------------------------------------------- /src/use_nacos/_chooser.py: -------------------------------------------------------------------------------- 1 | # created by gpt-4 2 | 3 | import random 4 | 5 | 6 | class Chooser: 7 | def __init__(self, host_with_weight: list): 8 | self.host_with_weight = host_with_weight 9 | self.items = [] 10 | self.weights = [] 11 | 12 | def refresh(self): 13 | origin_weight_sum = 0.0 14 | # Preparing the valid items list and calculating the original weights sum 15 | for item, weight in self.host_with_weight: 16 | if weight <= 0: 17 | continue 18 | if float('inf') == weight: 19 | weight = 10000.0 20 | elif float('nan') == weight: 21 | weight = 1.0 22 | origin_weight_sum += weight 23 | self.items.append(item) 24 | 25 | if not self.items: 26 | return 27 | 28 | # Computing the exact weights for each item 29 | exact_weights = [weight / origin_weight_sum for _, weight in self.host_with_weight if weight > 0] 30 | 31 | # Initializing the cumulative weights array 32 | random_range = 0.0 33 | for single_weight in exact_weights: 34 | random_range += single_weight 35 | self.weights.append(random_range) 36 | 37 | # Checking the final weight 38 | double_precision_delta = 0.0001 39 | if abs(self.weights[-1] - 1) < double_precision_delta: 40 | return 41 | raise ValueError("Cumulative Weight calculate wrong, the sum of probabilities does not equal 1.") 42 | 43 | def random_with_weight(self): 44 | # Generating a random number between 0 and 1 45 | random_value = random.random() 46 | 47 | # Using binary search to find the index for the random value 48 | index = self._find_index(self.weights, random_value) 49 | 50 | return self.items[index] 51 | 52 | @staticmethod 53 | def _find_index(weights, value): 54 | # Perform a binary search manually since weights are not just keys 55 | low = 0 56 | high = len(weights) - 1 57 | while low <= high: 58 | mid = (low + high) // 2 59 | if weights[mid] < value: 60 | low = mid + 1 61 | elif weights[mid] > value: 62 | high = mid - 1 63 | else: 64 | return mid # This is the exact match case 65 | return low # This is the case where value should be inserted 66 | -------------------------------------------------------------------------------- /src/use_nacos/cache.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | 5 | class BaseCache: 6 | def set(self, key, value): 7 | raise NotImplementedError 8 | 9 | def get(self, key): 10 | raise NotImplementedError 11 | 12 | def exists(self, key): 13 | raise NotImplementedError 14 | 15 | 16 | class MemoryCache(BaseCache): 17 | def __init__(self): 18 | self.storage = {} 19 | 20 | def set(self, key, value): 21 | self.storage[key] = value 22 | 23 | def get(self, key): 24 | return self.storage.get(key) 25 | 26 | def exists(self, key): 27 | return key in self.storage 28 | 29 | 30 | class FileCache(BaseCache): 31 | def __init__(self, file_path=None): 32 | self.file_path = file_path or '_nacos_config_cache.json' 33 | if not os.path.exists(self.file_path): 34 | with open(self.file_path, 'w') as f: 35 | json.dump({}, f) 36 | 37 | def _read_file(self): 38 | with open(self.file_path, 'r') as f: 39 | return json.load(f) 40 | 41 | def _write_file(self, data): 42 | with open(self.file_path, 'w') as f: 43 | json.dump(data, f) 44 | 45 | def set(self, key, value): 46 | data = self._read_file() 47 | data[key] = value 48 | self._write_file(data) 49 | 50 | def get(self, key): 51 | data = self._read_file() 52 | return data.get(key) 53 | 54 | def exists(self, key): 55 | data = self._read_file() 56 | return key in data 57 | 58 | 59 | memory_cache = MemoryCache() 60 | -------------------------------------------------------------------------------- /src/use_nacos/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from abc import abstractmethod 4 | from json import JSONDecodeError 5 | from typing import Any, Optional, Dict, List, Generator 6 | 7 | import httpx 8 | from httpx import Request, Response, Auth, HTTPTransport, AsyncHTTPTransport 9 | 10 | from .endpoints import ( 11 | ConfigEndpoint, InstanceEndpoint, ServiceEndpoint, NamespaceEndpoint, 12 | ConfigAsyncEndpoint, InstanceAsyncEndpoint 13 | ) 14 | from .exception import HTTPResponseError 15 | from .typings import SyncAsync, HttpxClient 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | DEFAULT_SERVER_ADDR = "http://localhost:8848/" 20 | DEFAULT_NAMESPACE = "" 21 | 22 | 23 | class BaseClient: 24 | 25 | def __init__( 26 | self, 27 | client: HttpxClient, 28 | server_addr: Optional[str] = None, 29 | username: Optional[str] = None, 30 | password: Optional[str] = None, 31 | namespace_id: Optional[str] = None, 32 | ): 33 | self.server_addr = server_addr or os.environ.get("NACOS_SERVER_ADDR") or DEFAULT_SERVER_ADDR 34 | self.username = username or os.environ.get("NACOS_USERNAME") 35 | self.password = password or os.environ.get("NACOS_PASSWORD") 36 | self.namespace_id = namespace_id or os.environ.get("NACOS_NAMESPACE") or DEFAULT_NAMESPACE 37 | 38 | self._clients: List[HttpxClient] = [] 39 | self.client = client 40 | # endpoints 41 | self.config = ConfigEndpoint(self) 42 | self.instance = InstanceEndpoint(self) 43 | self.service = ServiceEndpoint(self) 44 | self.namespace = NamespaceEndpoint(self) 45 | 46 | @property 47 | def client(self) -> HttpxClient: 48 | return self._clients[-1] 49 | 50 | @client.setter 51 | def client(self, client: HttpxClient) -> None: 52 | client.base_url = httpx.URL(self.server_addr) 53 | client.timeout = httpx.Timeout(timeout=60_000 / 1_000) 54 | client.headers = httpx.Headers( 55 | { 56 | "User-Agent": "use-py/use-nacos", 57 | } 58 | ) 59 | self._clients.append(client) 60 | 61 | def _build_request( 62 | self, 63 | method: str, 64 | path: str, 65 | query: Optional[Dict[Any, Any]] = None, 66 | body: Optional[Dict[Any, Any]] = None, 67 | headers: Optional[Dict[Any, Any]] = None, 68 | **kwargs 69 | ) -> Request: 70 | _headers = httpx.Headers() 71 | if headers: 72 | _headers.update(headers) 73 | return self.client.build_request( 74 | method, path, params=query, data=body, headers=_headers, **kwargs 75 | ) 76 | 77 | @staticmethod 78 | def _parse_response(response: Response, serialized: bool) -> Any: 79 | """ Parse response body """ 80 | if not serialized: 81 | return response.text 82 | try: 83 | body = response.json() 84 | except JSONDecodeError: 85 | body = response.text 86 | return body 87 | 88 | @abstractmethod 89 | def request( 90 | self, 91 | path: str, 92 | method: str, 93 | query: Optional[Dict[Any, Any]] = None, 94 | body: Optional[Dict[Any, Any]] = None, 95 | headers: Optional[Dict[Any, Any]] = None 96 | ) -> SyncAsync[Any]: 97 | raise NotImplementedError 98 | 99 | 100 | class NacosClient(BaseClient): 101 | client: httpx.Client 102 | 103 | def __init__( 104 | self, 105 | server_addr: Optional[str] = None, 106 | username: Optional[str] = None, 107 | password: Optional[str] = None, 108 | namespace_id: Optional[str] = None, 109 | client: Optional[httpx.Client] = None, 110 | *, 111 | http_retries: Optional[int] = 3, 112 | ): 113 | """ Nacos Sync Client """ 114 | client = client or httpx.Client(transport=HTTPTransport(retries=http_retries)) 115 | super().__init__( 116 | client=client, 117 | server_addr=server_addr, 118 | username=username, 119 | password=password, 120 | namespace_id=namespace_id 121 | ) 122 | 123 | def request( 124 | self, 125 | path: str, 126 | method: str = "GET", 127 | query: Optional[Dict[Any, Any]] = None, 128 | body: Optional[Dict[Any, Any]] = None, 129 | headers: Optional[Dict[Any, Any]] = None, 130 | serialized: Optional[bool] = True, 131 | **kwargs 132 | ) -> Any: 133 | request = self._build_request(method, path, query, body, headers, **kwargs) 134 | try: 135 | response = self.client.send( 136 | request, 137 | auth=NacosAPIAuth(self.username, self.password) 138 | ) 139 | response.raise_for_status() 140 | return self._parse_response(response, serialized) 141 | except httpx.HTTPStatusError as exc: 142 | raise HTTPResponseError(exc.response) 143 | 144 | 145 | class NacosAsyncClient(BaseClient): 146 | client: httpx.AsyncClient 147 | 148 | def __init__( 149 | self, 150 | server_addr: Optional[str] = None, 151 | username: Optional[str] = None, 152 | password: Optional[str] = None, 153 | namespace_id: Optional[str] = None, 154 | client: Optional[httpx.AsyncClient] = None, 155 | *, 156 | http_retries: Optional[int] = 3, 157 | ): 158 | """ Nacos Async Client """ 159 | client = client or httpx.AsyncClient(transport=AsyncHTTPTransport(retries=http_retries)) 160 | super().__init__( 161 | client=client, 162 | server_addr=server_addr, 163 | username=username, 164 | password=password, 165 | namespace_id=namespace_id 166 | ) 167 | self.config = ConfigAsyncEndpoint(self) 168 | self.instance = InstanceAsyncEndpoint(self) 169 | 170 | async def request( 171 | self, 172 | path: str, 173 | method: str = "GET", 174 | query: Optional[Dict[Any, Any]] = None, 175 | body: Optional[Dict[Any, Any]] = None, 176 | headers: Optional[Dict[Any, Any]] = None, 177 | serialized: Optional[bool] = True, 178 | **kwargs 179 | ) -> Any: 180 | request = self._build_request(method, path, query, body, headers, **kwargs) 181 | try: 182 | response = await self.client.send( 183 | request, 184 | auth=NacosAPIAuth(self.username, self.password) 185 | ) 186 | response.raise_for_status() 187 | return self._parse_response(response, serialized) 188 | except httpx.HTTPStatusError as exc: 189 | raise HTTPResponseError(exc.response) 190 | 191 | 192 | class NacosAPIAuth(Auth): 193 | """ Attaches HTTP Nacos Authentication to the given Request object. """ 194 | 195 | def __init__( 196 | self, username: str, password: str 197 | ) -> None: 198 | self.auth_params = {"username": username, "password": password} 199 | 200 | def auth_flow(self, request: Request) -> Generator[Request, Response, None]: 201 | request.url = request.url.copy_merge_params(params=self.auth_params) 202 | yield request 203 | -------------------------------------------------------------------------------- /src/use_nacos/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import ConfigEndpoint, ConfigAsyncEndpoint 2 | from .instance import InstanceEndpoint, InstanceAsyncEndpoint 3 | from .service import ServiceEndpoint 4 | from .namespace import NamespaceEndpoint 5 | 6 | __all__ = [ 7 | "ConfigEndpoint", 8 | "ConfigAsyncEndpoint", 9 | "InstanceEndpoint", 10 | "InstanceAsyncEndpoint", 11 | "ServiceEndpoint", 12 | "NamespaceEndpoint" 13 | ] 14 | -------------------------------------------------------------------------------- /src/use_nacos/endpoints/config.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import hashlib 3 | import logging 4 | import threading 5 | from typing import Optional, Any, Callable, Union 6 | 7 | import httpx 8 | 9 | from .endpoint import Endpoint 10 | from ..cache import BaseCache, MemoryCache, memory_cache 11 | from ..exception import HTTPResponseError 12 | from ..serializer import Serializer, AutoSerializer 13 | from ..typings import SyncAsync 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def _get_md5(content: Any): 19 | string_content = str(content) if not isinstance(content, str) else content 20 | return hashlib.md5(string_content.encode('utf-8')).hexdigest() if content else '' 21 | 22 | 23 | def _get_config_key(data_id: str, group: str, tenant: str): 24 | # because `#` is illegal character in Nacos 25 | return '#'.join([data_id, group, tenant]) 26 | 27 | 28 | def _parse_config_key(key: str): 29 | return key.split('#') 30 | 31 | 32 | def _serialize_config( 33 | config: Any, 34 | serializer: Optional[Union["Serializer", bool]] = None 35 | ): 36 | """ Serialize config with serializer """ 37 | if isinstance(serializer, bool) and serializer is True: 38 | serializer = AutoSerializer() 39 | if isinstance(serializer, Serializer): 40 | return serializer(config) 41 | return config 42 | 43 | 44 | class _BaseConfigEndpoint(Endpoint): 45 | 46 | def _get( 47 | self, 48 | data_id: str, 49 | group: str, 50 | tenant: Optional[str] = '' 51 | ) -> SyncAsync[Any]: 52 | return self.client.request( 53 | "/nacos/v1/cs/configs", 54 | query={ 55 | "dataId": data_id, 56 | "group": group, 57 | "tenant": tenant, 58 | }, 59 | serialized=False 60 | ) 61 | 62 | def publish( 63 | self, 64 | data_id: str, 65 | group: str, 66 | content: str, 67 | tenant: Optional[str] = '', 68 | type: Optional[str] = None, 69 | ) -> SyncAsync[Any]: 70 | return self.client.request( 71 | "/nacos/v1/cs/configs", 72 | method="POST", 73 | body={ 74 | "dataId": data_id, 75 | "group": group, 76 | "tenant": tenant, 77 | "content": content, 78 | "type": type, 79 | } 80 | ) 81 | 82 | def delete( 83 | self, 84 | data_id: str, 85 | group: str, 86 | tenant: Optional[str] = '', 87 | ) -> SyncAsync[Any]: 88 | return self.client.request( 89 | "/nacos/v1/cs/configs", 90 | method="DELETE", 91 | query={ 92 | "dataId": data_id, 93 | "group": group, 94 | "tenant": tenant, 95 | } 96 | ) 97 | 98 | @staticmethod 99 | def _format_listening_configs( 100 | data_id: str, 101 | group: str, 102 | content_md5: Optional[str] = None, 103 | tenant: Optional[str] = '' 104 | ) -> str: 105 | return u'\x02'.join([data_id, group, content_md5 or "", tenant]) + u'\x01' 106 | 107 | def subscriber( 108 | self, 109 | data_id: str, 110 | group: str, 111 | content_md5: Optional[str] = None, 112 | tenant: Optional[str] = '', 113 | timeout: Optional[int] = 30_000, 114 | ) -> SyncAsync[Any]: 115 | listening_configs = self._format_listening_configs( 116 | data_id, group, content_md5, tenant 117 | ) 118 | return self.client.request( 119 | "/nacos/v1/cs/configs/listener", 120 | method="POST", 121 | body={ 122 | "Listening-Configs": listening_configs 123 | }, 124 | headers={ 125 | "Long-Pulling-Timeout": f"{timeout}", 126 | }, 127 | timeout=timeout 128 | ) 129 | 130 | 131 | class ConfigOperationMixin: 132 | 133 | @staticmethod 134 | def _config_callback(callback, config, serializer): 135 | if not callable(callback): 136 | return 137 | config = _serialize_config(config, serializer) 138 | callback(config) 139 | 140 | def get( 141 | self, 142 | data_id: str, 143 | group: str, 144 | tenant: Optional[str] = '', 145 | *, 146 | serializer: Optional[Union["Serializer", bool]] = None, 147 | cache: Optional[BaseCache] = None, 148 | default: Optional[str] = None 149 | ) -> SyncAsync[Any]: 150 | cache = cache or memory_cache 151 | config_key = _get_config_key(data_id, group, tenant) 152 | try: 153 | config = self._get(data_id, group, tenant) 154 | # todo: this function need to be optimized 155 | cache.set(config_key, config) 156 | return _serialize_config(config, serializer) 157 | except (httpx.ConnectError, httpx.TimeoutException) as exc: 158 | logger.error("Failed to get config from server, try to get from cache. %s", exc) 159 | return _serialize_config(cache.get(config_key), serializer) 160 | except HTTPResponseError as exc: 161 | logger.debug("Failed to get config from server. %s", exc) 162 | if exc.status == 404 and default is not None: 163 | return default 164 | raise 165 | 166 | def subscribe( 167 | self, 168 | data_id: str, 169 | group: str, 170 | tenant: Optional[str] = '', 171 | timeout: Optional[int] = 30_000, 172 | serializer: Optional[Union["Serializer", bool]] = None, 173 | cache: Optional[BaseCache] = None, 174 | callback: Optional[Callable] = None 175 | ) -> SyncAsync[Any]: 176 | cache = cache or MemoryCache() 177 | config_key = _get_config_key(data_id, group, tenant) 178 | last_md5 = _get_md5(cache.get(config_key) or '') 179 | stop_event = threading.Event() 180 | stop_event.cancel = stop_event.set 181 | 182 | def _subscriber(): 183 | nonlocal last_md5 184 | while not stop_event.is_set(): 185 | try: 186 | response = self.subscriber(data_id, group, last_md5, tenant, timeout) 187 | if not response: 188 | continue 189 | logging.info("Configuration update detected.") 190 | last_config = self._get(data_id, group, tenant) 191 | last_md5 = _get_md5(last_config) 192 | cache.set(config_key, last_config) 193 | self._config_callback(callback, last_config, serializer) 194 | except Exception as exc: 195 | logging.error(exc) 196 | stop_event.wait(1) 197 | 198 | thread = threading.Thread(target=_subscriber) 199 | thread.start() 200 | return stop_event 201 | 202 | 203 | class ConfigAsyncOperationMixin: 204 | 205 | @staticmethod 206 | async def _config_callback(callback, config, serializer): 207 | if not callable(callback): 208 | return 209 | 210 | config = _serialize_config(config, serializer) 211 | if asyncio.iscoroutinefunction(callback): 212 | await callback(config) 213 | else: 214 | callback(config) 215 | 216 | async def get( 217 | self, 218 | data_id: str, 219 | group: str, 220 | tenant: Optional[str] = '', 221 | *, 222 | serializer: Optional[Union["Serializer", bool]] = None, 223 | cache: Optional[BaseCache] = None, 224 | default: Optional[str] = None 225 | ) -> SyncAsync[Any]: 226 | cache = cache or memory_cache 227 | config_key = _get_config_key(data_id, group, tenant) 228 | try: 229 | config = await self._get(data_id, group, tenant) 230 | cache.set(config_key, config) 231 | return _serialize_config(config, serializer) 232 | except (httpx.ConnectError, httpx.TimeoutException) as exc: 233 | logger.error("Failed to get config from server, try to get from cache. %s", exc) 234 | return _serialize_config(cache.get(config_key), serializer) 235 | except HTTPResponseError as exc: 236 | logger.debug("Failed to get config from server. %s", exc) 237 | if exc.status == 404 and default is not None: 238 | return default 239 | raise 240 | 241 | async def subscribe( 242 | self, 243 | data_id: str, 244 | group: str, 245 | tenant: Optional[str] = '', 246 | timeout: Optional[int] = 30_000, 247 | serializer: Optional[Union["Serializer", bool]] = None, 248 | cache: Optional[BaseCache] = None, 249 | callback: Optional[Callable] = None, 250 | ) -> SyncAsync[Any]: 251 | cache = cache or MemoryCache() 252 | config_key = _get_config_key(data_id, group, tenant) 253 | last_md5 = _get_md5(cache.get(config_key) or '') 254 | stop_event = threading.Event() 255 | stop_event.cancel = stop_event.set 256 | 257 | async def _async_subscriber(): 258 | nonlocal last_md5 259 | while True: 260 | try: 261 | response = await self.subscriber( 262 | data_id, group, last_md5, tenant, timeout 263 | ) 264 | if not response: 265 | continue 266 | logging.info("Configuration update detected.") 267 | last_config = await self._get(data_id, group, tenant) 268 | last_md5 = _get_md5(last_config) 269 | cache.set(config_key, last_config) 270 | await self._config_callback(callback, last_config, serializer) 271 | except asyncio.CancelledError: 272 | break 273 | except Exception as exc: 274 | logging.error(exc) 275 | await asyncio.sleep(1) 276 | 277 | return asyncio.create_task(_async_subscriber()) 278 | 279 | 280 | class ConfigEndpoint(_BaseConfigEndpoint, ConfigOperationMixin): 281 | ... 282 | 283 | 284 | class ConfigAsyncEndpoint(_BaseConfigEndpoint, ConfigAsyncOperationMixin): 285 | ... 286 | -------------------------------------------------------------------------------- /src/use_nacos/endpoints/endpoint.py: -------------------------------------------------------------------------------- 1 | class Endpoint: 2 | def __init__(self, client: "BaseClient") -> None: 3 | self.client = client 4 | -------------------------------------------------------------------------------- /src/use_nacos/endpoints/instance.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import threading 5 | import time 6 | from functools import partial 7 | from typing import Optional, Any, Literal, List, TypedDict 8 | 9 | import httpx 10 | 11 | from .endpoint import Endpoint 12 | from .._chooser import Chooser 13 | from ..exception import EmptyHealthyInstanceError 14 | from ..typings import SyncAsync, BeatType 15 | 16 | _ConsistencyType = Literal["ephemeral", "persist"] 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class InstanceType(TypedDict): 22 | ip: str 23 | port: int 24 | weight: float 25 | enabled: bool 26 | healthy: bool 27 | metadata: Optional[dict] 28 | 29 | 30 | def _choose_one_healthy(instances: List[InstanceType]) -> InstanceType: 31 | """ Choose one healthy instance """ 32 | hosts = [ 33 | (host, host.get('weight')) 34 | for host in instances 35 | ] 36 | if not hosts: 37 | raise EmptyHealthyInstanceError("No healthy instance found") 38 | chooser = Chooser(hosts) 39 | chooser.refresh() 40 | return chooser.random_with_weight() 41 | 42 | 43 | class InstanceOperationMixin: 44 | 45 | def __getattr__(self, attr) -> SyncAsync[Any]: 46 | return partial(self.request, service_name=attr) 47 | 48 | def request( 49 | self, 50 | method: str, 51 | path: str, 52 | instance: Optional[InstanceType] = None, 53 | service_name: Optional[str] = None, 54 | *args, **kwargs 55 | ) -> SyncAsync[Any]: 56 | """ Request with instance """ 57 | if not any([instance, service_name]): 58 | raise ValueError("Either `instance` or `service_name` should be provided") 59 | if not instance: 60 | instance = self.get_one_healthy(service_name) 61 | url = f"http://{instance['ip']}:{instance['port']}{path}" # noqa 62 | return httpx.Client().request(method=method, url=url, *args, **kwargs) 63 | 64 | def heartbeat( 65 | self, 66 | service_name: str, 67 | ip: str, 68 | port: int, 69 | weight: Optional[float] = 1.0, 70 | namespace_id: Optional[str] = '', 71 | group_name: Optional[str] = None, 72 | ephemeral: Optional[bool] = True, 73 | interval: Optional[int] = 1_000, 74 | skip_exception: Optional[bool] = True, 75 | **kwargs 76 | ) -> SyncAsync[Any]: 77 | stop_event = threading.Event() 78 | stop_event.cancel = stop_event.set 79 | 80 | def _heartbeat(): 81 | while not stop_event.is_set(): 82 | time.sleep(interval / 1_000) 83 | try: 84 | self.beat( 85 | service_name=service_name, 86 | ip=ip, 87 | port=port, 88 | weight=weight, 89 | namespace_id=namespace_id, 90 | group_name=group_name, 91 | ephemeral=ephemeral, 92 | **kwargs 93 | ) 94 | except Exception as exc: 95 | logger.error("Heartbeat error: %s", exc) 96 | if skip_exception: 97 | continue 98 | raise exc 99 | 100 | thread = threading.Thread(target=_heartbeat) 101 | thread.start() 102 | return stop_event 103 | 104 | def get_one_healthy( 105 | self, 106 | service_name: str, 107 | namespace_id: Optional[str] = None, 108 | group_name: Optional[str] = None, 109 | clusters: Optional[str] = None, 110 | 111 | ) -> InstanceType: 112 | """ Get a healthy instance """ 113 | instances = self.list( 114 | service_name=service_name, 115 | namespace_id=namespace_id, 116 | group_name=group_name, 117 | clusters=clusters, 118 | healthy_only=True 119 | ) 120 | return _choose_one_healthy(instances["hosts"]) 121 | 122 | 123 | class InstanceAsyncOperationMixin: 124 | 125 | def __getattr__(self, attr) -> SyncAsync[Any]: 126 | return partial(self.request, service_name=attr) 127 | 128 | async def request( 129 | self, 130 | method: str, 131 | path: str, 132 | instance: Optional[InstanceType] = None, 133 | service_name: Optional[str] = None, 134 | *args, **kwargs 135 | ) -> SyncAsync[Any]: 136 | """ Request with instance """ 137 | if not any([instance, service_name]): 138 | raise ValueError("Either `instance` or `service_name` should be provided") 139 | if not instance: 140 | instance = await self.get_one_healthy(service_name) 141 | url = f"http://{instance['ip']}:{instance['port']}{path}" # noqa 142 | return await httpx.AsyncClient().request(method=method, url=url, *args, **kwargs) 143 | 144 | async def heartbeat( 145 | self, 146 | service_name: str, 147 | ip: str, 148 | port: int, 149 | weight: Optional[float] = 1.0, 150 | namespace_id: Optional[str] = '', 151 | group_name: Optional[str] = None, 152 | ephemeral: Optional[bool] = True, 153 | interval: Optional[int] = 1_000, 154 | skip_exception: Optional[bool] = True, 155 | **kwargs 156 | ) -> SyncAsync[Any]: 157 | stop_event = threading.Event() 158 | stop_event.cancel = stop_event.set 159 | 160 | async def _async_heartbeat(): 161 | while True: 162 | await asyncio.sleep(interval / 1_000) 163 | try: 164 | await self.beat( 165 | service_name=service_name, 166 | ip=ip, 167 | port=port, 168 | weight=weight, 169 | namespace_id=namespace_id, 170 | group_name=group_name, 171 | ephemeral=ephemeral, 172 | **kwargs 173 | ) 174 | except asyncio.CancelledError: 175 | break 176 | except Exception as exc: 177 | logger.error("Heartbeat error: %s", exc) 178 | if skip_exception: 179 | continue 180 | raise exc 181 | 182 | return asyncio.create_task(_async_heartbeat()) 183 | 184 | async def get_one_healthy( 185 | self, 186 | service_name: str, 187 | namespace_id: Optional[str] = None, 188 | group_name: Optional[str] = None, 189 | clusters: Optional[str] = None, 190 | 191 | ) -> InstanceType: 192 | """ Get a healthy instance """ 193 | instances = await self.list( 194 | service_name=service_name, 195 | namespace_id=namespace_id, 196 | group_name=group_name, 197 | clusters=clusters, 198 | healthy_only=True 199 | ) 200 | return _choose_one_healthy(instances["hosts"]) 201 | 202 | 203 | class _BaseInstanceEndpoint(Endpoint): 204 | """ Instance Management API """ 205 | 206 | def register( 207 | self, 208 | service_name: str, 209 | ip: str, 210 | port: int, 211 | namespace_id: Optional[str] = '', 212 | weight: Optional[float] = 1.0, 213 | enabled: Optional[bool] = True, 214 | healthy: Optional[bool] = True, 215 | metadata: Optional[str] = None, 216 | cluster_name: Optional[str] = None, 217 | group_name: Optional[str] = None, 218 | ephemeral: Optional[bool] = None, 219 | ) -> SyncAsync[Any]: 220 | """ Register instance """ 221 | 222 | return self.client.request( 223 | "/nacos/v1/ns/instance", 224 | method="POST", 225 | query={ 226 | "ip": ip, 227 | "port": port, 228 | "serviceName": service_name, 229 | "namespaceId": namespace_id, 230 | "weight": weight, 231 | "enabled": enabled, 232 | "healthy": healthy, 233 | "metadata": metadata, 234 | "clusterName": cluster_name, 235 | "groupName": group_name, 236 | "ephemeral": ephemeral, 237 | } 238 | ) 239 | 240 | def delete( 241 | self, 242 | service_name: str, 243 | ip: str, 244 | port: str, 245 | group_name: Optional[str] = None, 246 | cluster_name: Optional[str] = None, 247 | namespace_id: Optional[str] = None, 248 | ephemeral: Optional[bool] = None, 249 | ) -> SyncAsync[Any]: 250 | return self.client.request( 251 | "/nacos/v1/ns/instance", 252 | method="DELETE", 253 | query={ 254 | "serviceName": service_name, 255 | "ip": ip, 256 | "port": port, 257 | "groupName": group_name, 258 | "clusterName": cluster_name, 259 | "namespaceId": namespace_id, 260 | "ephemeral": ephemeral, 261 | } 262 | ) 263 | 264 | def list( 265 | self, 266 | service_name: str, 267 | namespace_id: Optional[str] = None, 268 | group_name: Optional[str] = None, 269 | clusters: Optional[str] = None, 270 | healthy_only: Optional[bool] = False, 271 | ) -> SyncAsync[Any]: 272 | return self.client.request( 273 | "/nacos/v1/ns/instance/list", 274 | query={ 275 | "serviceName": service_name, 276 | "namespaceId": namespace_id, 277 | "groupName": group_name, 278 | "clusters": clusters, 279 | "healthyOnly": healthy_only, 280 | } 281 | ) 282 | 283 | def update( 284 | self, 285 | service_name: str, 286 | ip: str, 287 | port: int, 288 | namespace_id: Optional[str] = None, 289 | weight: Optional[float] = None, 290 | enabled: Optional[bool] = None, 291 | metadata: Optional[dict] = None, 292 | cluster_name: Optional[str] = None, 293 | group_name: Optional[str] = None, 294 | ephemeral: Optional[bool] = None, 295 | ) -> SyncAsync[Any]: 296 | return self.client.request( 297 | "/nacos/v1/ns/instance", 298 | method="PUT", 299 | query={ 300 | "ip": ip, 301 | "port": port, 302 | "serviceName": service_name, 303 | "namespaceId": namespace_id, 304 | "weight": weight, 305 | "enabled": enabled, 306 | "metadata": metadata, 307 | "clusterName": cluster_name, 308 | "groupName": group_name, 309 | "ephemeral": ephemeral, 310 | } 311 | ) 312 | 313 | def get( 314 | self, 315 | service_name: str, 316 | ip: str, 317 | port: int, 318 | namespace_id: Optional[str] = '', 319 | group_name: Optional[str] = None, 320 | cluster: Optional[str] = None, 321 | healthy_only: Optional[bool] = False, 322 | ephemeral: Optional[bool] = None, 323 | ) -> SyncAsync[Any]: 324 | return self.client.request( 325 | "/nacos/v1/ns/instance", 326 | query={ 327 | "serviceName": service_name, 328 | "ip": ip, 329 | "port": port, 330 | "namespaceId": namespace_id, 331 | "groupName": group_name, 332 | "cluster": cluster, 333 | "healthyOnly": healthy_only, 334 | "ephemeral": ephemeral, 335 | } 336 | ) 337 | 338 | def beat( 339 | self, 340 | service_name: str, 341 | ip: str, 342 | port: int, 343 | weight: Optional[float] = 1.0, 344 | namespace_id: Optional[str] = '', 345 | group_name: Optional[str] = None, 346 | ephemeral: Optional[bool] = None, 347 | **kwargs 348 | ) -> SyncAsync[Any]: 349 | # see: https://github.com/alibaba/nacos/issues/10448#issuecomment-1538178112 350 | serverName = f"{group_name}@@{service_name}" if group_name else service_name 351 | beat_params: BeatType = { 352 | "serviceName": serverName, 353 | "ip": ip, 354 | "port": port, 355 | "weight": weight, 356 | "ephemeral": ephemeral, 357 | **kwargs 358 | } 359 | return self.client.request( 360 | "/nacos/v1/ns/instance/beat", 361 | method="PUT", 362 | query={ 363 | "serviceName": serverName, 364 | "beat": json.dumps(beat_params), 365 | "namespaceId": namespace_id, 366 | "groupName": group_name, 367 | } 368 | ) 369 | 370 | def update_health( 371 | self, 372 | service_name: str, 373 | ip: str, 374 | port: int, 375 | healthy: bool, 376 | namespace_id: Optional[str] = '', 377 | group_name: Optional[str] = None, 378 | cluster_name: Optional[str] = None, 379 | ) -> SyncAsync[Any]: 380 | return self.client.request( 381 | "/nacos/v1/ns/health/instance", 382 | method="PUT", 383 | query={ 384 | "serviceName": service_name, 385 | "ip": ip, 386 | "port": port, 387 | "healthy": healthy, 388 | "namespaceId": namespace_id, 389 | "groupName": group_name, 390 | "clusterName": cluster_name, 391 | } 392 | ) 393 | 394 | def batch_update_metadata( 395 | self, 396 | service_name: str, 397 | namespace_id: str, 398 | metadata: dict, 399 | consistency_type: Optional["_ConsistencyType"] = None, 400 | instances: Optional[list] = None, 401 | ) -> SyncAsync[Any]: 402 | return self.client.request( 403 | "/nacos/v1/ns/instance/metadata/batch", 404 | method="PUT", 405 | query={ 406 | "serviceName": service_name, 407 | "namespaceId": namespace_id, 408 | "metadata": json.dumps(metadata), 409 | "consistencyType": consistency_type, 410 | "instances": json.dumps(instances), 411 | } 412 | ) 413 | 414 | def batch_delete_metadata( 415 | self, 416 | service_name: str, 417 | namespace_id: str, 418 | metadata: dict, 419 | consistency_type: Optional["_ConsistencyType"] = None, 420 | instances: Optional[list] = None, 421 | ) -> SyncAsync[Any]: 422 | return self.client.request( 423 | "/nacos/v1/ns/instance/metadata/batch", 424 | method="DELETE", 425 | query={ 426 | "serviceName": service_name, 427 | "namespaceId": namespace_id, 428 | "metadata": json.dumps(metadata), 429 | "consistencyType": consistency_type, 430 | "instances": json.dumps(instances), 431 | } 432 | ) 433 | 434 | 435 | class InstanceEndpoint(_BaseInstanceEndpoint, InstanceOperationMixin): 436 | ... 437 | 438 | 439 | class InstanceAsyncEndpoint(_BaseInstanceEndpoint, InstanceAsyncOperationMixin): 440 | ... 441 | -------------------------------------------------------------------------------- /src/use_nacos/endpoints/namespace.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Any 3 | 4 | from .endpoint import Endpoint 5 | from ..typings import SyncAsync 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class NamespaceEndpoint(Endpoint): 11 | """ Namespace Management API """ 12 | 13 | def create( 14 | self, 15 | custom_namespace_id: str, 16 | namespace_name: str, 17 | namespace_desc: Optional[str] = None, 18 | ) -> SyncAsync[Any]: 19 | return self.client.request( 20 | "/nacos/v1/console/namespaces", 21 | method="POST", 22 | query={ 23 | "customNamespaceId": custom_namespace_id, 24 | "namespaceName": namespace_name, 25 | "namespaceDesc": namespace_desc 26 | } 27 | ) 28 | 29 | def delete( 30 | self, 31 | namespace_id: str, 32 | ) -> SyncAsync[Any]: 33 | return self.client.request( 34 | "/nacos/v1/console/namespaces", 35 | method="DELETE", 36 | query={ 37 | "namespaceId": namespace_id 38 | } 39 | ) 40 | 41 | def list(self) -> SyncAsync[Any]: 42 | return self.client.request( 43 | "/nacos/v1/console/namespaces" 44 | ) 45 | 46 | def update( 47 | self, 48 | namespace: str, 49 | namespace_show_name: str, 50 | namespace_desc: str 51 | ) -> SyncAsync[Any]: 52 | return self.client.request( 53 | "/nacos/v1/console/namespaces", 54 | method="PUT", 55 | query={ 56 | "namespace": namespace, 57 | "namespaceShowName": namespace_show_name, 58 | "namespaceDesc": namespace_desc 59 | } 60 | ) 61 | -------------------------------------------------------------------------------- /src/use_nacos/endpoints/service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Any 3 | 4 | from .endpoint import Endpoint 5 | from ..typings import SyncAsync 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class ServiceEndpoint(Endpoint): 11 | """ Service Management API """ 12 | 13 | def create( 14 | self, 15 | service_name: str, 16 | namespace_id: Optional[str] = None, 17 | group_name: Optional[str] = None, 18 | protect_threshold: Optional[float] = 0, 19 | metadata: Optional[str] = None, 20 | selector: Optional[str] = None, 21 | ) -> SyncAsync[Any]: 22 | return self.client.request( 23 | "/nacos/v1/ns/service", 24 | method="POST", 25 | query={ 26 | "serviceName": service_name, 27 | "namespaceId": namespace_id, 28 | "groupName": group_name, 29 | "protectThreshold": protect_threshold, 30 | "metadata": metadata, 31 | "selector": selector, 32 | } 33 | ) 34 | 35 | def delete( 36 | self, 37 | service_name: str, 38 | namespace_id: Optional[str] = None, 39 | group_name: Optional[str] = None, 40 | ) -> SyncAsync[Any]: 41 | return self.client.request( 42 | "/nacos/v1/ns/service", 43 | method="DELETE", 44 | query={ 45 | "serviceName": service_name, 46 | "groupName": group_name, 47 | "namespaceId": namespace_id 48 | } 49 | ) 50 | 51 | def list( 52 | self, 53 | page_no: Optional[int] = 1, 54 | page_size: Optional[int] = 20, 55 | namespace_id: Optional[str] = None, 56 | group_name: Optional[str] = None, 57 | ) -> SyncAsync[Any]: 58 | return self.client.request( 59 | "/nacos/v1/ns/service/list", 60 | query={ 61 | "pageNo": page_no, 62 | "pageSize": page_size, 63 | "namespaceId": namespace_id, 64 | "groupName": group_name, 65 | } 66 | ) 67 | 68 | def update( 69 | self, 70 | service_name: str, 71 | namespace_id: Optional[str] = None, 72 | group_name: Optional[str] = None, 73 | protect_threshold: Optional[float] = None, 74 | metadata: Optional[str] = None, 75 | selector: Optional[str] = None, 76 | ) -> SyncAsync[Any]: 77 | return self.client.request( 78 | "/nacos/v1/ns/service", 79 | method="PUT", 80 | query={ 81 | "serviceName": service_name, 82 | "namespaceId": namespace_id, 83 | "groupName": group_name, 84 | "protectThreshold": protect_threshold, 85 | "metadata": metadata, 86 | "selector": selector, 87 | } 88 | ) 89 | 90 | def get( 91 | self, 92 | service_name: str, 93 | namespace_id: Optional[str] = '', 94 | group_ame: Optional[str] = None, 95 | ) -> SyncAsync[Any]: 96 | return self.client.request( 97 | "/nacos/v1/ns/service", 98 | query={ 99 | "serviceName": service_name, 100 | "namespaceId": namespace_id, 101 | "groupName": group_ame, 102 | } 103 | ) 104 | -------------------------------------------------------------------------------- /src/use_nacos/exception.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import httpx 4 | 5 | 6 | class HTTPResponseError(Exception): 7 | """Exception for HTTP errors. 8 | 9 | Responses from the API use HTTP response codes that are used to indicate general 10 | classes of success and error. 11 | """ 12 | 13 | code: str = "client_response_error" 14 | status: int 15 | headers: httpx.Headers 16 | body: str 17 | 18 | def __init__(self, response: httpx.Response, message: Optional[str] = None) -> None: 19 | if message is None: 20 | message = ( 21 | f"Request to Nacos API failed: {response.text}" 22 | ) 23 | super().__init__(message) 24 | self.status = response.status_code 25 | self.headers = response.headers 26 | self.body = response.text 27 | 28 | 29 | class EmptyHealthyInstanceError(Exception): 30 | """Exception for empty healthy instance list error.""" 31 | -------------------------------------------------------------------------------- /src/use_nacos/helper.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import httpx 4 | 5 | 6 | def is_async_client(client: Union[httpx.Client, httpx.AsyncClient]): 7 | """ Check if the client is async client """ 8 | return isinstance(client, httpx.AsyncClient) 9 | -------------------------------------------------------------------------------- /src/use_nacos/serializer.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import json 3 | import sys 4 | 5 | import yaml 6 | 7 | if sys.version_info >= (3, 11): 8 | import tomllib 9 | else: 10 | import tomli as tomllib 11 | 12 | 13 | class Serializer(abc.ABC): 14 | 15 | @abc.abstractmethod 16 | def __call__(self, *args, **kwargs): 17 | raise NotImplementedError 18 | 19 | 20 | class TextSerializer(Serializer): 21 | """ 22 | >>> text = TextSerializer() 23 | >>> text('a = 1') 24 | 'a = 1' 25 | >>> text('a = 1\\n[foo]\\nb = 2') 26 | 'a = 1\\n[foo]\\nb = 2' 27 | """ 28 | 29 | def __call__(self, data) -> str: 30 | return data 31 | 32 | 33 | class JsonSerializer(Serializer): 34 | """ 35 | >>> json_ = JsonSerializer() 36 | >>> json_('{"a": 1}') 37 | {'a': 1} 38 | >>> json_('{"a": 1, "foo": {"b": 2}}') 39 | {'a': 1, 'foo': {'b': 2}} 40 | """ 41 | 42 | def __call__(self, data) -> dict: 43 | try: 44 | return json.loads(data) 45 | except json.JSONDecodeError: 46 | raise SerializerException(f"Cannot parse data: {data!r}") 47 | 48 | 49 | class YamlSerializer(Serializer): 50 | """ 51 | >>> yaml_ = YamlSerializer() 52 | >>> yaml_('a: 1') 53 | {'a': 1} 54 | >>> yaml_('a: 1\\nfoo:\\n b: 2') 55 | {'a': 1, 'foo': {'b': 2}} 56 | """ 57 | 58 | def __call__(self, data) -> dict: 59 | try: 60 | return yaml.safe_load(data) 61 | except yaml.YAMLError: 62 | raise SerializerException(f"Cannot parse data: {data!r}") 63 | 64 | 65 | class TomlSerializer(Serializer): 66 | """ 67 | >>> toml = TomlSerializer() 68 | >>> toml('a = 1') 69 | {'a': 1} 70 | >>> toml('a = 1\\n[foo]\\nb = 2') 71 | {'a': 1, 'foo': {'b': 2}} 72 | """ 73 | 74 | def __call__(self, data) -> dict: 75 | try: 76 | return tomllib.loads(data) 77 | except Exception: 78 | raise SerializerException(f"Cannot parse data: {data!r}") 79 | 80 | 81 | class SerializerException(Exception): 82 | pass 83 | 84 | 85 | class AutoSerializer(Serializer): 86 | """ 87 | >>> auto = AutoSerializer() 88 | >>> auto('a = 1') 89 | {'a': 1} 90 | >>> auto('a = 1\\n[foo]\\nb = 2') 91 | {'a': 1, 'foo': {'b': 2}} 92 | >>> auto('{"a": 1}') 93 | {'a': 1} 94 | >>> auto('{"a": 1, "foo": {"b": 2}}') 95 | {'a': 1, 'foo': {'b': 2}} 96 | >>> auto('a: 1') 97 | {'a': 1} 98 | >>> auto('a: 1\\nfoo:\\n b: 2') 99 | {'a': 1, 'foo': {'b': 2}} 100 | """ 101 | 102 | def __init__(self): 103 | self.serializers = ( 104 | JsonSerializer(), 105 | TomlSerializer(), 106 | YamlSerializer(), 107 | TextSerializer(), 108 | ) 109 | 110 | def __call__(self, data) -> dict: 111 | for serializer in self.serializers: 112 | try: 113 | return serializer(data) 114 | except SerializerException: 115 | pass 116 | raise SerializerException(f"Cannot parse data: {data!r}") 117 | -------------------------------------------------------------------------------- /src/use_nacos/typings.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Union, TypeVar, TypedDict, Optional 2 | 3 | import httpx 4 | 5 | T = TypeVar("T") 6 | SyncAsync = Union[T, Awaitable[T]] 7 | HttpxClient = Union[httpx.Client, httpx.AsyncClient] 8 | 9 | 10 | class BeatType(TypedDict): 11 | service_name: str 12 | ip: str 13 | port: int 14 | weight: int 15 | ephemeral: bool 16 | cluster: Optional[str] 17 | metadata: Optional[Union[dict, str]] 18 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import time 4 | 5 | import httpx 6 | import pytest 7 | 8 | from use_nacos.cache import MemoryCache 9 | from use_nacos.client import NacosClient, NacosAsyncClient 10 | from use_nacos.endpoints import ConfigEndpoint, ConfigAsyncEndpoint, config as conf 11 | from use_nacos.exception import HTTPResponseError 12 | from use_nacos.serializer import JsonSerializer, AutoSerializer, YamlSerializer, TomlSerializer 13 | 14 | 15 | @pytest.fixture 16 | def client(): 17 | return NacosClient() 18 | 19 | 20 | @pytest.fixture 21 | def async_client(): 22 | return NacosAsyncClient() 23 | 24 | 25 | @pytest.fixture 26 | def config(client): 27 | yield ConfigEndpoint(client) 28 | 29 | 30 | @pytest.fixture 31 | def async_config(async_client): 32 | yield ConfigAsyncEndpoint(async_client) 33 | 34 | 35 | def test_config_get_not_found(config): 36 | with pytest.raises(HTTPResponseError): 37 | config.get('not_found', 'not_found') 38 | 39 | 40 | def test_config_get_not_found_default_value(config, mocker): 41 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=HTTPResponseError(response=httpx.Response(404))) 42 | assert config.get('test_config_miss', 'DEFAULT_GROUP', default="default_value") == "default_value" 43 | 44 | 45 | @pytest.mark.parametrize('data_id, group', [ 46 | ('test_config', 'DEFAULT_GROUP'), 47 | ]) 48 | @pytest.mark.parametrize('content ,tenant, type, serialized, expected', [ 49 | ('test_config', '', None, None, 'test_config'), 50 | (json.dumps({"a": "b"}), '', 'json', True, {"a": "b"}), 51 | ('

hello nacos

', '', 'html', False, '

hello nacos

'), 52 | (1234, '', None, True, 1234), 53 | ]) 54 | def test_config_publish_get(config, data_id, group, content, tenant, type, serialized, expected): 55 | assert config.publish(data_id, group, content, tenant, type) 56 | assert config.get( 57 | data_id, 58 | group, 59 | tenant, 60 | serializer=serialized 61 | ) == expected 62 | 63 | 64 | @pytest.mark.parametrize('data_id, group', [ 65 | ('test_config_delete', 'DEFAULT_GROUP'), 66 | ]) 67 | def test_config_delete(config, data_id, group): 68 | config.publish(data_id, group, "123") 69 | assert config.delete(data_id, group) 70 | time.sleep(.1) 71 | with pytest.raises(HTTPResponseError): 72 | config.get(data_id, group) 73 | 74 | 75 | def test_config_subscriber(config): 76 | dataId = f"test_config_{random.randint(0, 1000)}" 77 | assert config.publish(data_id=dataId, group="DEFAULT_GROUP", content="123") 78 | 79 | def _callback(new_config): 80 | assert new_config == "456" 81 | config_subscriber.cancel() 82 | 83 | config_subscriber = config.subscribe( 84 | data_id=dataId, 85 | group="DEFAULT_GROUP", 86 | callback=_callback 87 | ) 88 | # update config 89 | config.publish(data_id=dataId, group="DEFAULT_GROUP", content="456") 90 | 91 | 92 | # ===================== async config tests ===================== 93 | @pytest.mark.asyncio 94 | async def test_async_config_get_not_found(async_config): 95 | with pytest.raises(HTTPResponseError): 96 | await async_config.get('not_found', 'not_found') 97 | 98 | 99 | @pytest.mark.parametrize('data_id, group', [ 100 | ('test_config', 'DEFAULT_GROUP'), 101 | ]) 102 | @pytest.mark.parametrize('content ,tenant, type, serializer, expected', [ 103 | ('test_config', '', None, False, 'test_config'), 104 | (json.dumps({"a": "b"}), '', 'json', JsonSerializer(), {"a": "b"}), 105 | ('

hello nacos

', '', 'html', False, '

hello nacos

'), 106 | (1234, '', None, True, 1234), 107 | ]) 108 | @pytest.mark.asyncio 109 | async def test_async_config_publish_get(async_config, data_id, group, content, tenant, type, serializer, expected): 110 | assert await async_config.publish(data_id, group, content, tenant, type) 111 | assert await async_config.get( 112 | data_id, 113 | group, 114 | tenant, 115 | serializer=serializer 116 | ) == expected 117 | 118 | 119 | @pytest.mark.parametrize('data_id, group', [ 120 | ('test_config_delete_async', 'DEFAULT_GROUP'), 121 | ]) 122 | @pytest.mark.asyncio 123 | async def test_async_config_delete(async_config, data_id, group): 124 | assert await async_config.publish(data_id, group, "123") 125 | assert await async_config.delete(data_id, group) 126 | time.sleep(.1) 127 | with pytest.raises(HTTPResponseError): 128 | await async_config.get(data_id, group) 129 | 130 | 131 | @pytest.mark.parametrize("data_id, group, tenant, expected", [ 132 | ("test_config", "DEFAULT_GROUP", "", "test_config#DEFAULT_GROUP#"), 133 | ("test_config", "DEFAULT_GROUP", "test_tenant", "test_config#DEFAULT_GROUP#test_tenant"), 134 | ]) 135 | def test__get_config_key(config, data_id, group, tenant, expected): 136 | assert conf._get_config_key(data_id, group, tenant) == expected 137 | 138 | 139 | @pytest.mark.parametrize("content, expected", [ 140 | ("1234", "81dc9bdb52d04dc20036dbd8313ed055"), 141 | ({"a": 1}, "5268827fe25d043c696340679639cf67") 142 | ]) 143 | def test__get_md5(content, expected): 144 | assert conf._get_md5(content) == expected 145 | 146 | 147 | def test_mock_exception(config, mocker): 148 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=HTTPResponseError(response=httpx.Response(500))) 149 | with pytest.raises(HTTPResponseError): 150 | config.get('test_config', 'DEFAULT_GROUP') 151 | 152 | 153 | def test_mock_network_error_exception(config, mocker): 154 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=httpx.TimeoutException("")) 155 | assert config.get('test_config_1', 'DEFAULT_GROUP') is None 156 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=httpx.ConnectError("")) 157 | assert config.get('test_config_1', 'DEFAULT_GROUP') is None 158 | 159 | 160 | def test_config_from_cache(config, mocker): 161 | mc = MemoryCache() 162 | mc.set("test_config_cache#DEFAULT_GROUP#", "abc") 163 | # mock timeout 164 | mocker.patch.object(ConfigEndpoint, '_get', side_effect=httpx.TimeoutException("")) 165 | assert config.get('test_config_cache', 'DEFAULT_GROUP', cache=mc) == "abc" 166 | 167 | 168 | @pytest.mark.parametrize("conf_str, serializer, expected", [ 169 | ("123", AutoSerializer(), 123), 170 | ('{"a": 2}', JsonSerializer(), {"a": 2}), 171 | ('a: 1\nfoo:\n b: 2', YamlSerializer(), {'a': 1, 'foo': {'b': 2}}), 172 | ('a = 1\n[foo]\nb = 2', TomlSerializer(), {'a': 1, 'foo': {'b': 2}}), 173 | ]) 174 | def test_config_serializer(conf_str, serializer, expected): 175 | assert conf._serialize_config(conf_str, serializer) == expected 176 | -------------------------------------------------------------------------------- /tests/test_instance.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | import pytest 5 | 6 | from use_nacos.client import NacosClient 7 | from use_nacos.endpoints import InstanceEndpoint 8 | from use_nacos.exception import HTTPResponseError 9 | 10 | server_addr = os.environ.get('SERVER_ADDR') 11 | 12 | 13 | @pytest.fixture 14 | def client(): 15 | return NacosClient(server_addr=server_addr, username="nacos", password="nacos") 16 | 17 | 18 | @pytest.fixture 19 | def instance(client): 20 | return InstanceEndpoint(client) 21 | 22 | 23 | def test_instance_get_not_found(instance): 24 | with pytest.raises(HTTPResponseError): 25 | instance.get('not_found', 'not_found', 8000) 26 | 27 | 28 | def test_instance_register(instance): 29 | assert instance.register('test', '127.0.0.1', 8000) == 'ok' 30 | 31 | 32 | def test_instance_get(instance): 33 | _instance = instance.get('test', '127.0.0.1', 8000) 34 | assert _instance['metadata'] == {} 35 | assert _instance['ip'] == '127.0.0.1' 36 | 37 | 38 | def test_instance_update(instance): 39 | mock_service = { 40 | 'service_name': 'test', 41 | 'ip': '127.0.0.1', 42 | 'port': 8000, 43 | } 44 | assert instance.register(**mock_service) == 'ok' 45 | assert instance.update(weight=2.0, **mock_service) == 'ok' 46 | time.sleep(.5) 47 | _instance = instance.get(**mock_service) 48 | assert _instance['weight'] == 2.0 49 | assert _instance['healthy'] is True 50 | --------------------------------------------------------------------------------