mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-25 01:02:38 +02:00
migrate joyn and new template
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
# streaming_providers/base/testing/migration_pack.py
|
||||
"""
|
||||
Migration pack -- everything a migrator needs to know about a v1 provider
|
||||
BEFORE touching it, collected automatically so nobody has to guess or ask:
|
||||
|
||||
python -m streaming_providers.base.testing.migration_pack <key> \
|
||||
--repo <path to the repo root> [--extra-root <other repo> ...] \
|
||||
[-o pack.txt] [--json before.json] [--compare before.json]
|
||||
|
||||
Sections of the report
|
||||
1. REGISTRY how the class is registered and how the registry builds it:
|
||||
__init__ signature, SUPPORTED_COUNTRIES, instances the
|
||||
metadata mixin reports, and the SOURCE of the registry
|
||||
methods that construct providers (create_instance, ...)
|
||||
2. SURFACE every attribute the provider classes define (below the
|
||||
base), with kind and signature. Snapshot it before the
|
||||
migration (--json) and compare after (--compare): a
|
||||
migration PRESERVES the public surface, it never adds to it.
|
||||
3. LEGACY HELPERS source of every `self.<name>` the provider uses that is
|
||||
defined in a legacy mixin / the base class (e.g.
|
||||
_build_provider_headers, _setup_http_manager): you read
|
||||
them here instead of asking for the files.
|
||||
4. BACK-REFERENCES managers that reach into the provider (`self._provider.x`,
|
||||
`provider.x`): each one needs an explicit collaborator in v2.
|
||||
5. EXTERNAL USERS where the class name, the provider key and the commonly
|
||||
moved attributes are referenced OUTSIDE the provider
|
||||
directory (and in --extra-root repos: Kodi addon, PVR addon,
|
||||
backend ...). Run it with ALL repos you have.
|
||||
|
||||
Read-only: it imports the package and reads files; it never instantiates the
|
||||
provider and performs no network calls.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
MOVED_ATTRS = (
|
||||
"_last_auth_error", "_profile", "bearer_token", "entitlement_tag",
|
||||
"channel_manager", "_playout_cache", "_ensure_authenticated",
|
||||
"set_user_credentials", "get_auth_details", "get_last_auth_error",
|
||||
"authenticator", "provider_config", "http_manager", "get_profile",
|
||||
)
|
||||
TEXT_EXT = (".py", ".json", ".xml", ".yaml", ".yml", ".cpp", ".h", ".hpp",
|
||||
".md", ".txt", ".po", ".ini", ".cfg", ".js", ".ts")
|
||||
SKIP_DIRS = {".git", "__pycache__", "node_modules", "build", "dist", ".venv", "venv", ".idea"}
|
||||
MAX_HITS = 150
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. registry
|
||||
# --------------------------------------------------------------------------
|
||||
def registry_facts(cls, key: str) -> str:
|
||||
out = [f"class {cls.__module__}.{cls.__qualname__}",
|
||||
f"registry key {key!r}",
|
||||
f"__init__ {_sig(cls.__init__)}"]
|
||||
countries = getattr(cls, "SUPPORTED_COUNTRIES", None)
|
||||
out.append(f"SUPPORTED_COUNTRIES = {countries!r}")
|
||||
for name in ("supports_multiple_countries", "get_plugin_key", "get_all_possible_instances"):
|
||||
fn = getattr(cls, name, None)
|
||||
if callable(fn):
|
||||
try:
|
||||
out.append(f"{name}() -> {fn()!r}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
out.append(f"{name}() raised {type(exc).__name__}: {exc}")
|
||||
try:
|
||||
import importlib
|
||||
reg = importlib.import_module(cls.__module__.split(".providers.")[0] + ".base.provider_registry")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
out.append(f"\n(provider_registry not importable: {exc})")
|
||||
return "\n".join(out)
|
||||
for owner in ("ProviderMetadata", "ProviderRegistry"):
|
||||
klass = getattr(reg, owner, None)
|
||||
for meth in ("create_instance", "_extract_metadata", "discover_all_providers", "get_provider"):
|
||||
fn = getattr(klass, meth, None) if klass else None
|
||||
if fn is not None:
|
||||
out.append(f"\n--- {owner}.{meth} ---\n{_source(fn)}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. surface
|
||||
# --------------------------------------------------------------------------
|
||||
def _is_base(klass) -> bool:
|
||||
mod = getattr(klass, "__module__", "") or ""
|
||||
return klass is object or ".base." in mod + "." and (
|
||||
".base.provider" in mod or ".base.managed_provider" in mod
|
||||
or ".provider_mixins." in mod or ".base.models" in mod or ".base.managers" in mod)
|
||||
|
||||
|
||||
def surface(cls) -> Dict[str, dict]:
|
||||
snap: Dict[str, dict] = {}
|
||||
for klass in cls.__mro__:
|
||||
if _is_base(klass) or klass is object:
|
||||
continue
|
||||
for name, value in vars(klass).items():
|
||||
if name.startswith("__") or name in snap:
|
||||
continue
|
||||
if isinstance(value, property):
|
||||
kind, sig = "property", ""
|
||||
elif isinstance(value, classmethod):
|
||||
kind, sig = "classmethod", _sig(value.__func__)
|
||||
elif isinstance(value, staticmethod):
|
||||
kind, sig = "staticmethod", _sig(value.__func__)
|
||||
elif callable(value):
|
||||
kind, sig = "method", _sig(value)
|
||||
else:
|
||||
kind, sig = "attribute", ""
|
||||
snap[name] = {"kind": kind, "sig": sig, "defined_in": klass.__qualname__}
|
||||
return snap
|
||||
|
||||
|
||||
def diff_surfaces(before: Dict[str, dict], after: Dict[str, dict]) -> str:
|
||||
lines = []
|
||||
for name in sorted(set(before) - set(after)):
|
||||
lines.append(f"REMOVED {name} ({before[name]['kind']} {before[name]['sig']})")
|
||||
for name in sorted(set(after) - set(before)):
|
||||
lines.append(f"ADDED {name} ({after[name]['kind']} {after[name]['sig']})")
|
||||
for name in sorted(set(before) & set(after)):
|
||||
if before[name]["kind"] != after[name]["kind"] or before[name]["sig"] != after[name]["sig"]:
|
||||
lines.append(f"CHANGED {name}: {before[name]['kind']} {before[name]['sig']}"
|
||||
f" -> {after[name]['kind']} {after[name]['sig']}")
|
||||
return "\n".join(lines) or "(no differences)"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3 + 4. legacy helpers and back-references
|
||||
# --------------------------------------------------------------------------
|
||||
def _self_names(tree: ast.AST, owner: str = "self") -> List[str]:
|
||||
names = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == owner:
|
||||
names.append(node.attr)
|
||||
return names
|
||||
|
||||
|
||||
def _package_files(cls) -> List[str]:
|
||||
module = sys.modules.get(cls.__module__)
|
||||
path = getattr(module, "__file__", None)
|
||||
if not path:
|
||||
return []
|
||||
folder = os.path.dirname(path)
|
||||
return sorted(os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(".py"))
|
||||
|
||||
|
||||
def legacy_helpers(cls) -> str:
|
||||
own_file = inspect.getsourcefile(cls)
|
||||
names = []
|
||||
with open(own_file, encoding="utf-8") as fh:
|
||||
names = _self_names(ast.parse(fh.read()))
|
||||
seen, out = set(), []
|
||||
for name in names:
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
for klass in cls.__mro__:
|
||||
if name in vars(klass):
|
||||
if klass is cls or not _is_base(klass) or klass.__module__.endswith("managed_provider"):
|
||||
break
|
||||
member = vars(klass)[name]
|
||||
member = getattr(member, "fget", None) or getattr(member, "__func__", member)
|
||||
if callable(member):
|
||||
out.append(f"--- {klass.__qualname__}.{name} ({klass.__module__}) ---\n{_source(member)}")
|
||||
break
|
||||
return "\n\n".join(out) or "(the provider uses no legacy helper)"
|
||||
|
||||
|
||||
def back_references(cls) -> str:
|
||||
own = inspect.getsourcefile(cls)
|
||||
hits = []
|
||||
for path in _package_files(cls):
|
||||
if os.path.abspath(path) == os.path.abspath(own):
|
||||
continue
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
tree = ast.parse(fh.read())
|
||||
for owner in ("_provider", "provider"):
|
||||
attrs = sorted(set(_self_names(tree, owner)))
|
||||
# attribute access on `self._provider.<name>`
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Attribute)
|
||||
and isinstance(node.value.value, ast.Name) and node.value.value.id == "self"
|
||||
and node.value.attr in ("_provider", "provider")):
|
||||
hits.append(f"{os.path.basename(path)}:{node.lineno} self.{node.value.attr}.{node.attr}")
|
||||
return "\n".join(sorted(set(hits))) or "(no manager reaches into the provider)"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. external users
|
||||
# --------------------------------------------------------------------------
|
||||
def external_references(roots: Iterable[str], key: str, class_name: str, exclude_dir: Optional[str]) -> str:
|
||||
patterns = {
|
||||
f"class name {class_name}": re.compile(rf"\b{re.escape(class_name)}\b"),
|
||||
f"provider key '{key}'": re.compile(rf"""["']{re.escape(key)}(?:_[a-z]{{2,3}})?["']"""),
|
||||
"moved/compat attributes": re.compile(r"\.(" + "|".join(map(re.escape, MOVED_ATTRS)) + r")\b"),
|
||||
}
|
||||
results = {label: [] for label in patterns}
|
||||
exclude = os.path.abspath(exclude_dir) if exclude_dir else None
|
||||
for root in roots:
|
||||
for folder, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
if exclude and os.path.abspath(folder).startswith(exclude):
|
||||
continue
|
||||
for name in files:
|
||||
if not name.endswith(TEXT_EXT):
|
||||
continue
|
||||
path = os.path.join(folder, name)
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as fh:
|
||||
for number, line in enumerate(fh, 1):
|
||||
for label, rx in patterns.items():
|
||||
if rx.search(line) and len(results[label]) < MAX_HITS:
|
||||
results[label].append(f"{path}:{number}: {line.strip()[:140]}")
|
||||
except OSError:
|
||||
continue
|
||||
parts = []
|
||||
for label, hits in results.items():
|
||||
body = "\n".join(hits) if hits else "(none)"
|
||||
parts.append(f"## {label}\n{body}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# helpers / CLI
|
||||
# --------------------------------------------------------------------------
|
||||
def _sig(fn) -> str:
|
||||
try:
|
||||
return str(inspect.signature(fn))
|
||||
except (TypeError, ValueError):
|
||||
return "(?)"
|
||||
|
||||
|
||||
def _source(fn) -> str:
|
||||
try:
|
||||
return inspect.getsource(fn)
|
||||
except (OSError, TypeError):
|
||||
return f"(source not available for {fn!r})"
|
||||
|
||||
|
||||
def build_report(key: str, roots: List[str], extra: List[str]) -> str:
|
||||
import streaming_providers
|
||||
registry = getattr(streaming_providers, "AVAILABLE_PROVIDERS", {})
|
||||
if key not in registry:
|
||||
return f"unknown provider key {key!r}; registered: {sorted(registry)}"
|
||||
cls = registry[key]
|
||||
provider_dir = os.path.dirname(inspect.getsourcefile(cls))
|
||||
sections = [
|
||||
("1. REGISTRY", registry_facts(cls, key)),
|
||||
("2. SURFACE (snapshot with --json before, --compare after)",
|
||||
"\n".join(f"{n:34} {v['kind']:12} {v['sig']} [{v['defined_in']}]"
|
||||
for n, v in sorted(surface(cls).items()))),
|
||||
("3. LEGACY HELPERS used by the provider (source)", legacy_helpers(cls)),
|
||||
("4. BACK-REFERENCES from managers to the provider", back_references(cls)),
|
||||
("5. EXTERNAL USERS (outside the provider directory)",
|
||||
external_references(roots + extra, key, cls.__name__, provider_dir)),
|
||||
]
|
||||
return "\n\n".join(f"{'=' * 78}\n{title}\n{'=' * 78}\n{body}" for title, body in sections)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("key")
|
||||
ap.add_argument("--repo", action="append", default=[], help="repo root to search (repeatable)")
|
||||
ap.add_argument("--extra-root", action="append", default=[], help="additional repos (Kodi addon, PVR addon, ...)")
|
||||
ap.add_argument("-o", "--output")
|
||||
ap.add_argument("--json", help="write the surface snapshot to this file")
|
||||
ap.add_argument("--compare", help="compare the current surface with a snapshot file")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
import streaming_providers
|
||||
cls = getattr(streaming_providers, "AVAILABLE_PROVIDERS", {}).get(args.key)
|
||||
if args.json or args.compare:
|
||||
if cls is None:
|
||||
print(f"unknown provider key {args.key!r}")
|
||||
return 2
|
||||
current = surface(cls)
|
||||
if args.json:
|
||||
with open(args.json, "w", encoding="utf-8") as fh:
|
||||
json.dump(current, fh, indent=2, sort_keys=True)
|
||||
print(f"surface snapshot written to {args.json} ({len(current)} names)")
|
||||
if args.compare:
|
||||
with open(args.compare, encoding="utf-8") as fh:
|
||||
print(diff_surfaces(json.load(fh), current))
|
||||
return 0
|
||||
|
||||
report = build_report(args.key, args.repo, args.extra_root)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as fh:
|
||||
fh.write(report)
|
||||
print(f"written to {args.output}")
|
||||
else:
|
||||
print(report)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,158 @@
|
||||
# streaming_providers/base/testing/provider_contract.py
|
||||
"""
|
||||
Provider contract -- checks every v2 (ManagedProvider) provider must pass.
|
||||
|
||||
from streaming_providers.base.testing.provider_contract import check_provider
|
||||
problems, warnings = check_provider(ExampleProvider, "example")
|
||||
assert problems == []
|
||||
|
||||
It instantiates the provider with a NETWORK-DISABLED http manager (every call
|
||||
raises ConnectionError), so it also proves that the constructor performs no
|
||||
network I/O. Nothing here talks to a real backend.
|
||||
|
||||
Errors (must be fixed) vs warnings (advisory):
|
||||
|
||||
errors
|
||||
* class is a ManagedProvider; explicit HEADERS_FROM_MANAGERS in the class
|
||||
body (the decision must be visible, not inherited)
|
||||
* instantiation works offline with country = first SUPPORTED_COUNTRIES
|
||||
(plus `ctor_kwargs` for providers whose constructor requires more)
|
||||
* provider_name == registry key, get_plugin_key() == registry key
|
||||
* capability flags agree with the managers that exist
|
||||
* DRM declared: a manager overrides get_*_drm => DRM_IN_MANAGERS or
|
||||
_build_drm(); DRM_IN_MANAGERS=True with no manager implementing it is
|
||||
a mistake; both at once is refused by ManagedProvider itself
|
||||
* header hooks overridden on a manager => HEADERS_FROM_MANAGERS must be
|
||||
True (otherwise the hooks are dead code)
|
||||
* EPG: no manager => epg_window == (0, 0); manager => implements_epg agrees
|
||||
with epg_window != (0, 0)
|
||||
* recordings: schedule_recording overridden => supports_scheduling True
|
||||
* the provider package exports only the provider in __all__ and does not
|
||||
expose ManagedProvider in its namespace (discovery picks the first
|
||||
StreamingProvider subclass it finds)
|
||||
warnings
|
||||
* country not normalised to lowercase after construction
|
||||
* empty SUPPORTED_COUNTRIES, empty PROVIDER_LABEL
|
||||
"""
|
||||
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from typing import List, Optional, Tuple
|
||||
from unittest import mock
|
||||
|
||||
from ..managed_provider import ManagedProvider
|
||||
from ..managers import CatchupManager, ChannelManager, VodManager
|
||||
|
||||
|
||||
class NullHttp:
|
||||
"""HTTP manager that fails every call: proves 'no network in __init__'."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
def _blocked(*args, **kwargs):
|
||||
raise ConnectionError(f"network disabled in contract test ({name})")
|
||||
return _blocked
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _offline(cls):
|
||||
with mock.patch.object(cls, "_setup_http_manager", lambda self, **kw: NullHttp(), create=True):
|
||||
yield
|
||||
|
||||
|
||||
def _overrides(obj, abc, name) -> bool:
|
||||
return obj is not None and getattr(type(obj), name) is not getattr(abc, name)
|
||||
|
||||
|
||||
def check_provider(cls, key: str, ctor_kwargs: Optional[dict] = None) -> Tuple[List[str], List[str]]:
|
||||
errors: List[str] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
if not (isinstance(cls, type) and issubclass(cls, ManagedProvider)):
|
||||
return [f"{cls!r} is not a ManagedProvider subclass"], warnings
|
||||
|
||||
declared = [
|
||||
k for k in cls.__mro__
|
||||
if k is not ManagedProvider and issubclass(k, ManagedProvider)
|
||||
and "HEADERS_FROM_MANAGERS" in vars(k)
|
||||
]
|
||||
if not declared:
|
||||
errors.append("HEADERS_FROM_MANAGERS is not declared in the class body "
|
||||
"(decide: True = manager header hooks, False = legacy {})")
|
||||
|
||||
countries = list(getattr(cls, "SUPPORTED_COUNTRIES", []) or [])
|
||||
if not countries:
|
||||
warnings.append("SUPPORTED_COUNTRIES is empty (single-country default)")
|
||||
if not getattr(cls, "PROVIDER_LABEL", ""):
|
||||
warnings.append("PROVIDER_LABEL is empty")
|
||||
|
||||
try:
|
||||
with _offline(cls):
|
||||
# ctor_kwargs: constructor arguments a provider REQUIRES beyond
|
||||
# `country` (e.g. Joyn's config object); they override the default.
|
||||
kwargs = {"country": countries[0] if countries else "DE", **(ctor_kwargs or {})}
|
||||
inst = cls(**kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - report, don't crash the suite
|
||||
return errors + [f"cannot instantiate offline: {type(exc).__name__}: {exc}"], warnings
|
||||
|
||||
if inst.provider_name != key:
|
||||
errors.append(f"provider_name {inst.provider_name!r} != registry key {key!r}")
|
||||
try:
|
||||
if cls.get_plugin_key() != key:
|
||||
errors.append(f"get_plugin_key() {cls.get_plugin_key()!r} != registry key {key!r}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"get_plugin_key() failed: {exc}")
|
||||
if inst.country != inst.country.lower():
|
||||
warnings.append(f"country {inst.country!r} is not lower-case after construction")
|
||||
|
||||
# -- flags vs managers
|
||||
caps = inst.capabilities
|
||||
for name in ("channels", "vod", "recordings", "favorites", "bookmarks"):
|
||||
if caps[name] != (getattr(inst, name) is not None):
|
||||
errors.append(f"implements_{name} disagrees with the {name} manager")
|
||||
|
||||
# -- DRM declaration
|
||||
folded = _overrides(inst.channels, ChannelManager, "get_channel_drm") or \
|
||||
_overrides(inst.vod, VodManager, "get_vod_drm")
|
||||
if folded and not (cls.DRM_IN_MANAGERS or inst.drm is not None):
|
||||
errors.append("a manager implements get_*_drm but DRM_IN_MANAGERS is False "
|
||||
"and no _build_drm(): the DRM is never served")
|
||||
if cls.DRM_IN_MANAGERS and not folded:
|
||||
errors.append("DRM_IN_MANAGERS = True but no manager overrides get_channel_drm/get_vod_drm")
|
||||
|
||||
# -- dead header hooks
|
||||
hooks = (
|
||||
_overrides(inst.channels, ChannelManager, "get_channel_manifest_headers"),
|
||||
_overrides(inst.channels, ChannelManager, "get_segment_headers"),
|
||||
_overrides(inst.vod, VodManager, "get_vod_manifest_headers"),
|
||||
_overrides(inst.vod, VodManager, "get_segment_headers"),
|
||||
_overrides(inst.catchup, CatchupManager, "get_catchup_manifest_headers"),
|
||||
_overrides(inst.catchup, CatchupManager, "get_catchup_segment_headers"),
|
||||
)
|
||||
if any(hooks) and not cls.HEADERS_FROM_MANAGERS:
|
||||
errors.append("a manager overrides header hooks but HEADERS_FROM_MANAGERS is False: "
|
||||
"the hooks are dead code")
|
||||
|
||||
# -- EPG
|
||||
if inst.epg is None:
|
||||
if inst.epg_window != (0, 0) or inst.implements_epg:
|
||||
errors.append("no EPG manager but epg_window != (0, 0) / implements_epg")
|
||||
elif inst.implements_epg != (inst.epg_window != (0, 0)):
|
||||
errors.append("implements_epg disagrees with epg_window != (0, 0)")
|
||||
|
||||
# -- recordings
|
||||
if inst.recordings is not None:
|
||||
from ..managers import RecordingsManager
|
||||
if _overrides(inst.recordings, RecordingsManager, "schedule_recording") \
|
||||
and not inst.recordings.supports_scheduling:
|
||||
errors.append("schedule_recording is overridden but supports_scheduling is False")
|
||||
|
||||
# -- package namespace (discovery safety)
|
||||
package = sys.modules.get(cls.__module__.rsplit(".", 1)[0])
|
||||
if package is not None:
|
||||
exported = getattr(package, "__all__", None)
|
||||
if exported is None or cls.__name__ not in exported:
|
||||
errors.append("package __init__ must define __all__ containing the provider class")
|
||||
if hasattr(package, "ManagedProvider"):
|
||||
errors.append("package namespace exposes ManagedProvider (discovery could pick it)")
|
||||
|
||||
return errors, warnings
|
||||
Reference in New Issue
Block a user