crosslocator 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ """crosslocator — define a UI selector once, resolve it on every platform."""
2
+ from .appium import use_appium
3
+ from .locator import (
4
+ CrossLocatorError,
5
+ Locator,
6
+ LocatorNotFound,
7
+ PlatformNotSet,
8
+ )
9
+ from .platform import (
10
+ Platform,
11
+ get_current_platform,
12
+ set_current_platform,
13
+ set_platform_provider,
14
+ )
15
+
16
+ __version__ = "0.2.0"
17
+
18
+ __all__ = [
19
+ "Locator",
20
+ "Platform",
21
+ "get_current_platform",
22
+ "set_current_platform",
23
+ "set_platform_provider",
24
+ "use_appium",
25
+ "CrossLocatorError",
26
+ "LocatorNotFound",
27
+ "PlatformNotSet",
28
+ "__version__",
29
+ ]
crosslocator/appium.py ADDED
@@ -0,0 +1,55 @@
1
+ """Automatic platform detection from a live Appium session.
2
+
3
+ crosslocator stays framework-agnostic: this module only reads the session
4
+ capabilities and translates them into a :class:`~crosslocator.platform.Platform`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Mapping, Optional
9
+
10
+ from .platform import Platform, set_platform_provider
11
+
12
+
13
+ def platform_from_capabilities(
14
+ capabilities: Optional[Mapping[str, Any]],
15
+ ) -> Optional[Platform]:
16
+ """Translate Appium capabilities into a :class:`Platform`.
17
+
18
+ Returns ``None`` when the platform cannot be determined.
19
+ """
20
+ if not capabilities:
21
+ return None
22
+ name = str(
23
+ capabilities.get("platformName")
24
+ or capabilities.get("appium:platformName")
25
+ or ""
26
+ ).strip().lower()
27
+ device = str(
28
+ capabilities.get("deviceName")
29
+ or capabilities.get("appium:deviceName")
30
+ or ""
31
+ ).lower()
32
+ if name == "android":
33
+ return Platform.ANDROID
34
+ if name in ("ios", "iphoneos", "iphone os"):
35
+ return Platform.IPAD if "ipad" in device else Platform.IOS
36
+ if name in ("windows", "win"):
37
+ return Platform.WINDOWS
38
+ return None
39
+
40
+
41
+ def use_appium(driver: Optional[Any]) -> None:
42
+ """Detect the current platform automatically from an Appium ``driver``.
43
+
44
+ Registers a provider that reads ``driver.capabilities`` on each resolution,
45
+ so you no longer need to call :func:`set_current_platform` by hand. Pass
46
+ ``None`` to stop using the driver.
47
+ """
48
+ if driver is None:
49
+ set_platform_provider(None)
50
+ return
51
+
52
+ def provider() -> Optional[Platform]:
53
+ return platform_from_capabilities(getattr(driver, "capabilities", None))
54
+
55
+ set_platform_provider(provider)
@@ -0,0 +1,107 @@
1
+ """The :class:`Locator` type: define a selector once, resolve it per platform."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Dict, Optional
5
+
6
+ from .platform import (
7
+ Platform,
8
+ PlatformLike,
9
+ get_current_platform,
10
+ normalize_platform,
11
+ )
12
+
13
+
14
+ class CrossLocatorError(Exception):
15
+ """Base class for all crosslocator errors."""
16
+
17
+
18
+ class PlatformNotSet(CrossLocatorError):
19
+ """Raised when a resolution needs the current platform but none is set."""
20
+
21
+
22
+ class LocatorNotFound(CrossLocatorError, LookupError):
23
+ """Raised when no selector is available for the requested platform."""
24
+
25
+
26
+ # A platform inherits from the next one in its chain when it has no own selector.
27
+ _FALLBACK_CHAINS: Dict[Platform, tuple] = {
28
+ Platform.IPAD: (Platform.IPAD, Platform.IOS),
29
+ }
30
+
31
+
32
+ class Locator:
33
+ """A UI selector declared once for several platforms.
34
+
35
+ Define a selector per platform plus an optional shared ``default``. At
36
+ runtime, :meth:`resolve` (or :meth:`for_platform`) returns the right
37
+ selector string. crosslocator only *routes* strings — it never parses them
38
+ nor touches your driver.
39
+ """
40
+
41
+ __slots__ = ("_selectors", "_default")
42
+
43
+ def __init__(
44
+ self,
45
+ *,
46
+ android: Optional[str] = None,
47
+ ios: Optional[str] = None,
48
+ ipad: Optional[str] = None,
49
+ windows: Optional[str] = None,
50
+ web: Optional[str] = None,
51
+ default: Optional[str] = None,
52
+ ) -> None:
53
+ self._selectors: Dict[Platform, Optional[str]] = {
54
+ Platform.ANDROID: android,
55
+ Platform.IOS: ios,
56
+ Platform.IPAD: ipad,
57
+ Platform.WINDOWS: windows,
58
+ Platform.WEB: web,
59
+ }
60
+ self._default = default
61
+ if default is None and not any(self._selectors.values()):
62
+ raise ValueError(
63
+ "A Locator needs at least one platform selector or a default."
64
+ )
65
+
66
+ def for_platform(self, platform: PlatformLike) -> str:
67
+ """Return the selector for ``platform``, applying fallbacks.
68
+
69
+ Resolution order: the platform's own selector, then its fallback chain
70
+ (e.g. iPad -> iOS), then ``default``. Raises :class:`LocatorNotFound`
71
+ if nothing matches.
72
+ """
73
+ target = normalize_platform(platform)
74
+ for candidate in _FALLBACK_CHAINS.get(target, (target,)):
75
+ value = self._selectors.get(candidate)
76
+ if value is not None:
77
+ return value
78
+ if self._default is not None:
79
+ return self._default
80
+ raise LocatorNotFound(
81
+ f"No selector defined for platform {target.value!r} and no default set."
82
+ )
83
+
84
+ def resolve(self) -> str:
85
+ """Return the selector for the current platform.
86
+
87
+ Raises :class:`PlatformNotSet` when the current platform is unknown.
88
+ """
89
+ platform = get_current_platform()
90
+ if platform is None:
91
+ raise PlatformNotSet(
92
+ "No current platform set. Use set_current_platform(...) or the "
93
+ "CROSSLOCATOR_PLATFORM environment variable."
94
+ )
95
+ return self.for_platform(platform)
96
+
97
+ def __repr__(self) -> str:
98
+ parts = {p.value: v for p, v in self._selectors.items() if v is not None}
99
+ if self._default is not None:
100
+ parts["default"] = self._default
101
+ inner = ", ".join(f"{k}={v!r}" for k, v in parts.items())
102
+ return f"Locator({inner})"
103
+
104
+ def __eq__(self, other: object) -> bool:
105
+ if not isinstance(other, Locator):
106
+ return NotImplemented
107
+ return self._selectors == other._selectors and self._default == other._default
@@ -0,0 +1,89 @@
1
+ """Platform identification and current-platform state.
2
+
3
+ crosslocator resolves selectors against a *current platform*. This module owns
4
+ the small enum of supported platforms, alias normalization, and the resolution
5
+ of the current platform from (in priority order):
6
+
7
+ 1. an explicit value set via :func:`set_current_platform`
8
+ 2. the ``CROSSLOCATOR_PLATFORM`` environment variable
9
+ 3. a registered provider (e.g. reading a live Appium session)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from enum import Enum
15
+ from typing import Callable, Optional, Union
16
+
17
+
18
+ class Platform(Enum):
19
+ """A UI platform a selector can target."""
20
+
21
+ ANDROID = "android"
22
+ IOS = "ios"
23
+ IPAD = "ipad"
24
+ WINDOWS = "windows"
25
+ WEB = "web"
26
+
27
+
28
+ PlatformLike = Union[Platform, str]
29
+
30
+ ENV_VAR = "CROSSLOCATOR_PLATFORM"
31
+
32
+ _ALIASES = {
33
+ "android": Platform.ANDROID,
34
+ "ios": Platform.IOS,
35
+ "iphone": Platform.IOS,
36
+ "ipad": Platform.IPAD,
37
+ "ipados": Platform.IPAD,
38
+ "windows": Platform.WINDOWS,
39
+ "win": Platform.WINDOWS,
40
+ "web": Platform.WEB,
41
+ "browser": Platform.WEB,
42
+ }
43
+
44
+ _current: Optional[Platform] = None
45
+ _provider: Optional[Callable[[], Optional[PlatformLike]]] = None
46
+
47
+
48
+ def normalize_platform(value: PlatformLike) -> Platform:
49
+ """Return the :class:`Platform` for ``value``, accepting common aliases.
50
+
51
+ Raises :class:`ValueError` for unknown platforms.
52
+ """
53
+ if isinstance(value, Platform):
54
+ return value
55
+ key = str(value).strip().lower()
56
+ try:
57
+ return _ALIASES[key]
58
+ except KeyError:
59
+ raise ValueError(f"Unknown platform: {value!r}") from None
60
+
61
+
62
+ def set_current_platform(value: Optional[PlatformLike]) -> None:
63
+ """Set (or clear, with ``None``) the platform used to resolve selectors."""
64
+ global _current
65
+ _current = None if value is None else normalize_platform(value)
66
+
67
+
68
+ def set_platform_provider(
69
+ provider: Optional[Callable[[], Optional[PlatformLike]]],
70
+ ) -> None:
71
+ """Register a callable returning the current platform (e.g. from Appium).
72
+
73
+ Pass ``None`` to unregister.
74
+ """
75
+ global _provider
76
+ _provider = provider
77
+
78
+
79
+ def get_current_platform() -> Optional[Platform]:
80
+ """Resolve the current platform, or ``None`` if it cannot be determined."""
81
+ if _current is not None:
82
+ return _current
83
+ env = os.environ.get(ENV_VAR)
84
+ if env:
85
+ return normalize_platform(env)
86
+ if _provider is not None:
87
+ result = _provider()
88
+ return None if result is None else normalize_platform(result)
89
+ return None
crosslocator/py.typed ADDED
File without changes
crosslocator/robot.py ADDED
@@ -0,0 +1,51 @@
1
+ """Robot Framework integration for crosslocator.
2
+
3
+ Import in a suite with::
4
+
5
+ Library crosslocator.robot.CrossLocator
6
+
7
+ Locators are plain Python objects: define them in a Python variable file and
8
+ import them, then resolve them with the keywords below.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional
13
+
14
+ from .locator import Locator
15
+ from .platform import get_current_platform, set_current_platform
16
+
17
+
18
+ class CrossLocator:
19
+ """Robot Framework library exposing crosslocator as keywords."""
20
+
21
+ ROBOT_LIBRARY_SCOPE = "GLOBAL"
22
+
23
+ def set_current_platform(self, platform: str) -> None:
24
+ """Set the platform used to resolve locators.
25
+
26
+ Accepts ``android``, ``ios``, ``ipad``, ``windows`` or ``web``.
27
+ """
28
+ set_current_platform(platform)
29
+
30
+ def get_current_platform(self) -> str:
31
+ """Return the current platform name, or an empty string if unset."""
32
+ platform = get_current_platform()
33
+ return platform.value if platform is not None else ""
34
+
35
+ def resolve_locator(self, locator: Locator, platform: Optional[str] = None) -> str:
36
+ """Resolve a ``Locator`` to a selector string.
37
+
38
+ Uses ``platform`` when given, otherwise the current platform.
39
+ """
40
+ if platform:
41
+ return locator.for_platform(platform)
42
+ return locator.resolve()
43
+
44
+ def use_appium_session(self) -> None:
45
+ """Auto-detect the platform from the active AppiumLibrary session."""
46
+ from robot.libraries.BuiltIn import BuiltIn
47
+
48
+ from .appium import use_appium
49
+
50
+ appium_lib = BuiltIn().get_library_instance("AppiumLibrary")
51
+ use_appium(appium_lib._current_application())
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: crosslocator
3
+ Version: 0.2.0
4
+ Summary: Define a UI selector once, resolve it on every platform (Android, iOS, iPadOS, Windows, web).
5
+ Project-URL: Homepage, https://github.com/julien-becheny/crosslocator
6
+ Project-URL: Repository, https://github.com/julien-becheny/crosslocator
7
+ Project-URL: Issues, https://github.com/julien-becheny/crosslocator/issues
8
+ Author: Julien Becheny
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: android,appium,ios,locators,mobile-testing,playwright,robotframework,selectors,test-automation,windows
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Robot Framework :: Library
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.9
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # crosslocator
29
+
30
+ **Define a UI selector once. Resolve it on every platform.**
31
+
32
+ Cross-platform test automation means maintaining the same element for Android,
33
+ iOS, iPadOS, Windows and the web. Most teams end up either duplicating selectors
34
+ or scattering `if platform == ...` branches through their page objects.
35
+
36
+ `crosslocator` lets you declare a selector **once**, per platform, and resolves
37
+ the right one at runtime — from **pure Python (Appium), Robot Framework, or any
38
+ other framework**. It only routes selector *strings*; it never wraps your driver.
39
+
40
+ ```python
41
+ from crosslocator import Locator
42
+
43
+ ADD_PATIENT = Locator(
44
+ android="accessibility_id=add_patient",
45
+ ios="add_patient",
46
+ windows='//Button[@Name="Add"]',
47
+ default="id=add",
48
+ )
49
+
50
+ ADD_PATIENT.for_platform("ios") # -> "add_patient"
51
+ ```
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install crosslocator
57
+ ```
58
+
59
+ Zero runtime dependencies.
60
+
61
+ ## Why
62
+
63
+ - **One source of truth** per element — no duplicated selectors, no platform `if/else`.
64
+ - **Framework-agnostic** — Appium in plain Python, Robot Framework, or anything else.
65
+ - **Sensible fallbacks** — iPad inherits from iOS, and a shared `default` covers the rest.
66
+ - **Typed** and dependency-free.
67
+
68
+ ## Quick start (Python)
69
+
70
+ ```python
71
+ from crosslocator import Locator, set_current_platform
72
+
73
+ ADD_PATIENT = Locator(
74
+ android="accessibility_id=add_patient",
75
+ ios="add_patient",
76
+ windows='//Button[@Name="Add"]',
77
+ )
78
+
79
+ set_current_platform("android") # usually done once, at session start
80
+ ADD_PATIENT.resolve() # -> "accessibility_id=add_patient"
81
+ ```
82
+
83
+ ## Robot Framework
84
+
85
+ ```robotframework
86
+ *** Settings ***
87
+ Library crosslocator.robot.CrossLocator
88
+ Variables locators.py # ADD_PATIENT defined as a Locator here
89
+
90
+ *** Test Cases ***
91
+ Open Add Patient
92
+ Set Current Platform android
93
+ ${selector}= Resolve Locator ${ADD_PATIENT}
94
+ # -> pass ${selector} to AppiumLibrary / Browser as usual
95
+ ```
96
+
97
+ ## How the platform is resolved
98
+
99
+ `resolve()` determines the current platform in this order:
100
+
101
+ 1. an explicit `set_current_platform(...)`
102
+ 2. the `CROSSLOCATOR_PLATFORM` environment variable
103
+ 3. a registered provider — typically an Appium session (see below)
104
+
105
+ ## Automatic platform detection (Appium)
106
+
107
+ Let crosslocator read the platform straight from a live Appium session, so you
108
+ never call `set_current_platform` by hand.
109
+
110
+ **Python (Appium-Python-Client):**
111
+
112
+ ```python
113
+ from crosslocator import use_appium
114
+
115
+ use_appium(driver) # your Appium webdriver
116
+ LOGIN_BUTTON.resolve() # platform detected from the session capabilities
117
+ ```
118
+
119
+ **Robot Framework (AppiumLibrary):**
120
+
121
+ ```robotframework
122
+ Open Application ${REMOTE_URL} platformName=Android ...
123
+ Use Appium Session
124
+ ${selector}= Resolve Locator ${LOGIN_BUTTON}
125
+ ```
126
+
127
+ iPad is detected from the session device name and resolves to iPad selectors
128
+ (falling back to iOS when none are defined).
129
+
130
+ ## Fallback rules
131
+
132
+ - `ipad` → falls back to `ios` when no iPad-specific selector is set
133
+ - any platform → falls back to `default`
134
+ - nothing found → `LocatorNotFound`
135
+
136
+ ## Examples
137
+
138
+ Runnable examples (no device needed) live in the [`examples/`](examples/) folder —
139
+ a login screen resolved across Android, iOS, iPadOS and Windows, plus a Robot
140
+ Framework suite.
141
+
142
+ ## Roadmap
143
+
144
+ - **v0.1:** the core `Locator` type, platform resolution, Robot Framework keywords.
145
+ - **v0.2 (current):** automatic platform detection from a live Appium session.
146
+ - **Next:** Playwright/Browser helpers, and declarative loading of whole screens from YAML/dict.
147
+
148
+ Issues and contributions are welcome.
149
+
150
+ ## License
151
+
152
+ MIT © 2026 Julien Becheny
@@ -0,0 +1,10 @@
1
+ crosslocator/__init__.py,sha256=APhm-eLujcLCQDuSEZMbzQlL7g0iryzuElSkulXXtHQ,622
2
+ crosslocator/appium.py,sha256=F-nzbMgO6Ennf7XTWJTSrtSgNTrLDSqBnVRSmgK65Yw,1793
3
+ crosslocator/locator.py,sha256=PoNZeHAkD4bGlVIQZwC8D5aVrU_aXyFTMNEftz3sR2k,3762
4
+ crosslocator/platform.py,sha256=c9or-XWVoG4GA6pIMuFKh2oG070d8G1-PJcKxo_W-Zc,2601
5
+ crosslocator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ crosslocator/robot.py,sha256=g8e3GKhxb9qGY86GRqG23br2wg49ts-dao4mALQiUEo,1729
7
+ crosslocator-0.2.0.dist-info/METADATA,sha256=9vGHNdMnu9N5Ceb-2KK5dVEijOmQQLRkzI2NANToN2k,4823
8
+ crosslocator-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ crosslocator-0.2.0.dist-info/licenses/LICENSE,sha256=FWUI76-_E-wL4KfLcr4hKvTb_2-iS6XTMdLHfV8atfs,1092
10
+ crosslocator-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julien Becheny
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.