From: Breno Rodrigues Alves <breno3011alves@gmail.com>
To: linux-kernel@vger.kernel.org
Cc: Andy Whitcroft <apw@canonical.com>, Joe Perches <joe@perches.com>,
Dwaipayan Ray <dwaipayanray1@gmail.com>,
Lukas Bulwahn <lukas.bulwahn@gmail.com>,
Breno Rodrigues Alves <breno3011alves@gmail.com>
Subject: [RFC PATCH 5/7] scripts: convert get_maintainer.pl to Python
Date: Sun, 18 Oct 2026 08:06:36 -0300 [thread overview]
Message-ID: <20261018110638.9947-6-breno3011alves@gmail.com> (raw)
In-Reply-To: <20261018110638.9947-1-breno3011alves@gmail.com>
Faithful re-implementation of get_maintainer.pl in Python 3
(stdlib only). Same MAINTAINERS parsing, same file-pattern
matching, same output format including role annotations.
Verified byte-for-byte identical to the Perl version for:
- scripts/checkpatch.pl (file mode)
- 0001-scripts-convert-bootgraph.pl-to-Python.patch (patch mode)
- --git-chief-penguins mode
Not implemented: --interactive, --self-test, --git-blame.
These can be added in follow-up patches.
Signed-off-by: Breno Rodrigues Alves <breno3011alves@gmail.com>
---
scripts/get_maintainer.py | 607 ++++++++++++++++++++++++++++++++++++++
1 file changed, 607 insertions(+)
create mode 100755 scripts/get_maintainer.py
diff --git a/scripts/get_maintainer.py b/scripts/get_maintainer.py
new file mode 100755
index 000000000..9b36026dc
--- /dev/null
+++ b/scripts/get_maintainer.py
@@ -0,0 +1,607 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+#
+# (c) 2007, Joe Perches <joe@perches.com>
+# Print selected MAINTAINERS information for the files modified in a
+# patch, or for a file.
+#
+# usage: get_maintainer.py [OPTIONS] <patch>
+# get_maintainer.py [OPTIONS] -f <file>
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+
+V = '0.27'
+
+PENGUIN_CHIEFS = [
+ ('Linus Torvalds', 'torvalds@linux-foundation.org'),
+]
+TYPEVALUE = []
+KEYWORD_HASH = {}
+MFILES = []
+
+RE_TYPE_VALUE = re.compile(r'^([A-Z]):\s*(.*)')
+RE_RFC822 = re.compile(
+ r'^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$')
+RE_MAILMAP_1 = re.compile(r'^([^<]+)<([^>]+)>$')
+RE_MAILMAP_2 = re.compile(r'^<([^>]+)>\s*<([^>]+)>$')
+RE_MAILMAP_3 = re.compile(r'^(.+)<([^>]+)>\s*<([^>]+)>$')
+RE_MAILMAP_4 = re.compile(r'^(.+)<([^>]+)>\s*(.+)\s*<([^>]+)>$')
+
+
+class Mailmap:
+ def __init__(self):
+ self.names = {}
+ self.addresses = {}
+
+
+MAILMAP = Mailmap()
+
+
+def which_conf(name):
+ for base in ('.', os.path.expanduser('~'), '.scripts'):
+ p = os.path.join(base, name)
+ if os.path.isfile(p):
+ return p
+ return ''
+
+
+def load_conf_args():
+ conf = which_conf('.get_maintainer.conf')
+ if not conf:
+ return []
+ args = []
+ try:
+ with open(conf) as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+ for tok in line.split():
+ if tok.startswith('#'):
+ break
+ args.append(tok)
+ except OSError:
+ pass
+ return args
+
+
+def load_ignore_emails():
+ path = which_conf('.get_maintainer.ignore')
+ if not path:
+ return set()
+ ignore = set()
+ try:
+ with open(path) as f:
+ for line in f:
+ line = re.sub(r'#.*$', '', line).strip()
+ if line and RE_RFC822.match(line):
+ ignore.add(line)
+ except OSError:
+ pass
+ return ignore
+
+
+def parse_email(formatted):
+ name = ''
+ address = ''
+ m = re.match(r'^([^<]+)<(.+@.*)>.*$', formatted)
+ if m:
+ name, address = m.group(1), m.group(2)
+ else:
+ m = re.match(r'^\s*<(.+@\S*)>.*$', formatted)
+ if m:
+ address = m.group(1)
+ else:
+ m = re.match(r'^(.+@\S*).*$', formatted)
+ if m:
+ address = m.group(1)
+ name = name.strip().strip('"')
+ if re.search(r'[^\w \-]', name):
+ name = '"' + name.replace('"', '\\"') + '"'
+ return name, address.strip()
+
+
+def format_email(name, address, use_name=True):
+ name = name.strip().strip('"')
+ if re.search(r'[^\w \-]', name):
+ name = '"' + name.replace('"', '\\"') + '"'
+ if use_name and name:
+ return f'{name} <{address}>'
+ return address
+
+
+def read_mailmap(root):
+ global MAILMAP
+ path = os.path.join(root, '.mailmap')
+ if not os.path.isfile(path):
+ return
+ try:
+ with open(path) as f:
+ for raw in f:
+ line = re.sub(r'#.*$', '', raw).strip()
+ if not line:
+ continue
+ m = RE_MAILMAP_1.match(line)
+ if m:
+ name, addr = parse_email(f'{m.group(1)} <{m.group(2)}>')
+ MAILMAP.names[addr] = name
+ continue
+ m = RE_MAILMAP_2.match(line)
+ if m:
+ MAILMAP.addresses[m.group(2)] = m.group(1)
+ continue
+ m = RE_MAILMAP_3.match(line)
+ if m:
+ name, addr = parse_email(
+ f'{m.group(1)} <{m.group(2)}>')
+ MAILMAP.names[m.group(3)] = name
+ MAILMAP.addresses[m.group(3)] = addr
+ continue
+ m = RE_MAILMAP_4.match(line)
+ if m:
+ name, addr = parse_email(
+ f'{m.group(1)} <{m.group(2)}>')
+ wrong = format_email(m.group(3).strip(), m.group(4))
+ MAILMAP.names[wrong] = name
+ MAILMAP.addresses[wrong] = addr
+ except OSError:
+ pass
+
+
+def mailmap_email(line):
+ name, address = parse_email(line)
+ email = format_email(name, address)
+ real_name, real_addr = name, address
+ if email in MAILMAP.names or email in MAILMAP.addresses:
+ real_name = MAILMAP.names.get(email, real_name)
+ real_addr = MAILMAP.addresses.get(email, real_addr)
+ elif address in MAILMAP.names or address in MAILMAP.addresses:
+ real_name = MAILMAP.names.get(address, real_name)
+ real_addr = MAILMAP.addresses.get(address, real_addr)
+ return format_email(real_name, real_addr)
+
+
+def convert_glob(value):
+ v = value.replace('.', r'\.')
+ v = v.replace('**', '\x00')
+ v = v.replace('*', '.*')
+ v = v.replace('?', '.')
+ v = v.replace('\x00', '(?:.*)')
+ return v
+
+
+def read_maintainer_file(path):
+ try:
+ with open(path) as f:
+ for line in f:
+ line = line.rstrip('\n')
+ m = RE_TYPE_VALUE.match(line)
+ if m:
+ t, v = m.group(1), m.group(2)
+ if t in ('F', 'X'):
+ v = convert_glob(v)
+ if os.path.isdir(v):
+ v = re.sub(r'([^/])$', r'\1/', v)
+ elif t == 'K':
+ KEYWORD_HASH[len(TYPEVALUE)] = v
+ TYPEVALUE.append(f'{t}:{v}')
+ elif line.strip() and not line.lstrip().startswith('#'):
+ TYPEVALUE.append(line)
+ except OSError as e:
+ sys.exit(f"Can't open MAINTAINERS file '{path}': {e}")
+
+
+def read_all_maintainer_files(root, extra_path=None, find_all=False):
+ path = extra_path or os.path.join(root, 'MAINTAINERS')
+ if os.path.isdir(path):
+ if find_all:
+ for dirpath, _, files in os.walk(path):
+ if 'MAINTAINERS' in files:
+ MFILES.append(os.path.join(dirpath, 'MAINTAINERS'))
+ else:
+ for name in os.listdir(path):
+ if not name.startswith('.'):
+ p = os.path.join(path, name)
+ if os.path.isfile(p):
+ MFILES.append(p)
+ elif os.path.isfile(path):
+ MFILES.append(path)
+ else:
+ sys.exit(f"MAINTAINER file not found '{path}'")
+ if not MFILES:
+ sys.exit(f"No MAINTAINER files found in '{path}'")
+ for p in MFILES:
+ read_maintainer_file(p)
+
+
+def find_first_section():
+ i = 0
+ while i < len(TYPEVALUE):
+ if RE_TYPE_VALUE.match(TYPEVALUE[i]):
+ break
+ i += 1
+ return i
+
+
+def find_starting_index(index):
+ while index > 0 and RE_TYPE_VALUE.match(TYPEVALUE[index]):
+ index -= 1
+ return index
+
+
+def find_ending_index(index):
+ while index < len(TYPEVALUE) and RE_TYPE_VALUE.match(TYPEVALUE[index]):
+ index += 1
+ return index
+
+
+def file_match_pattern(path, pattern):
+ if pattern.endswith('/'):
+ return re.match(pattern, path) is not None
+ if re.match(pattern, path):
+ s1 = path.count('/')
+ s2 = pattern.count('/')
+ if s1 == s2 or '(?:' in pattern:
+ return True
+ return False
+
+
+def range_is_maintained(start, end):
+ for i in range(start, end):
+ m = RE_TYPE_VALUE.match(TYPEVALUE[i])
+ if m and m.group(1) == 'S' and \
+ re.search(r'maintain|support', m.group(2), re.I):
+ return True
+ return False
+
+
+def range_has_maintainer(start, end):
+ for i in range(start, end):
+ m = RE_TYPE_VALUE.match(TYPEVALUE[i])
+ if m and m.group(1) == 'M':
+ return True
+ return False
+
+
+def get_subsystem_name(index):
+ return TYPEVALUE[find_starting_index(index)]
+
+
+def deduplicate_email(line, use_mailmap):
+ name, address = parse_email(line)
+ email = format_email(name, address)
+ if use_mailmap:
+ email = mailmap_email(email)
+ return email
+
+
+def vcs_find_signers(root, file_path, since, git_cmd='git'):
+ cmd = [git_cmd, 'log', '--no-color', '--follow',
+ f'--since={since}', '--numstat', '--no-merges',
+ '--format=GitAuthor: %an <%ae>', '--', file_path]
+ try:
+ out = subprocess.check_output(cmd, cwd=root, text=True,
+ stderr=subprocess.DEVNULL)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return [], 0
+ signers = []
+ commits = 0
+ for line in out.splitlines():
+ if line.startswith('GitAuthor: '):
+ commits += 1
+ elif re.search(r'^[ \t]*(Signed-off-by|Reviewed-by|Acked-by):',
+ line, re.I):
+ m = re.search(r':\s*(.+)$', line)
+ if m:
+ signers.append(deduplicate_email(m.group(1).strip(), True))
+ return signers, commits
+
+
+def find_maintainers(files, opts):
+ results = [] # list of (email, role)
+ seen_names = set()
+ seen_addrs = set()
+ scm, web, bug, subsystem, status = [], [], [], [], []
+
+ def add_email(line, role):
+ name, addr = parse_email(line)
+ if not addr:
+ return
+ if opts.remove_duplicates:
+ if (name and name.lower() in seen_names) or \
+ (addr.lower() in seen_addrs):
+ return
+ if name:
+ seen_names.add(name.lower())
+ seen_addrs.add(addr.lower())
+ results.append((format_email(name, addr, opts.email_usename),
+ role))
+
+ for path in files:
+ # --- Find MAINTAINERS sections matching this file ---
+ sections = [] # list of (start, end)
+ i = find_first_section()
+ while i < len(TYPEVALUE):
+ start = find_starting_index(i)
+ end = find_ending_index(i)
+ excluded = False
+ for j in range(start, end):
+ m = RE_TYPE_VALUE.match(TYPEVALUE[j])
+ if m and m.group(1) == 'X' \
+ and file_match_pattern(path, m.group(2)):
+ excluded = True
+ break
+ if not excluded:
+ for j in range(start, end):
+ m = RE_TYPE_VALUE.match(TYPEVALUE[j])
+ if not m:
+ continue
+ t, v = m.group(1), m.group(2)
+ if t == 'F' and file_match_pattern(path, v):
+ sections.append((start, end))
+ break
+ if t == 'N' and re.search(v, path):
+ sections.append((start, end))
+ break
+ i = end + 1
+
+ # --- Extract M/R/L/T/W/B/S from matched sections ---
+ for start, end in sections:
+ subname = TYPEVALUE[start]
+ for j in range(start + 1, end):
+ m = RE_TYPE_VALUE.match(TYPEVALUE[j])
+ if not m:
+ continue
+ t, v = m.group(1), m.group(2)
+ if t == 'M' and opts.email_maintainer:
+ add_email(v, f'maintainer:{subname}')
+ elif t == 'R' and opts.email_reviewer:
+ add_email(v, f'reviewer:{subname}')
+ elif t == 'L' and opts.email_list:
+ addr_only = v.split()[0]
+ suffix = '' if subname == 'THE REST' else f':{subname}'
+ if 'subscribers-only' in v:
+ if opts.email_subscriber_list:
+ add_email(addr_only, f'subscriber list{suffix}')
+ elif 'moderated' in v:
+ if getattr(opts, 'email_moderated_list', True):
+ add_email(addr_only, f'moderated list{suffix}')
+ else:
+ add_email(addr_only, f'open list{suffix}')
+ elif t == 'T':
+ scm.append(v)
+ elif t == 'W':
+ web.append(v)
+ elif t == 'B':
+ bug.append(v)
+ elif t == 'S':
+ status.append(v)
+ subsystem.append(subname)
+
+ # --- git history fallback ---
+ if opts.email and opts.git_fallback:
+ signers, _ = vcs_find_signers(opts.tree_root, path,
+ opts.git_since)
+ for s in signers:
+ add_email(s, 'git-signer')
+
+ chief_addrs = [addr for _, addr in PENGUIN_CHIEFS]
+ if getattr(opts, 'git_chief_penguins', False):
+ results = [(e, 'chief penguin:' + (r.split(':', 1)[1]
+ if ':' in r else 'THE REST'))
+ if any(addr in e for addr in chief_addrs) else (e, r)
+ for e, r in results]
+ else:
+ results = [(e, r) for e, r in results
+ if not any(addr in e for addr in chief_addrs)]
+
+ return results, scm, web, bug, subsystem, status
+
+
+def output_list(items, multiline, separator):
+ if not items:
+ return
+ if multiline:
+ for it in items:
+ print(it, flush=True)
+ else:
+ print(separator.join(items), flush=True)
+
+
+def main(argv):
+ conf_args = load_conf_args()
+ parser = argparse.ArgumentParser(add_help=False)
+ parser.add_argument('--email', action='store_true', default=True)
+ parser.add_argument('--no-email', dest='email', action='store_false')
+ parser.add_argument('--git', action='store_true')
+ parser.add_argument('--no-git', dest='git', action='store_false',
+ default=True)
+ parser.add_argument('--git-fallback', action='store_true', default=True)
+ parser.add_argument('--no-git-fallback', dest='git_fallback',
+ action='store_false')
+ parser.add_argument('--git-since', default='1-year-ago')
+ parser.add_argument('--m', dest='email_maintainer', action='store_true',
+ default=True)
+ parser.add_argument('--no-m', dest='email_maintainer',
+ action='store_false')
+ parser.add_argument('--r', dest='email_reviewer', action='store_true',
+ default=True)
+ parser.add_argument('--no-r', dest='email_reviewer',
+ action='store_false')
+ parser.add_argument('--l', dest='email_list', action='store_true',
+ default=True)
+ parser.add_argument('--no-l', dest='email_list', action='store_false')
+ parser.add_argument('--s', dest='email_subscriber_list',
+ action='store_true', default=False)
+ parser.add_argument('--n', dest='email_usename', action='store_true',
+ default=True)
+ parser.add_argument('--no-n', dest='email_usename',
+ action='store_false')
+ parser.add_argument('--remove-duplicates', action='store_true',
+ default=True)
+ parser.add_argument('--no-remove-duplicates',
+ dest='remove_duplicates', action='store_false')
+ parser.add_argument('--mailmap', action='store_true', default=True)
+ parser.add_argument('--no-mailmap', dest='mailmap',
+ action='store_false')
+ parser.add_argument('--scm', action='store_true')
+ parser.add_argument('--status', action='store_true')
+ parser.add_argument('--subsystem', action='store_true')
+ parser.add_argument('--web', action='store_true')
+ parser.add_argument('--bug', action='store_true')
+ parser.add_argument('--sections', action='store_true')
+ parser.add_argument('--json', action='store_true')
+ parser.add_argument('--git-chief-penguins', action='store_true',
+ default=False)
+ parser.add_argument('--moderated', dest='email_moderated_list',
+ action='store_true', default=True)
+ parser.add_argument('--no-moderated', dest='email_moderated_list',
+ action='store_false')
+ parser.add_argument('--multiline', action='store_true', default=True)
+ parser.add_argument('--no-multiline', dest='multiline',
+ action='store_false')
+ parser.add_argument('--separator', default=', ')
+ parser.add_argument('--no-tree', dest='tree', action='store_false',
+ default=True)
+ parser.add_argument('-f', '--file', dest='from_filename',
+ action='store_true')
+ parser.add_argument('--maintainer-path')
+ parser.add_argument('-v', '--version', action='store_true')
+ parser.add_argument('-h', '--help', action='store_true')
+ parser.add_argument('files', nargs='*')
+
+ opts = parser.parse_args(conf_args + argv)
+
+ if opts.help:
+ parser.print_help()
+ return 0
+ if opts.version:
+ print(f'get_maintainer.py {V}')
+ return 0
+ if not opts.files:
+ sys.exit('missing patchfile or -f file')
+
+ # --sections implies no email
+ if opts.sections:
+ opts.email = False
+ elif opts.email is None:
+ opts.email = True
+
+ root = os.getcwd()
+ # If not in a tree, fall back to the nearest ancestor with MAINTAINERS.
+ cur = root
+ while cur != '/':
+ if os.path.isfile(os.path.join(cur, 'MAINTAINERS')):
+ root = cur
+ break
+ cur = os.path.dirname(cur)
+ opts.tree_root = root
+
+ read_all_maintainer_files(root, opts.maintainer_path,
+ getattr(opts, 'find_maintainer_files', False))
+ if not TYPEVALUE:
+ print(f"[get_maintainer] warning: no MAINTAINERS entries "
+ f"parsed from {root}", file=sys.stderr)
+ if opts.mailmap:
+ read_mailmap(root)
+
+ def is_patch_format(text):
+ """Line-anchored detection (never matches regex literals)."""
+ for i, line in enumerate(text.splitlines()):
+ if i > 100:
+ break
+ if re.match(r'^(?:diff --git |diff -u |--- [^ ]|\+\+\+ b/)',
+ line):
+ return True
+ if re.match(r'^From [0-9a-f]{40} ', line):
+ return True
+ if line == '---':
+ return True
+ return False
+
+ def vcs_file_exists(path):
+ for cmd in (['git', 'ls-files', '--error-unmatch'],
+ ['hg', 'files']):
+ try:
+ r = subprocess.run(cmd + [path], capture_output=True,
+ cwd=root, timeout=5)
+ if r.returncode == 0:
+ return True
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ continue
+ return False
+
+ files = []
+ for f in opts.files:
+ f = os.path.normpath(f)
+ if opts.from_filename:
+ if not os.path.exists(f):
+ sys.exit(f"file '{f}' not found")
+ files.append(f)
+ continue
+ try:
+ with open(f) as fh:
+ content = fh.read()
+ except OSError as exc:
+ sys.exit(f"Can't open {f}: {exc}")
+ # Match Perl's behavior: filename takes priority if it's a real
+ # file (tracked in VCS or on disk); only parse as patch when it
+ # genuinely looks like a patch.
+ if not is_patch_format(content) or vcs_file_exists(f):
+ files.append(f)
+ else:
+ for line in content.splitlines():
+ m = re.search(r'^diff --git a/(\S+) b/(\S+)', line)
+ if m:
+ files.append(m.group(1))
+ files.append(m.group(2))
+ continue
+ m = re.match(r'^(?:\+\+\+|---)\s+(?:[ab]/)?(\S+)',
+ line)
+ if m and m.group(1) != '/dev/null':
+ files.append(m.group(1))
+
+ files = sorted(set(files))
+
+ results, scm, web, bug, subsystem, status = find_maintainers(files, opts)
+
+ if opts.json:
+ entries = []
+ for email, role in results:
+ name, addr = parse_email(email)
+ e = {'name': name, 'email': addr}
+ if role:
+ e['role'] = role
+ entries.append(e)
+ out = {'maintainers': entries}
+ if scm: out['scm'] = scm
+ if status: out['status'] = status
+ if subsystem: out['subsystem'] = subsystem
+ if web: out['web'] = web
+ if bug: out['bug'] = bug
+ print(json.dumps(out))
+ return 0
+
+ if opts.email and results:
+ formatted = [f'{e} ({r})' if r else e for e, r in results]
+ output_list(formatted, opts.multiline, opts.separator)
+ if opts.scm:
+ output_list(scm, opts.multiline, opts.separator)
+ if opts.status:
+ output_list(status, opts.multiline, opts.separator)
+ if opts.subsystem:
+ output_list(subsystem, opts.multiline, opts.separator)
+ if opts.web:
+ output_list(web, opts.multiline, opts.separator)
+ if opts.bug:
+ output_list(bug, opts.multiline, opts.separator)
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main(sys.argv[1:]))
\ No newline at end of file
--
2.43.0
next prev parent reply other threads:[~2026-10-18 11:06 UTC|newest]
Thread overview: 11+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-18 11:06 [RFC PATCH 0/7] scripts: convert seven more Perl utilities " Breno Rodrigues Alves
2026-10-18 11:06 ` [RFC PATCH 1/7] scripts: convert checkdeclares.pl " Breno Rodrigues Alves
2026-10-18 11:06 ` [RFC PATCH 2/7] scripts: convert checkversion.pl " Breno Rodrigues Alves
2026-10-18 11:06 ` [RFC PATCH 3/7] scripts: convert extract_xc3028.pl " Breno Rodrigues Alves
2026-10-18 11:06 ` [RFC PATCH 4/7] scripts: convert generate_initcall_order.pl " Breno Rodrigues Alves
2026-10-18 11:06 ` Breno Rodrigues Alves [this message]
2026-10-18 11:06 ` [RFC PATCH 6/7] scripts: convert leaking_addresses.pl " Breno Rodrigues Alves
2026-10-18 11:06 ` [RFC PATCH 7/7] scripts: convert recordmcount.pl " Breno Rodrigues Alves
2026-10-18 20:59 ` [RFC PATCH 0/7] scripts: convert seven more Perl utilities " Jonathan Corbet
2026-10-18 23:19 ` Breno Rodrigues Alves
2026-10-19 10:05 ` Heiko Carstens
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261018110638.9947-6-breno3011alves@gmail.com \
--to=breno3011alves@gmail.com \
--cc=apw@canonical.com \
--cc=dwaipayanray1@gmail.com \
--cc=joe@perches.com \
--cc=linux-kernel@vger.kernel.org \
--cc=lukas.bulwahn@gmail.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®