clan_cli: Rewrite ClanURI

This commit is contained in:
Luis Hebendanz 2024-03-07 02:24:36 +07:00
parent 9f632e90c5
commit 93c868a3b7
3 changed files with 107 additions and 198 deletions

View File

@ -5,13 +5,13 @@ import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, member from enum import Enum, member
from pathlib import Path from pathlib import Path
from typing import Any, Self from typing import Any
from .errors import ClanError from .errors import ClanError
# Define an enum with different members that have different values # Define an enum with different members that have different values
class ClanScheme(Enum): class ClanUrl(Enum):
# Use the dataclass decorator to add fields and methods to the members # Use the dataclass decorator to add fields and methods to the members
@member @member
@dataclass @dataclass
@ -33,137 +33,116 @@ class ClanScheme(Enum):
# Parameters defined here will be DELETED from the nested uri # Parameters defined here will be DELETED from the nested uri
# so make sure there are no conflicts with other webservices # so make sure there are no conflicts with other webservices
@dataclass @dataclass
class ClanParameters: class MachineParams:
flake_attr: str = "defaultVM" dummy_opt: str = "dummy"
@dataclass
class MachineData:
name: str = "defaultVM"
params: MachineParams = dataclasses.field(default_factory=MachineParams)
# Define the ClanURI class # Define the ClanURI class
class ClanURI: class ClanURI:
_orig_uri: str
_nested_uri: str
_components: urllib.parse.ParseResult
url: ClanUrl
machines: list[MachineData]
# Initialize the class with a clan:// URI # Initialize the class with a clan:// URI
def __init__(self, uri: str) -> None: def __init__(self, uri: str) -> None:
self.machines = []
# users might copy whitespace along with the uri # users might copy whitespace along with the uri
uri = uri.strip() uri = uri.strip()
self._full_uri = uri self._orig_uri = uri
# Check if the URI starts with clan:// # Check if the URI starts with clan://
# If it does, remove the clan:// prefix # If it does, remove the clan:// prefix
if uri.startswith("clan://"): if uri.startswith("clan://"):
self._nested_uri = uri[7:] self._nested_uri = uri[7:]
else: else:
raise ClanError(f"Invalid scheme: expected clan://, got {uri}") raise ClanError(f"Invalid uri: expected clan://, got {uri}")
# Parse the URI into components # Parse the URI into components
# scheme://netloc/path;parameters?query#fragment # url://netloc/path;parameters?query#fragment
self._components = urllib.parse.urlparse(self._nested_uri) self._components = urllib.parse.urlparse(self._nested_uri)
# Parse the query string into a dictionary # Replace the query string in the components with the new query string
query = urllib.parse.parse_qs(self._components.query) clean_comps = self._components._replace(
query=self._components.query, fragment=""
)
# Create a new dictionary with only the parameters we want # Parse the URL into a ClanUrl object
# example: https://example.com?flake_attr=myVM&password=1234 self.url = self._parse_url(clean_comps)
# becomes: https://example.com?password=1234
# clan_params = {"flake_attr": "myVM"} # Parse the fragment into a list of machine queries
# query = {"password": ["1234"]} # Then parse every machine query into a MachineParameters object
clan_params: dict[str, str] = {} machine_frags = list(
for field in dataclasses.fields(ClanParameters): filter(lambda x: len(x) > 0, self._components.fragment.split("#"))
if field.name in query: )
values = query[field.name] for machine_frag in machine_frags:
machine = self._parse_machine_query(machine_frag)
self.machines.append(machine)
# If there are no machine fragments, add a default machine
if len(machine_frags) == 0:
self.machines.append(MachineData())
def _parse_url(self, comps: urllib.parse.ParseResult) -> ClanUrl:
comb = (
comps.scheme,
comps.netloc,
comps.path,
comps.params,
comps.query,
comps.fragment,
)
match comb:
case ("file", "", path, "", "", _) | ("", "", path, "", "", _): # type: ignore
url = ClanUrl.LOCAL.value(Path(path).expanduser().resolve()) # type: ignore
case _:
url = ClanUrl.REMOTE.value(comps.geturl()) # type: ignore
return url
def _parse_machine_query(self, machine_frag: str) -> MachineData:
comp = urllib.parse.urlparse(machine_frag)
query = urllib.parse.parse_qs(comp.query)
machine_name = comp.path
machine_params: dict[str, Any] = {}
for dfield in dataclasses.fields(MachineParams):
if dfield.name in query:
values = query[dfield.name]
if len(values) > 1: if len(values) > 1:
raise ClanError(f"Multiple values for parameter: {field.name}") raise ClanError(f"Multiple values for parameter: {dfield.name}")
clan_params[field.name] = values[0] machine_params[dfield.name] = values[0]
# Remove the field from the query dictionary # Remove the field from the query dictionary
# clan uri and nested uri share one namespace for query parameters # clan uri and nested uri share one namespace for query parameters
# we need to make sure there are no conflicts # we need to make sure there are no conflicts
del query[field.name] del query[dfield.name]
# Reencode the query dictionary into a query string params = MachineParams(**machine_params)
real_query = urllib.parse.urlencode(query, doseq=True) machine = MachineData(name=machine_name, params=params)
return machine
# If the fragment contains a #, use the part after the # as the flake_attr def get_orig_uri(self) -> str:
# on multiple #, use the first one return self._orig_uri
if self._components.fragment != "":
clan_params["flake_attr"] = self._components.fragment.split("#")[0]
# Replace the query string in the components with the new query string def get_url(self) -> str:
self._components = self._components._replace(query=real_query, fragment="") match self.url:
case ClanUrl.LOCAL.value(path):
# Create a ClanParameters object from the clan_params dictionary
self.params = ClanParameters(**clan_params)
comb = (
self._components.scheme,
self._components.netloc,
self._components.path,
self._components.params,
self._components.query,
self._components.fragment,
)
match comb:
case ("file", "", path, "", "", "") | ("", "", path, "", "", _): # type: ignore
self.scheme = ClanScheme.LOCAL.value(Path(path).expanduser().resolve()) # type: ignore
case _:
self.scheme = ClanScheme.REMOTE.value(self._components.geturl()) # type: ignore
def get_internal(self) -> str:
match self.scheme:
case ClanScheme.LOCAL.value(path):
return str(path) return str(path)
case ClanScheme.REMOTE.value(url): case ClanUrl.REMOTE.value(url):
return url return url
case _: case _:
raise ClanError(f"Unsupported uri components: {self.scheme}") raise ClanError(f"Unsupported uri components: {self.url}")
def get_full_uri(self) -> str:
return self._full_uri
def get_id(self) -> str:
return f"{self.get_internal()}#{self.params.flake_attr}"
@classmethod
def from_path(
cls, # noqa
path: Path,
flake_attr: str | None = None,
params: dict[str, Any] | ClanParameters | None = None,
) -> Self:
return cls.from_str(str(path), flake_attr=flake_attr, params=params)
@classmethod
def from_str(
cls, # noqa
url: str,
flake_attr: str | None = None,
params: dict[str, Any] | ClanParameters | None = None,
) -> Self:
if flake_attr is not None and params is not None:
raise ClanError("flake_attr and params are mutually exclusive")
prefix = "clan://"
if url.startswith(prefix):
url = url[len(prefix) :]
if params is None and flake_attr is None:
return cls(f"clan://{url}")
comp = urllib.parse.urlparse(url)
query = urllib.parse.parse_qs(comp.query)
if isinstance(params, dict):
query.update(params)
elif isinstance(params, ClanParameters):
query.update(params.__dict__)
elif flake_attr is not None:
query["flake_attr"] = [flake_attr]
else:
raise ClanError(f"Unsupported params type: {type(params)}")
new_query = urllib.parse.urlencode(query, doseq=True)
comp = comp._replace(query=new_query)
new_url = urllib.parse.urlunparse(comp)
return cls(f"clan://{new_url}")
def __str__(self) -> str: def __str__(self) -> str:
return self.get_full_uri() return self.get_orig_uri()
def __repr__(self) -> str: def __repr__(self) -> str:
return f"ClanURI({self.get_full_uri()})" return f"ClanURI({self})"

View File

@ -4,7 +4,7 @@ import datetime
from clan_cli.flakes.inspect import inspect_flake from clan_cli.flakes.inspect import inspect_flake
from ..clan_uri import ClanParameters, ClanURI from ..clan_uri import ClanURI, MachineParams
from ..errors import ClanCmdError from ..errors import ClanCmdError
from ..locked_open import write_history_file from ..locked_open import write_history_file
from ..nix import nix_metadata from ..nix import nix_metadata
@ -28,9 +28,9 @@ def update_history() -> list[HistoryEntry]:
) )
uri = ClanURI.from_str( uri = ClanURI.from_str(
url=str(entry.flake.flake_url), url=str(entry.flake.flake_url),
params=ClanParameters(entry.flake.flake_attr), params=MachineParams(machine_name=entry.flake.flake_attr),
) )
flake = inspect_flake(uri.get_internal(), uri.params.flake_attr) flake = inspect_flake(uri.get_url(), uri.machines[0].name)
flake.flake_url = str(flake.flake_url) flake.flake_url = str(flake.flake_url)
entry = HistoryEntry( entry = HistoryEntry(
flake=flake, last_used=datetime.datetime.now().isoformat() flake=flake, last_used=datetime.datetime.now().isoformat()

View File

@ -1,28 +1,28 @@
from pathlib import Path from pathlib import Path
from clan_cli.clan_uri import ClanParameters, ClanScheme, ClanURI from clan_cli.clan_uri import ClanURI, ClanUrl
def test_get_internal() -> None: def test_get_url() -> None:
# Create a ClanURI object from a remote URI with parameters # Create a ClanURI object from a remote URI with parameters
uri = ClanURI("clan://https://example.com?flake_attr=myVM&password=1234") uri = ClanURI("clan://https://example.com?password=1234#myVM")
assert uri.get_internal() == "https://example.com?password=1234" assert uri.get_url() == "https://example.com?password=1234"
uri = ClanURI("clan://~/Downloads") uri = ClanURI("clan://~/Downloads")
assert uri.get_internal().endswith("/Downloads") assert uri.get_url().endswith("/Downloads")
uri = ClanURI("clan:///home/user/Downloads") uri = ClanURI("clan:///home/user/Downloads")
assert uri.get_internal() == "/home/user/Downloads" assert uri.get_url() == "/home/user/Downloads"
uri = ClanURI("clan://file:///home/user/Downloads") uri = ClanURI("clan://file:///home/user/Downloads")
assert uri.get_internal() == "/home/user/Downloads" assert uri.get_url() == "/home/user/Downloads"
def test_local_uri() -> None: def test_local_uri() -> None:
# Create a ClanURI object from a local URI # Create a ClanURI object from a local URI
uri = ClanURI("clan://file:///home/user/Downloads") uri = ClanURI("clan://file:///home/user/Downloads")
match uri.scheme: match uri.url:
case ClanScheme.LOCAL.value(path): case ClanUrl.LOCAL.value(path):
assert path == Path("/home/user/Downloads") # type: ignore assert path == Path("/home/user/Downloads") # type: ignore
case _: case _:
assert False assert False
@ -32,8 +32,8 @@ def test_is_remote() -> None:
# Create a ClanURI object from a remote URI # Create a ClanURI object from a remote URI
uri = ClanURI("clan://https://example.com") uri = ClanURI("clan://https://example.com")
match uri.scheme: match uri.url:
case ClanScheme.REMOTE.value(url): case ClanUrl.REMOTE.value(url):
assert url == "https://example.com" # type: ignore assert url == "https://example.com" # type: ignore
case _: case _:
assert False assert False
@ -42,104 +42,34 @@ def test_is_remote() -> None:
def test_direct_local_path() -> None: def test_direct_local_path() -> None:
# Create a ClanURI object from a remote URI # Create a ClanURI object from a remote URI
uri = ClanURI("clan://~/Downloads") uri = ClanURI("clan://~/Downloads")
assert uri.get_internal().endswith("/Downloads") assert uri.get_url().endswith("/Downloads")
def test_direct_local_path2() -> None: def test_direct_local_path2() -> None:
# Create a ClanURI object from a remote URI # Create a ClanURI object from a remote URI
uri = ClanURI("clan:///home/user/Downloads") uri = ClanURI("clan:///home/user/Downloads")
assert uri.get_internal() == "/home/user/Downloads" assert uri.get_url() == "/home/user/Downloads"
def test_remote_with_clanparams() -> None: def test_remote_with_clanparams() -> None:
# Create a ClanURI object from a remote URI with parameters # Create a ClanURI object from a remote URI with parameters
uri = ClanURI("clan://https://example.com") uri = ClanURI("clan://https://example.com")
assert uri.params.flake_attr == "defaultVM" assert uri.machines[0].name == "defaultVM"
match uri.scheme: match uri.url:
case ClanScheme.REMOTE.value(url): case ClanUrl.REMOTE.value(url):
assert url == "https://example.com" # type: ignore assert url == "https://example.com" # type: ignore
case _: case _:
assert False assert False
def test_from_path_with_custom() -> None:
# Create a ClanURI object from a remote URI with parameters
uri_str = Path("/home/user/Downloads")
params = ClanParameters(flake_attr="myVM")
uri = ClanURI.from_path(uri_str, params=params)
assert uri.params.flake_attr == "myVM"
match uri.scheme:
case ClanScheme.LOCAL.value(path):
assert path == Path("/home/user/Downloads") # type: ignore
case _:
assert False
def test_from_path_with_default() -> None:
# Create a ClanURI object from a remote URI with parameters
uri_str = Path("/home/user/Downloads")
params = ClanParameters()
uri = ClanURI.from_path(uri_str, params=params)
assert uri.params.flake_attr == "defaultVM"
match uri.scheme:
case ClanScheme.LOCAL.value(path):
assert path == Path("/home/user/Downloads") # type: ignore
case _:
assert False
def test_from_str() -> None:
# Create a ClanURI object from a remote URI with parameters
uri_str = "https://example.com?password=asdasd&test=1234"
params = ClanParameters(flake_attr="myVM")
uri = ClanURI.from_str(url=uri_str, params=params)
assert uri.params.flake_attr == "myVM"
match uri.scheme:
case ClanScheme.REMOTE.value(url):
assert url == "https://example.com?password=asdasd&test=1234" # type: ignore
case _:
assert False
uri = ClanURI.from_str(url=uri_str, params={"flake_attr": "myVM"})
assert uri.params.flake_attr == "myVM"
uri = ClanURI.from_str(uri_str, "myVM")
assert uri.params.flake_attr == "myVM"
uri_str = "~/Downloads/democlan"
params = ClanParameters(flake_attr="myVM")
uri = ClanURI.from_str(url=uri_str, params=params)
assert uri.params.flake_attr == "myVM"
assert uri.get_internal().endswith("/Downloads/democlan")
uri_str = "~/Downloads/democlan"
uri = ClanURI.from_str(url=uri_str)
assert uri.params.flake_attr == "defaultVM"
assert uri.get_internal().endswith("/Downloads/democlan")
uri_str = "clan://~/Downloads/democlan"
uri = ClanURI.from_str(url=uri_str)
assert uri.params.flake_attr == "defaultVM"
assert uri.get_internal().endswith("/Downloads/democlan")
def test_remote_with_all_params() -> None: def test_remote_with_all_params() -> None:
# Create a ClanURI object from a remote URI with parameters uri = ClanURI("clan://https://example.com?password=12345#myVM#secondVM")
uri = ClanURI("clan://https://example.com?flake_attr=myVM&password=1234") assert uri.machines[0].name == "myVM"
assert uri.params.flake_attr == "myVM" assert uri.machines[1].name == "secondVM"
match uri.url:
match uri.scheme: case ClanUrl.REMOTE.value(url):
case ClanScheme.REMOTE.value(url): assert url == "https://example.com?password=12345" # type: ignore
assert url == "https://example.com?password=1234" # type: ignore
case _: case _:
assert False assert False
def test_with_hashtag() -> None:
uri = ClanURI("clan://https://example.com?flake_attr=thirdVM#myVM#secondVM")
assert uri.params.flake_attr == "myVM"